Caching — Deep Dive Reference

From fundamentals to production patterns.

0/0 checks

1. Why Cache

Access Type Latency Notes
L1 CPU cache ~1 ns Per core
L2/L3 CPU cache 4–40 ns Shared across cores
RAM ~100 ns Main memory
Redis (local) ~0.1–1 ms In-process network
SSD disk ~100 µs NVMe
HDD disk ~5–10 ms Rotational
DB query (cold) 5–50 ms Index + disk I/O
Cross-region network 50–200 ms Geographic distance

Read amplification: a single app query can fan out to dozens of DB reads. A cache short-circuits this.

Cost reduction: DB CPU is expensive; Redis/Memcached nodes are cheap per RPS served.

A DB query (cold) takes 5–50ms and Redis takes ~0.1–1ms. Why is the DB so much slower even though both are ultimately reading from fast storage?


2. Cache Tier Hierarchy

Each tier trades latency for sharing scope: the faster a cache is, the fewer things can see what's in it. CPU cache is invisible outside one core; an in-process cache is invisible outside one app instance; only the distributed tier is actually shared state across a fleet.

graph TD
    classDef client fill:#7f8c8d,stroke:#616a6b,color:#fff
    classDef edge fill:#8e44ad,stroke:#6c3483,color:#fff
    classDef app fill:#3498db,stroke:#2471a3,color:#fff
    classDef cache fill:#e67e22,stroke:#ba6018,color:#fff
    classDef db fill:#2c3e50,stroke:#1a252f,color:#fff
    classDef cpu fill:#27ae60,stroke:#1e8449,color:#fff

    Client["Client Browser<br/>issues the request"]:::client

    subgraph EDGE["Edge Tier — global, per-PoP"]
        CDN["CDN Edge Cache<br/>~5ms, geographic"]:::edge
    end

    subgraph APP["Application Tier — per instance"]
        CPU["CPU L1/L2/L3 Cache<br/>1–40ns, per core"]:::cpu
        AppL1["App In-Process Cache<br/>~0.01ms, per instance heap"]:::app
    end

    subgraph SHARED["Shared Cache Tier — fleet-wide"]
        Redis["Distributed Cache<br/>Redis / Memcached<br/>~0.5ms, shared"]:::cache
    end

    subgraph DBT["Database Tier"]
        DBQueryCache["DB Query Cache<br/>~1ms, per DB node"]:::db
        DB["Database Disk<br/>~10ms+"]:::db
    end

    Client -->|"request"| CDN
    CDN -->|"miss: forward"| AppL1
    CPU -.->|"used transparently<br/>by the app process"| AppL1
    AppL1 -->|"miss: fan out"| Redis
    Redis -->|"miss: query"| DBQueryCache
    DBQueryCache -->|"miss: disk read"| DB

Why can't an app in-process cache just replace the shared Redis/Memcached tier entirely, given it's ~50x faster (0.01ms vs 0.5ms)?


3. Cache-Aside (Lazy Loading)

The application owns the cache interaction.

sequenceDiagram
    participant App
    participant Cache
    participant DB

    App->>Cache: GET key
    alt cache hit
        Cache-->>App: return value
        Note over App: DB never touched — fast path
    else cache miss (cold key or expired TTL)
        Cache-->>App: nil
        App->>DB: SELECT ...
        DB-->>App: row data
        App->>Cache: SET key value EX ttl
        Note over Cache: key now warm until TTL expiry
        App-->>App: return value
    end
    Note over App,DB: Risk: if another process updates the DB directly,<br/>this cache entry goes stale until TTL expiry

Code pattern (Python/Redis):

def get_user(user_id):
    key = f"user:{user_id}"
    data = redis.get(key)
    if data:
        return json.loads(data)
    user = db.query("SELECT * FROM users WHERE id = %s", user_id)
    redis.setex(key, 300, json.dumps(user))
    return user

Pros: Only caches what's actually read. Cache failures don't break writes.

Cons: First read is always slow (cold miss). Stale data possible if DB changes outside the app.

Thundering herd risk: Many requests miss simultaneously on cold start or expiry — all hit DB at once. See §11.

A cache-aside app updates a row directly in the DB via a one-off admin script, bypassing the application code entirely. What happens to reads of that row?


4. Write-Through

Every write goes to cache AND DB in the same operation.

sequenceDiagram
    participant App
    participant Cache
    participant DB
    participant App2 as Another App Instance

    App->>Cache: SET key value
    activate Cache
    Cache->>DB: INSERT/UPDATE (synchronous)
    DB-->>Cache: ack
    Cache-->>App: ack
    deactivate Cache
    Note over App,DB: Write latency = cache write + DB write,<br/>paid on every write regardless of future reads

    App2->>Cache: GET key
    Cache-->>App2: return value (already fresh)
    Note over App2: No stale read possible —<br/>cache was updated in the same operation as the DB

Pros: Cache is always consistent with DB. No stale reads after writes.

Cons: Write latency = cache latency + DB latency. Cache fills with data that may never be read.

In the write-through diagram, "Another App Instance" issues a GET right after the first write and gets the fresh value with no DB round trip. What would break that guarantee if the Cache→DB step were made asynchronous instead of synchronous?


5. Write-Behind (Write-Back)

Write to cache immediately; flush to DB asynchronously.

sequenceDiagram
    participant App
    participant Cache
    participant Queue as Dirty-Key Queue
    participant DB

    App->>Cache: SET key value
    Cache-->>App: ack (fast — DB not involved yet)
    Cache->>Queue: enqueue dirty key
    Note over Queue: keys batch up until<br/>flush interval or batch size hit

    par periodic flush
        Queue->>DB: batched INSERT/UPDATE
        DB-->>Queue: ack
        Queue->>Queue: mark keys clean
    end

    Note over Cache,DB: Risk: if Cache/Queue crashes before flush,<br/>queued writes are lost — the client already got "ack"

Pros: Extremely fast writes. Batch DB writes reduce I/O.

Cons: Data loss if cache crashes before flush. Complexity in failure handling.

The app already received "ack" for a write-behind SET. Ten seconds later, the cache process crashes before its next batch flush. Is that write safe?


6. Read-Through

Cache sits in front of DB and fetches transparently on miss.

sequenceDiagram
    participant App
    participant Cache as Cache + Loader<br/>(e.g. Spring @Cacheable)
    participant DB

    App->>Cache: GET key
    alt cache hit
        Cache-->>App: value
    else cache miss
        Note over Cache,DB: The cache itself calls the DB —<br/>App code never sees this branch
        Cache->>DB: SELECT ...
        DB-->>Cache: data
        Cache->>Cache: store fetched value
        Cache-->>App: data
    end
    Note over App: App's code is identical on hit or miss —<br/>always just "GET key"

Difference from cache-aside: App only talks to cache. Cache library/provider handles DB fetching. Example: Spring Cache with @Cacheable.

In the read-through diagram, the note says the app's code is "identical on hit or miss — always just GET key." In cache-aside's Python example, is that also true?


7. All 4 Patterns — Side-by-Side

Solid arrows are synchronous (the caller waits); dashed arrows are asynchronous (the caller already moved on). That one visual distinction is most of what separates write-through's safety from write-behind's speed.

graph LR
    classDef app fill:#3498db,stroke:#2471a3,color:#fff
    classDef cache fill:#e67e22,stroke:#ba6018,color:#fff
    classDef db fill:#2c3e50,stroke:#1a252f,color:#fff

    subgraph CacheAside["Cache-Aside — app owns both paths"]
        CA_App["App"]:::app -->|"read: GET"| CA_Cache["Cache"]:::cache
        CA_App -->|"on miss: SELECT"| CA_DB["DB"]:::db
        CA_App -->|"on miss: SET"| CA_Cache
        CA_App -->|"write: UPDATE"| CA_DB
    end

    subgraph WriteThrough["Write-Through — sync, consistent"]
        WT_App["App"]:::app -->|"write: SET"| WT_Cache["Cache"]:::cache
        WT_Cache -->|"sync write"| WT_DB["DB"]:::db
    end

    subgraph WriteBehind["Write-Behind — fast, eventually consistent"]
        WB_App["App"]:::app -->|"write: SET"| WB_Cache["Cache"]:::cache
        WB_Cache -.->|"async flush"| WB_DB["DB"]:::db
    end

    subgraph ReadThrough["Read-Through — cache owns the DB fetch"]
        RT_App["App"]:::app -->|"read: GET"| RT_Cache["Cache"]:::cache
        RT_Cache -->|"on miss: SELECT"| RT_DB["DB"]:::db
    end
Pros: Only caches what's actually read. Cache failures don't break writes.
Cons: First read is always slow (cold miss). Stale data possible if DB changes outside the app.
Pros: Cache is always consistent with DB. No stale reads after writes.
Cons: Write latency = cache latency + DB latency. Cache fills with data that may never be read.
Pros: Extremely fast writes. Batch DB writes reduce I/O.
Cons: Data loss if cache crashes before flush. Complexity in failure handling.
Pros: App only talks to cache — simpler application code, no manual miss-handling.
Cons: Locked into whatever the cache library/provider's loader supports (e.g. Spring Cache's @Cacheable) — less control than hand-rolled cache-aside.

You need the fastest possible writes and can tolerate losing the last few seconds of data on a crash. Which of the 4 patterns fits, and which is the wrong choice for the same requirement?

Live Demo: Cache vs DB, Same Two Operations, Three Strategies

Pick a strategy below, then Read/Write a key and watch what actually happens to the cache and the DB — they diverge only on the write path. Reads behave identically everywhere: a hit returns from cache, a miss falls through to the DB and repopulates the cache.

Writes go straight to the DB; the cache entry is invalidated, not updated. The next read repopulates it from the DB.
Writes update cache and DB in the same synchronous operation — both are consistent immediately.
Writes land in cache only; the DB update is queued and applied later by a flush — fast, but not yet durable.

8. Eviction Policies

Algorithms

Policy Description Use Case
LRU Evict least recently used General purpose
LFU Evict least frequently used Skewed access patterns
FIFO Evict oldest inserted key Simple queues
TTL Expire after time-to-live Session data, tokens
Random Evict random key When access is uniform
LRU-K LRU using K-th most recent access Avoids one-time scan pollution
2Q Two queues: probationary + protected Better than LRU for scan resistance

Redis maxmemory-policy options

# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru
Policy Behavior
noeviction Return error when memory full
allkeys-lru Evict any key by LRU
volatile-lru Evict keys with TTL set, by LRU
allkeys-lfu Evict any key by LFU
volatile-lfu Evict keys with TTL set, by LFU
allkeys-random Evict any key randomly
volatile-random Evict TTL keys randomly
volatile-ttl Evict keys with shortest TTL first

Rule of thumb: Use allkeys-lru for pure caches. Use volatile-ttl when mixing persistent and ephemeral keys.

A Redis instance stores both session cache keys (with TTLs) and a permanent feature-flag hash (no TTL) that must never be evicted. Which maxmemory-policy fits, and why would allkeys-lru be wrong here?


9. Redis Data Structures for Caching

String — simple key-value

SET user:1001 '{"name":"alice","role":"admin"}' EX 300
GET user:1001
INCR page:views:home   # atomic counter

Hash — object fields without JSON serialization

HSET user:1001 name alice role admin age 30
HGET user:1001 name
HGETALL user:1001
# Update one field without fetching/re-serializing whole object
HSET user:1001 age 31

Sorted Set — leaderboards, rate limiting

# Leaderboard
ZADD leaderboard 9500 player:alice
ZADD leaderboard 8200 player:bob
ZREVRANGE leaderboard 0 9 WITHSCORES   # top 10

# Sliding window rate limit (requests in last 60s)
ZADD rate:user:1001 1719651233 req:uuid1
ZREMRANGEBYSCORE rate:user:1001 -inf (now-60)
ZCARD rate:user:1001   # current count

List — queues, recent activity

LPUSH recent:user:1001 "viewed:product:42"
LTRIM recent:user:1001 0 99   # keep last 100
LRANGE recent:user:1001 0 -1

Bitmap — feature flags, daily active users

# User 1001 active today (bit at offset = user_id)
SETBIT dau:2026-06-29 1001 1
BITCOUNT dau:2026-06-29   # total DAU
GETBIT dau:2026-06-29 1001

HyperLogLog — unique count (approximate, 0.81% error)

PFADD unique:visitors:home user:1001 user:1002 user:1001
PFCOUNT unique:visitors:home   # returns 2, not 3

Uses ~12KB regardless of cardinality — far cheaper than a Set for billions of IDs.

PFADD unique:visitors:home user:1001 user:1002 user:1001 followed by PFCOUNT returns exactly 2, not 3. Is that count guaranteed to be exact in general, and why does HyperLogLog trade that away?


10. Redis vs Memcached

Feature Redis Memcached
Data structures String, Hash, List, Set, ZSet, Stream, Geo, HLL String only
Persistence RDB snapshots + AOF None
Replication Primary-replica, async No built-in
Cluster Redis Cluster (hash slots) Client-side sharding only
Pub/Sub Yes No
Lua scripting Yes (EVAL) No
Transactions MULTI/EXEC + WATCH No
Memory efficiency Higher overhead per key (object metadata) Lower overhead, better for simple KV at scale
Multi-threading Single-threaded command loop (I/O multi-threaded since 6.0) Multi-threaded
Max value size 512 MB 1 MB

Choose Memcached when: pure string caching, extreme memory efficiency matters, multi-threaded performance on many cores.

Choose Redis when: you need persistence, replication, complex data structures, pub/sub, or Lua atomicity.

A team needs a sliding-window rate limiter and a leaderboard, both backed by the same cache layer. Why does that requirement alone rule out Memcached?


11. Cache Stampede / Thundering Herd

Problem: A hot key expires. Thousands of requests all miss simultaneously, all hit DB at once, DB collapses.

The danger isn't the miss itself — cache-aside is designed to tolerate misses. It's that a hot key means hundreds or thousands of in-flight requests were relying on that one cached value at the same moment, so the instant it disappears, all of them fall through to the DB together, as if the cache had never been there at all.

sequenceDiagram
    participant R1 as Request 1
    participant R2 as Request 2
    participant RN as Request N (thousands more)
    participant Cache
    participant DB

    Note over Cache: hot_key's TTL just expired — cache now empty for this key
    par near-simultaneous misses
        R1->>Cache: GET hot_key
        Cache-->>R1: nil (miss)
    and
        R2->>Cache: GET hot_key
        Cache-->>R2: nil (miss)
    and
        RN->>Cache: GET hot_key
        Cache-->>RN: nil (miss)
    end
    Note over R1,RN: all of them saw the same empty key within milliseconds —<br/>none knows another is about to run the identical query
    rect rgb(90, 30, 30)
    par stampede — every miss independently falls through to the DB
        R1->>DB: SELECT (recompute hot_key)
        R2->>DB: SELECT (recompute hot_key)
        RN->>DB: SELECT (recompute hot_key)
    end
    end
    Note over DB: DB was sized for a trickle of cache misses,<br/>not the full read volume at once — CPU/connections saturate
1. Steady state. hot_key is cached and its TTL is ticking down. Thousands of requests/sec hit Redis and the DB sees none of that traffic.
2. TTL expires. The single cached value for hot_key is gone — but request volume hasn't dropped even slightly; it's the same hot key it was a second ago.
3. Every in-flight request misses at once. Because they were all reading the same key, they all discover the miss within the same few milliseconds — not staggered, not one-at-a-time.
4. Each miss independently falls through to the DB. Cache-aside's own miss-handling logic runs on every one of those requests — no request has any way of knowing thousands of others are about to issue the identical query.
5. The DB collapses. It was provisioned for a steady trickle of cache misses, not the full read volume landing in one burst. Connections and CPU saturate, latency spikes for unrelated queries too, and the outage can cascade well beyond the one hot key.

Solution 1: Mutex / Distributed Lock

def get_with_lock(key):
    val = redis.get(key)
    if val:
        return val
    lock_key = f"lock:{key}"
    if redis.set(lock_key, "1", nx=True, ex=5):  # acquire lock
        try:
            val = db.fetch(key)
            redis.setex(key, 300, val)
            return val
        finally:
            redis.delete(lock_key)
    else:
        time.sleep(0.05)        # wait and retry
        return get_with_lock(key)

Solution 2: XFetch (Probabilistic Early Expiration)

Recompute before expiry based on probability that increases as TTL decreases.

import math, random, time

def xfetch(key, ttl, beta=1.0):
    value, delta, expiry = cache_get_with_metadata(key)
    if time.time() - beta * delta * math.log(random.random()) >= expiry:
        # probabilistically recompute early
        value = db.fetch(key)
        cache_set_with_metadata(key, value, ttl)
    return value

Solution 3: Stale-While-Revalidate

Return stale value immediately; refresh in background.

def get_stale_ok(key, ttl, stale_ttl=60):
    val = redis.get(key)
    if val:
        return val
    # Check extended stale window
    stale = redis.get(f"stale:{key}")
    if stale:
        threading.Thread(target=refresh, args=(key, ttl)).start()
        return stale
    return refresh_sync(key, ttl)

Solution 4: Background Refresh

Cron/worker refreshes hot keys before they expire. Works well for known-hot keys.

The mutex solution and XFetch both prevent a DB pile-up, but in different ways. What specifically does XFetch avoid that the mutex approach doesn't?


12. Cache Warming

Why it matters: A cold cache after deploy → all traffic hits DB → DB overload → cascading failure.

Strategies

Pre-warming on deploy (best for known hot keys):

# During deploy pipeline, before shifting traffic
python warm_cache.py --keys top_products,homepage,config

Lazy warming: Cache-aside naturally warms on first reads. Acceptable if DB can handle initial cold traffic.

Scheduled warming job:

# K8s CronJob, runs every 5 min for known-hot queries
def warm():
    top_products = db.query("SELECT * FROM products ORDER BY views DESC LIMIT 100")
    for p in top_products:
        redis.setex(f"product:{p.id}", 600, json.dumps(p))

Shadow traffic replay: Replay recent production logs against new cache before cutover.

Rule: Never deploy a stateful cache service without a warm-up phase. Use feature flags or canary to shift traffic gradually.

Lazy warming is called "acceptable if DB can handle initial cold traffic." A team relies on lazy warming alone and then deploys during peak hours with a cache flush. What's the risk, in the section's own terms?


13. Cache Invalidation Patterns

"There are only two hard things in computer science: cache invalidation and naming things." — Phil Karlton

TTL-Based

Simplest. Set EX on every key. Accept eventual staleness.

SET product:42 '...' EX 300

Event-Driven Invalidation

DB triggers or application events delete cache entries on writes.

def update_product(product_id, data):
    db.execute("UPDATE products SET ... WHERE id = %s", product_id)
    redis.delete(f"product:{product_id}")          # invalidate
    redis.delete(f"product:list:category:{data.category_id}")  # invalidate list

Tag-Based Invalidation

Group related keys under a tag; invalidate all by tag.

# On write: record tag → keys mapping
redis.sadd("tag:category:5", "product:42", "product:43")
# On category update:
keys = redis.smembers("tag:category:5")
redis.delete(*keys)
redis.delete("tag:category:5")

Version Keys (Cache Busting)

Append a version to the key. Old keys expire naturally via TTL.

version = redis.get("product:42:version") or 1
key = f"product:42:v{version}"
data = redis.get(key)
# To invalidate:
redis.incr("product:42:version")

Incrementing product:42:version doesn't delete product:42:v1. What actually removes the old versioned key, and what goes wrong if it was set without a TTL?


14. Distributed Cache Consistency

The Write Race Problem

Two app instances update the same key simultaneously — last write wins but may overwrite a newer value.

sequenceDiagram
    participant A as App Instance A
    participant B as App Instance B
    participant Cache

    rect rgb(90, 30, 30)
    Note over A,B: Unsafe — plain GET + SET, no coordination
    A->>Cache: GET user:1 → v1
    B->>Cache: GET user:1 → v1
    A->>Cache: SET user:1 v2
    B->>Cache: SET user:1 v3 (B never saw v2, overwrites blindly)
    Note over Cache: v2 is gone — B's write didn't know v2 existed
    end

    rect rgb(30, 70, 40)
    Note over A,B: Safe — WATCH / MULTI / EXEC (optimistic locking)
    A->>Cache: WATCH user:1, GET user:1 → v1
    B->>Cache: WATCH user:1, GET user:1 → v1
    A->>Cache: MULTI, SET user:1 v2, EXEC
    Cache-->>A: EXEC succeeds — user:1 unchanged since A's WATCH
    B->>Cache: MULTI, SET user:1 v3, EXEC
    Cache-->>B: EXEC returns nil — user:1 changed since B's WATCH
    B->>Cache: retry: GET user:1 → v2, recompute, MULTI/EXEC
    Note over Cache: no write silently lost — B is forced to retry on fresh data
    end

Optimistic locking doesn't prevent the race from happening — B still reads the stale v1 first. What it prevents is B's write from landing unnoticed: EXEC fails atomically the moment the watched key changed underneath it, forcing B back through the read-transform-write loop until it succeeds against current data.

In the "unsafe" half of the diagram, why is it v2 that gets lost, specifically, and not v3?

Redis WATCH / MULTI / EXEC (Optimistic Locking)

WATCH user:1
val = GET user:1
MULTI
  SET user:1 <new_value>
EXEC   # returns nil if user:1 changed since WATCH → retry
def safe_update(key, transform):
    with redis.pipeline() as pipe:
        while True:
            try:
                pipe.watch(key)
                current = pipe.get(key)
                new_val = transform(current)
                pipe.multi()
                pipe.set(key, new_val)
                pipe.execute()
                break
            except redis.WatchError:
                continue  # retry on conflict

Compare-and-Swap with Lua (atomic)

-- SET only if value matches expected
local current = redis.call('GET', KEYS[1])
if current == ARGV[1] then
  redis.call('SET', KEYS[1], ARGV[2])
  return 1
end
return 0
EVAL "<script>" 1 user:1 expected_val new_val

The Lua compare-and-swap script only calls SET if current == expected. Why is this immune to the same race that broke the plain GET+SET example, when it's still doing a read followed by a write?


15. CDN vs Application Cache vs DB Cache

Dimension CDN Edge Cache App In-Process Cache Distributed Cache Redis DB Query Cache
What it caches Static assets, rendered HTML, API responses Hot objects in heap Shared computed data, sessions Query result sets
Scope Global, per-PoP Per app instance Shared across all instances Per DB node
Latency ~5–50ms (geographic) ~0.01ms (local RAM) ~0.5–2ms (network) ~1ms (shared memory)
Invalidation Purge API, TTL, surrogate keys In-memory eviction, restart DEL/EXPIRE, event-driven AUTO on write (MySQL 8 removed it)
Use when Public static/cacheable content Single-instance hot data Multi-instance shared state Read-heavy reporting queries
Cost CDN egress pricing Free (heap memory) Redis node cost Included with DB
Risk Stale public content No sharing across pods Network latency + connection pool MySQL removed query cache in 8.0

The table lists "DB Query Cache" with invalidation "AUTO on write" but its risk is "MySQL removed query cache in 8.0." What does that imply for a system designed around this tier today?


16. Sizing & Monitoring

Working Set Estimation

working_set = hot_keys × avg_value_size × replication_factor
# Example: 1M hot keys × 2KB avg × 2 replicas = 4 GB

Hit Rate Math

hit_rate = keyspace_hits / (keyspace_hits + keyspace_misses)
effective_latency = hit_rate × cache_latency + (1 - hit_rate) × db_latency

# Example: 90% hit, 1ms cache, 20ms DB:
# = 0.9 × 1 + 0.1 × 20 = 0.9 + 2 = 2.9ms avg
# vs no cache: 20ms avg

Redis Monitoring

redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses|evicted_keys|expired_keys"
redis-cli INFO memory | grep used_memory_human
redis-cli INFO keyspace

Key metrics:

  • keyspace_hits / keyspace_misses → compute hit rate; target > 90%
  • evicted_keys > 0 → memory pressure, increase maxmemory or evict more aggressively
  • used_memory_rss >> used_memory → memory fragmentation (see §17)
  • connected_clients near maxclients (default 10000) → connection pool exhaustion

Prometheus / Grafana

Use redis_exporter. Key PromQL:

# Hit rate
rate(redis_keyspace_hits_total[5m]) /
  (rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m]))

# Eviction rate
rate(redis_evicted_keys_total[5m])

# Memory fragmentation ratio (>1.5 = fragmented)
redis_memory_used_rss_bytes / redis_memory_used_bytes

With 90% hit rate, 1ms cache latency, and 20ms DB latency, the worked example gives 2.9ms average effective latency. Why isn't it closer to the midpoint of 1ms and 20ms (~10.5ms)?


17. Common Issues

Cache Poisoning

Attacker inserts malicious data into cache (e.g., via HTTP response splitting, unvalidated key construction).

Fix: Validate and sanitize all keys. Never build cache keys from raw user input without normalization. Use signed tokens for sensitive cache values.

Stale Reads After Write

Write updates DB but forgets to invalidate cache. Reads return old data.

Fix: Always pair DB writes with cache invalidation in the same transaction scope (or use write-through). Use short TTLs as a safety net.

Memory Fragmentation

Redis allocates memory in chunks; after many DEL/EXPIRE cycles, RSS grows above used_memory.

# Check fragmentation ratio (>1.5 is concerning)
redis-cli INFO memory | grep mem_fragmentation_ratio

# Enable active defragmentation (Redis 4+)
# redis.conf
activedefrag yes
active-defrag-ignore-bytes 100mb
active-defrag-threshold-lower 10   # start at 10% fragmentation
active-defrag-threshold-upper 100

Hot Keys

A single key receives millions of requests/sec, saturating a single Redis shard.

Detection:

redis-cli --hotkeys   # requires maxmemory-policy != noeviction
redis-cli monitor | head -100   # live command stream

Fix: Local L1 shadow cache in each app instance for the hot key (small TTL, 1–5s):

local_cache = {}  # in-process dict

def get_hot(key):
    if key in local_cache and time.time() < local_cache[key]['exp']:
        return local_cache[key]['val']
    val = redis.get(key)
    local_cache[key] = {'val': val, 'exp': time.time() + 1}  # 1s local TTL
    return val

Alternative: replicate hot keys across multiple Redis nodes with a prefix shard (hot:0:key, hot:1:key, ...) and randomly select a shard on read.

Connection Pool Exhaustion

Each app thread holding a Redis connection; pool depleted under load.

Fix:

# redis-py with connection pool
pool = redis.ConnectionPool(host='redis', port=6379, max_connections=50)
r = redis.Redis(connection_pool=pool)
  • Set max_connections to (app_threads × 0.5) as a starting point
  • Monitor connected_clients in Redis
  • Use pipelining to batch commands and reduce round-trips

The hot-key fix uses a local shadow cache with a 1–5 second TTL. What would go wrong if that local TTL were stretched to 60 seconds instead?


Quick Reference

Cache-aside    → app manages read/write, lazy population
Write-through  → sync write to cache + DB, no stale reads
Write-behind   → fast writes, async DB flush, data loss risk
Read-through   → cache handles DB fetch transparently

LRU            → general purpose
LFU            → skewed/Zipf access patterns
volatile-ttl   → mixed persistent + ephemeral keys

Stampede fix   → mutex lock OR XFetch probabilistic OR stale-while-revalidate
Invalidation   → TTL (simple) → event-driven (consistent) → tag-based (grouped)
Hot key fix    → L1 local shadow cache with short TTL
Fragmentation  → activedefrag yes in redis.conf