---
title: "Caching & Redis: The 'Sticky Note' Mental Model"
description: "Why does Redis make everything faster? A mastery guide to cache invalidation (the hardest problem in CS), eviction strategies, and Redis data types."
pubDate: "2023-05-25"
lang: "en"
slug: "caching-redis-mastery"
url: "https://hoangyell.com/caching-redis-mastery/"
author: "HoangYell"
translation_vi: "https://hoangyell.com/vi/caching-redis-mastery/"
categories: ["System Design"]
tags: ["system-design", "redis", "performance", "python", "caching"]
heroImage: "https://raw.githubusercontent.com/yellcamap/img/refs/heads/main/photo-1515879218367-8466d910aaa4.webp"
---

"We'll add Redis and it'll fix the performance."

Every engineer has said this. Few understand *why* it works - or when it catastrophically fails.

Caching is the single most powerful performance optimization you can apply. It is also the easiest way to serve **stale data** and have users see incorrect information for hours.

This is the **Mastery Guide** to Caching. We'll cover Redis data types, the 3 eviction strategies, the hardest problem in Computer Science, and the patterns that separate seniors from juniors.

***

## Part 1: Foundations (The Mental Model)

### The Sticky Note vs. The Filing Cabinet

Every time your application needs data, it faces a choice:

*   **The Database = The Filing Cabinet** (in the basement)
    Accurate. Has everything. But you have to physically walk downstairs, open the drawer, find the folder, and walk back up. **Slow**: 5–100ms per trip.

*   **The Cache (Redis) = The Sticky Note on your Monitor**
    You already wrote the answer here earlier. Just look up. **Fast**: 0.1–1ms.

The goal: answer from the Sticky Note whenever possible. Only go to the Basement when the Sticky Note doesn't exist or is outdated.

### The Two Fundamental Questions

Every caching decision comes down to two questions:

1.  **How long is the data "fresh"?** (TTL - Time To Live)
2.  **What happens when the cache is full?** (Eviction Strategy)

***

## Part 2: The Investigation (Redis Data Types)

Redis is not just a key-value store. It has 5 primary data structures, each solving a different problem.

| Type | Real-World Object | Use Case |
|:---|:---|:---|
| **String** | A Sticky Note | Session tokens, simple cache values, counters. |
| **Hash** | A Mini-Spreadsheet | User profile (`user:123 → {name, email, age}`). |
| **List** | A Queue (FIFO) | Activity feed (latest 50 actions). Task queues. |
| **Set** | A Box of Unique Marbles | Unique visitors today. Tags on a post. |
| **Sorted Set** | A Ranked Leaderboard | Top 10 users by score. Rate limiting windows. |

### The TTL (Time To Live)

Every cache entry should have a TTL - an expiration time. Without it, your cache is a **memory leak**.

```python
import redis

r = redis.Redis()

# Set with a 5-minute TTL
r.set("user:123:profile", json.dumps(user_data), ex=300)

# Check remaining TTL
r.ttl("user:123:profile")  # Returns seconds remaining, -1 if no TTL, -2 if key gone
```

***

## Part 3: The Diagnosis (Cache Invalidation - The Hardest Problem)

Phil Karlton famously said:
> "There are only two hard things in Computer Science: cache invalidation and naming things."

### The 3 Eviction Strategies (When the Cache is Full)

When Redis runs out of memory, it must delete something. Which?

| Strategy | What Gets Deleted | Use When |
|:---|:---|:---|
| **LRU** (Least Recently Used) | The key that hasn't been accessed the longest. | General purpose. Safe default. ("Delete the oldest stale data"). |
| **LFU** (Least Frequently Used) | The key accessed the fewest times overall. | When access frequency matters. ("Viral content stays; niche stays too"). |
| **FIFO** (allkeys-random) | A random key. | Almost never. Dangerous. |

**Set your eviction policy**: 
```
maxmemory-policy allkeys-lru
```

### The 3 Cache Failure Patterns

**1. Cache Miss (Normal)**
Key not in cache → fetch from DB → store in cache → return. This is expected.

**2. Cache Stampede (Danger)**
TTL expires. 10,000 users all request the same data at the same moment. All of them miss. All of them hit the DB at once. **The DB collapses.**

*Fix*: Use a **lock** (Redis `SETNX`) when regenerating a cache entry. Only one process regenerates; others wait.

```python
import redis, time

r = redis.Redis()

def get_heavy_data(key: str):
    cached = r.get(key)
    if cached:
        return json.loads(cached)

    # Only ONE process should regenerate
    lock_key = f"lock:{key}"
    if r.set(lock_key, "1", nx=True, ex=10):  # nx=SETNX (only if NOT exists)
        data = expensive_db_query()
        r.set(key, json.dumps(data), ex=300)
        r.delete(lock_key)
        return data
    else:
        time.sleep(0.1)
        return get_heavy_data(key)  # Retry
```

**3. Cache Poisoning (Disaster)**
You cache wrong data. Now every user sees the wrong data until the TTL expires. There's no easy fix - you must forcibly delete the key.

```bash
# Nuclear option: delete a specific key
redis-cli DEL "user:123:profile"

# Delete all keys matching a pattern (careful in production!)
redis-cli --scan --pattern "user:*" | xargs redis-cli DEL
```

***

## Part 4: The Resolution (Python Cookbook)

### 1. The Cache-Aside Pattern (The Standard)

The application controls the cache. The most common pattern.

```markdy
scene width=1150 height=650
theme: light=doodle dark=nebula
layout auto

browser Client "Web / Mobile App"
service API "Backend Service"
cache Redis "Redis Cluster (TTL 300s)"
database DB "PostgreSQL (Master)"

group storage: Redis DB

beat cache_hit "1. Fast Path (Cache Hit)" {
  show $nodes stagger=50ms
  frame Client API Redis zoom=1.12
  Client -> API "GET /user/123"
  API -> Redis "GET user:123"
  API <- Redis "cached JSON (0.5ms)"
  Client <- API "200 OK"
  glow Redis color=#22c55e

}
beat cache_miss "2. Slow Path (Cache Miss & Backfill)" {
  frame API Redis DB zoom=1.15
  Client -> API "GET /user/456"
  API -> Redis "GET user:456"
  API <- Redis "nil (Miss)"
  API -> DB "SELECT * FROM users"
  API <- DB "row data (15ms)"
  API ~> Redis "SET user:456 ex=300"
  Client <- API "200 OK"
  glow DB color=#f59e0b
}

player:
    playback:
      loop true
    controls:
      speed true
      speeds "0.25"
      fullscreen true
      gif true
      theme true
    interaction:
      zoom true
      pan true
      click_to_play true
      double_click_to_reset true
      keyboard false
    chrome:
      badge true
      progress none
```

```python
def get_user(user_id: int) -> dict:
    cache_key = f"user:{user_id}"
    
    # 1. Try cache first
    cached = redis_client.get(cache_key)
    if cached:
        return json.loads(cached)  # Cache HIT
    
    # 2. Cache miss: query the DB
    user = User.objects.get(id=user_id)
    data = serialize(user)
    
    # 3. Store in cache for next time (TTL: 5 minutes)
    redis_client.set(cache_key, json.dumps(data), ex=300)
    
    return data  # Cache MISS
```

### 2. Invalidate on Write (Keep the Cache Honest)

When data changes, delete the old cache entry *immediately*.

```python
def update_user(user_id: int, new_data: dict):
    # 1. Update the DB
    User.objects.filter(id=user_id).update(**new_data)
    
    # 2. IMMEDIATELY invalidate the cache (don't wait for TTL!)
    redis_client.delete(f"user:{user_id}")
```

### 3. Rate Limiting (Redis as Counter)

A classic use case: limit an API to 100 requests per user per minute.

```python
def is_rate_limited(user_id: int, limit: int = 100) -> bool:
    key = f"rate:{user_id}:{int(time.time() // 60)}"  # Key changes every minute
    
    count = redis_client.incr(key)  # Atomic increment
    if count == 1:
        redis_client.expire(key, 60)  # Set TTL on first increment
    
    return count > limit
```

***

## Final Mental Model

```
Cache (Redis) -> The Sticky Note. Fast, temporary, might be outdated.
Database      -> The Filing Cabinet. Slow, accurate, permanent.

LRU Eviction  -> "Delete the one nobody visited in the longest time."
Cache Stampede -> 10,000 people flip over the same sticky note at once.
Cache Invalidation -> The hardest problem: knowing WHEN to throw away the sticky note.
```

**Rules**:
*   Set a TTL on **everything**. No TTL = Memory leak.
*   Invalidate cache on **writes**, not just by TTL.
*   Cache at the **right layer**: Avoid caching partial data that is hard to invalidate.
