Lesson 0004 · Caching patterns

Cache-aside & the stampede problem

The pattern behind almost every Redis cache — and the thundering herd that TTLs quietly set up.

~11 minRetrieval quiz at the endCheat sheet: here

You now know (Lesson 0003) that a TTL makes a key vanish at a moment in time. This lesson builds the caching pattern that relies on that — cache-aside — and then confronts the failure it sets up: the instant a hot key expires, thousands of requests miss at once and stampede your database. Getting cache-aside right is table stakes; knowing how to tame the stampede is what separates "I use Redis" from "I run Redis in production."

Part 1 — Cache-aside (lazy loading)

Cache-aside puts the application in charge: it consults the cache, and only touches the database on a miss. The read path is three steps AWS — Database Caching Strategies (Caching patterns):

  1. Check the cache. Data present → cache hit, return it.
  2. On a miss, query the database.
  3. Populate the cache with what you fetched (with a TTL), then return it.
# cache-aside read
val = GET key
if val is nil:                 # cache miss
    val = db.query(...)        # go to the source of truth
    SET key val EX 300         # populate + TTL (lazy load)
return val

Because the cache fills only on demand, it holds just what the app actually requests — cheap and self-trimming. The cost: every miss pays one slow DB round-trip, and the data can go stale between the DB changing and the TTL expiring. On writes, cache-aside is usually paired with either write-through (update DB, then immediately update the cache) or simply invalidation (DEL key on write, let the next read repopulate). AWS whitepaper

PatternWho loads the cacheWhen
Cache-aside (lazy)Application codeOn a read miss
Read-throughThe cache layer itselfOn a read miss (transparently)
Write-throughApplication/backendSynchronously on every DB write
Write-behind (write-back)CacheWrite to cache now, flush to DB later (async)

AWS documents cache-aside and write-through in detail; read-through and write-behind are the standard industry terms for the other two rows.

Part 2 — The stampede TTLs set up

A cache stampede (a.k.a. thundering herd or dogpile) is a cascading failure: a popular key expires, and every concurrent request that was being served from it now misses simultaneously — so they all run step 2 (query the DB) at the same instant. The database, which the cache existed to protect, takes the full unbuffered load. Cache stampede (Vattani et al., VLDB 2015)

Why it's worse than it looks

It compounds. The recomputation is slow (that's why you cached it), so the herd keeps arriving during the recompute window — hundreds of requests each kicking off their own identical DB query. And if your TTLs were all set together (e.g. a bulk cache warm), keys expire in lockstep and many hot keys stampede at the same second.

Part 3 — Four ways to tame it

1. TTL jitter — always do this

Add a random spread to every TTL so keys don't expire in lockstep: EX (300 + rand(0..60)). Cheapest possible fix, kills the synchronized-expiry variant outright. AWS ElastiCache — add jitter to TTLs

2. Single-flight lock — one recompute, not ten thousand

On a miss, have exactly one request rebuild the value while the rest wait briefly or serve stale. Redis gives you the atomic primitive for free (recall from Lesson 0001: commands are atomic): SET lock:key token NX EX 10 succeeds for only one caller. Redis Docs — SET NX EX

val = GET key
if val is nil:
    if SET lock:key <token> NX EX 10:   # exactly one winner
        val = db.query(...)
        SET key val EX (300 + jitter)
        # release only our own lock (see warning)
    else:
        sleep a little / serve stale, then re-GET
return val
The naive-lock trap (interview favorite)

Releasing the lock with a bare DEL lock:key is unsafe: if your work outran the lock's TTL, you'd delete a different client's lock. Store a random token and release only if it still matches, via a Lua script (atomic check-and-delete). For locking across multiple Redis nodes, the single-instance lock has a real failure mode (async replication can hand two clients the same lock) — that's what the Redlock algorithm exists to address. Redis Docs — Distributed Locks

3. Probabilistic early recomputation (XFetch)

Instead of waiting for expiry, let each reader probabilistically refresh the key before it dies, with the probability rising as the TTL approaches. One lucky request rebuilds early; everyone else keeps getting a cache hit — the herd never forms. This is the Vattani et al. result; the trigger recomputes when now − delta·β·ln(rand) ≥ expiry, where delta is the last recompute duration and β tunes eagerness. "Optimal Probabilistic Cache Stampede Prevention", VLDB 2015

4. Stale-while-revalidate

Serve the last-known value while one background refresh runs — no reader ever blocks on the DB. Keep the value under a longer "hard" TTL than its "fresh" window; past freshness, return stale and trigger a single async rebuild. This is the same idea RFC 5861 standardized for HTTP caches. RFC 5861 — stale-while-revalidate

How to choose

Jitter is free — do it always. Add a single-flight lock for expensive-to-compute hot keys. Reach for XFetch or stale-while-revalidate when even a brief per-key stall is unacceptable and you can tolerate serving slightly stale data. They compose: jitter + lock covers most real systems.

Check yourself

Answer from memory — effortful recall builds retention. Two questions revisit earlier lessons on purpose (spacing).

In cache-aside, the database is read:

Cache-aside is lazy: check cache first, hit → return; miss → read DB, populate cache with a TTL, return. The DB is touched only when the cache lacks the data.

A cache stampede happens the moment a hot key:

On expiry, all concurrent requests miss at once and hit the database simultaneously. The recompute is slow, so the herd keeps arriving during the rebuild window.

Which Redis command atomically elects one request to rebuild a key?

SET ... NX succeeds for only the first caller (single-threaded atomicity), so exactly one wins the lock and recomputes. A GET-then-SET has a race between the two commands.

The cheapest fix for many keys expiring in lockstep is to:

Adding a random spread (e.g. base + rand(0..60)s) desynchronizes expiry so keys don't all die at the same instant. Cheapest, always-applicable mitigation.

Interleave — overwriting a cache key with a bare SET (no KEEPTTL) does what to its TTL?

From Lesson 0003: a successful SET discards any previous TTL. In a cache that silently turns a value permanent — pass KEEPTTL, or re-set the TTL deliberately.

Interleave — your Redis cache has no TTLs and fills maxmemory. Correct policy?

From Lesson 0003: volatile-* needs TTL'd candidates (none here), and default noeviction errors on writes. allkeys-lru/lfu can evict any key under pressure.

Hands-on: cause a stampede, then stop it

You need a local Redis and redis-cli. We'll fake a "slow DB" with a sleep.

1 · The single-flight lock in raw commands

# two terminals racing to rebuild "report" — only one should win the lock
redis-cli SET lock:report t1 NX EX 10     # terminal A -> OK
redis-cli SET lock:report t2 NX EX 10     # terminal B -> (nil): blocked
# A does the slow work, writes the value with jitter, then releases safely:
redis-cli SET report "…computed…" EX 305
redis-cli EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 lock:report t1

2 · See jitter desynchronize expiry

redis-cli EVAL "for i=1,5 do redis.call('set','p:'..i,i,'EX',60+math.random(0,30)) end" 0
redis-cli TTL p:1
redis-cli TTL p:2                          # different — no lockstep expiry
Predict first

In step 1, why does terminal B get (nil)? And what would break if A released the lock with a bare DEL lock:report after its work ran past 10 seconds? Commit, then reason it through with the warning above.

Clean up with redis-cli FLUSHALL. Bring your lock-race reasoning to your teacher.

Primary source — read this next

AWS — Database Caching Strategies Using Redis: Caching patterns is the clearest authoritative write-up of cache-aside vs write-through. For the stampede, skim Cache stampede and, if you want the theory, the Vattani VLDB paper.