Caching
Part 1 of 8 · Redis cacheRedis Cache-Aside, Invalidation & Stampede Prevention
Cache-aside + delete-on-write; TTL jitter; single-flight SET NX PX + Lua unlock; XFetch; negative caching.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Overview
Most read-heavy services (product catalogs, user profiles, feed cards, config) die the same way under load: the primary DB becomes the bottleneck, P95 climbs, connection pools exhaust, and a brief traffic spike turns into a cascade.
Redis cache-aside is the default fix — but naive cache-aside fails at the exact moment you need it most: when a hot key expires and hundreds of pods all miss at once (cache stampede / thundering herd).
By the end of this lesson you should be able to:
- Draw the cache-aside read path and the delete-on-write write path
- Pick cache-aside vs write-through vs write-behind with a reason
- Layer stampede controls: jitter, single-flight, XFetch / stale-while-revalidate
- Cache "not found" safely (negative caching)
- State an explicit Redis-down policy: fail-open vs fail-closed
Why cache-aside + invalidate usually wins
Prefer
Cache-aside, then DEL after DB commit
The app owns policy. Redis is a shared L2, not the source of truth. Misses rebuild from the primary.
- Memory only for keys that are actually read.
- Invalidation is simpler than keeping two writes in lockstep.
- Pairs cleanly with jitter, single-flight, and negative caching.
Alternative
Write-through or write-behind as the default
Warm-after-write and async flush are real tools — not the interview default for product reads.
- Write-through pays cache memory + write latency on every mutation, including cold keys.
- Write-behind acks before durability; a crash can lose writes unless the queue is a WAL.
- Set-on-write races can resurrect an older value after a later commit.
Read miss without a stampede
Phone-friendly vertical path. The mermaid diagrams below show the same architecture.
- 1
GET Redis
Hit → return. This is the common path. - 2
Miss → claim a lock
SET lock:{key} token NX PX (lock TTL ≥ p99 load). Only one loader proceeds. - 3
Load primary + SET with jittered TTL
Cache the value (or a negative sentinel). Unlock with token-checked Lua — never bare DEL. - 4
Waiters poll the data key
They must not all hit the DB. Timeout → stale or 503 per policy. - 5
On write: commit DB, then DEL
Next read rebuilds. Do not SET the cache before commit.
Cache-aside (lazy loading)
The app owns the policy. Redis is a shared L2, not the source of truth.
Read path
GETthe Redis key- On hit → return
- On miss → load from the primary →
SETwith TTL → return
Write path (recommended)
- Write the primary (source of truth)
DELthe cache key (invalidate)- The next read repopulates
Do not try to keep cache and DB perfectly in sync by writing both on every update unless you have a strong reason (that is write-through). Invalidation is simpler and safer under partial failures.
Decisions
- 1
Step 1 GET cache:user:42
- nextStep 2 Hit?
- ?
Step 2 Hit?
- yesStep 3a Return the cached value
- noStep 3b SET lock NX PX - one loader wins
- 3
Step 3a Return the cached value
- 4
Step 3b SET lock NX PX - one loader wins
- wonStep 4a Re-check cache, then load the primary DB
- lostStep 4b Poll the cache with backoff
- 5
Step 4a Re-check cache, then load the primary DB
- nextStep 5 SET value EX base TTL plus jitter
- 6
Step 5 SET value EX base TTL plus jitter
- nextStep 6 Unlock with token-checked Lua
- 7
Step 6 Unlock with token-checked Lua
- 8
Step 4b Poll the cache with backoff
- timeoutFailure path - serve stale or degrade, never send all N to the DB
- 9
Failure path - serve stale or degrade, never send all N to the DB
- 10
Step 7 Write path - commit DB, then DEL the key
- DEL failsFailure path - stale until TTL; retry DEL
- 11
Failure path - stale until TTL; retry DEL
Lesson map
Redis Cache-Aside, Invalidation & Stampede Prevention
Cache-aside + delete-on-write; TTL jitter; single-flight SET NX PX + Lua unlock; XFetch; negative caching.
Architecture. Step 1 GET cache:user:42 Ready. Step 2 Hit? Ready. Step 3a Return the cached value Ready. Step 3b SET lock NX PX - one loader wins Ready. Step 4a Re-check cache, then load the primary DB Ready. Step 5 SET value EX base TTL plus jitter Ready. Step 6 Unlock with token-checked Lua Ready. Step 4b Poll the cache with backoff Ready. Failure path - serve stale or degrade, never send all N to the DB Ready. Step 7 Write path - commit DB, then DEL the key Ready. Failure path - stale until TTL; retry DEL Ready
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB R["Step 1 GET cache:user:42 Ready"] H["Step 2 Hit? Ready"] A["Step 3a Return the cached value Ready"] L["Step 3b SET lock NX PX - one loader wins Ready"] C["Step 4a Re-check cache, then load the primary DB Ready"] S["Step 5 SET value EX base TTL plus jitter Ready"] U["Step 6 Unlock with token-checked Lua Ready"] P["Step 4b Poll the cache with backoff Ready"] D["Failure path - serve stale or degrade, never send all N to the DB Ready"] W["Step 7 Write path - commit DB, then DEL the key Ready"] T["Failure path - stale until TTL retry DEL Ready"] R -->|continues| H H -->|yes| A H -->|no| L L -->|won| C C -->|continues| S S -->|continues| U L -->|lost| P P -->|timeout| D W -->|DEL fails| T
Write-through vs write-behind
Interview default for user-facing reads: cache-aside + invalidate-on-write.
| Pattern | Write path | Strength | Cost |
|---|---|---|---|
| Cache-aside | App writes DB, then invalidates cache | Simple; memory only for hot data | Miss latency + stampede risk |
| Write-through | App writes cache; cache syncs DB | Always warm after write | Writes slower; unused keys waste memory |
| Write-behind | App writes cache; async flush to DB | Fast writes | Durability risk; harder correctness |
Read-through is cache-aside with miss loading moved into a library or proxy. Same races. Depth (who fills, ack timing, crash-before-flush): write-through vs write-behind.
Delete-on-write, not update-the-cache
Updating the cache on write races with concurrent writers and can resurrect an older value: write A → write B → set cache A. Deleting forces the next read to rebuild from the committed primary.
If you must set-on-write, use versioning / compare-and-set. Delete-on-write still has one race: a reader that missed loads the old row, the writer commits and deletes, then the reader SETs the old value. TTL bounds it; a versioned SET or a delayed second DEL closes it. Prefer delete-after-commit so readers never see an uncommitted or rolled-back row. If the DEL is lost, retry it (outbox / queue); TTL is the backstop, not the happy path.
Forget invalidate on all write paths — admin tools, batch jobs, and migrations silently leave stale data.
TTL as a staleness bound
TTL is not just memory management. It is your maximum staleness window if invalidation fails.
Typical production practice:
- Short TTL for volatile data (seconds to minutes)
- Longer TTL for slowly changing data
- TTL jitter:
base_ttl + random(0, jitter)so a slab of batch-warmed keys does not expire in lockstep
Identical TTLs on a warm create synchronized expiry cliffs. Jitter spreads them. It is not enough alone for an ultra-hot key.
Cache stampede
When a popular key expires under concurrency:
- N request handlers all see miss
- All N hit Postgres/MySQL for the same row
- DB CPU / pool saturates → latency cascade
Layer the fixes — they are not rivals:
| Approach | Idea | Pros | Cons |
|---|---|---|---|
| TTL jitter | Spread many keys' hard expiry | Cheap; stops cliffs | Not enough for one viral key |
Hard lock (SET NX) | One filler; others wait or poll | Bounds DB load | Lock holder crash; wait latency |
| Soft TTL / serve-stale | Logical expire before hard delete; background refresh | Users keep getting data | Stale window; need a refresh worker |
| Probabilistic early expire (XFetch) | Each request may refresh early with rising probability | No central lock | Extra refreshes; tuning β |
| In-process single-flight | One in-flight load shared per process | Great per pod | Not enough across many pods alone |
What fails if you choose wrong
- Only process-local single-flight across 200 pods → still 200 DB queries. Sell Go
singleflightas a pod tool, not a cluster solution. - Lock without TTL / fencing → deadlock after filler crash; permanent miss storm.
- Serve-stale forever without refresh success metrics → silent data rot.
- No jitter on identical TTLs → synchronized expiry of many keys. Related: TTL design & jitter. Mutex depth: single-flight locking. HTTP cousins: request collapsing and SWR.
Store {value, soft_exp, hard_exp} when you want serve-stale. After soft_exp, still return the blob but trigger async refresh (or only the lock winner refreshes). After hard_exp, treat as miss.
Sequence
- 1
Cache
Step1 key expires
- 2
User1 → Cache
GET hot
- 3
User2 → Cache
GET hot
- 4
UserN → Cache
GET hot
- 5
Cache → User1
miss
- 6
Cache → User2
miss
- 7
Cache → UserN
miss
- 8
User1
Step2 herd hits DB
- 9
User1 → DB
SELECT
- 10
User2 → DB
SELECT
- 11
UserN → DB
SELECT
- 12
User1
Step3 redundant SETs
- 13
User1 → Cache
SET
- 14
User2 → Cache
SET
- 15
UserN → Cache
SET
Flow
- 1
Hot key expires
- nextAll pods GET miss
- 2
All pods GET miss
- nextNaive: every pod hits DB
- nextSET NX PX on lock key
- 3
Naive: every pod hits DB
- nextPool exhaust / P95 spike
- 4
Pool exhaust / P95 spike
- 5
SET NX PX on lock key
- nextOne pod loads primary
- nextOthers poll GET
- 6
One pod loads primary
- nextSET value plus jittered TTL
- 7
Others poll GET
- 8
SET value plus jittered TTL
- nextLua unlock if still owner
- 9
Lua unlock if still owner
- nextOthers poll GET
Single-flight mutex (Redis official pattern)
Acquire the lock atomically:
SET lock:{key} {random_token} NX PX {lock_ttl_ms}Rules interviewers love:
- Lock TTL must be greater than p99 loader latency (else the lock expires mid-load and a second loader starts)
- Release only if you still own the lock (token-checked Lua
GET+DEL) — never bareDEL - Waiters poll the cache, not immediately fall through to the DB
- After acquiring the lock, re-check the cache (another worker may have filled it)
SETNXthenEXPIREas two commands is a bug: a crash between them leaves an immortal lock. AlwaysSET key val NX PX msin one call.
-- acquire (conceptually SET NX PX)
-- return redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2])
-- release: delete only if the token still matches
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
endBare DEL on unlock can delete another worker's lock after your TTL expired. That is how a "safe" mutex turns back into a stampede.
After wait timeout, loading once is still better than all N loading immediately. If a soft TTL exists, prefer serving stale over a 503 or a herd.
XFetch (VLDB 2015)
Vattani, Grosvenor, Rodriguez — Optimal Probabilistic Cache Stampede Prevention.
Store (value, delta, logical_expiry) with a physical Redis TTL slightly longer than logical expiry. On each hit, refresh early if:
now - delta * beta * ln(random()) >= expirydelta= measured recompute costbeta≈ 1.0 (higher = earlier refresh)- No locks; occasional redundant refreshes are OK
- Combine with the mutex for hard misses (value already gone)
Deep dive · Why the log of a random number?
random() is in (0, 1), so ln(random()) is negative and - delta * beta * ln(random()) is a random lead time. Hot keys get sampled more often, so they refresh earlier — without a global lock. Far from expiry the inequality almost never fires; near expiry it fires with rising probability. Pair it with a mutex so the rare true miss still single-flights.
Negative caching
Cache "not found" briefly to stop hammering the DB for missing IDs (bot traffic, deleted entities, scanners). Use a short TTL and a distinct sentinel / typed miss marker — never confuse a miss marker with a real empty object.
Without negative caching, miss storms become a DB DoS.
Key design
cache:{entity}:{id} # e.g. cache:user:42
cache:{entity}:{id}:v{n} # versioned keys for schema migrationsPrefer hashes (HSET / HGET) or RedisJSON for field-level updates without rewriting the whole blob.
Do not cache huge blobs or unbounded lists — memory eviction (LRU/LFU) plus latency. Paginate, or cache IDs and hydrate.
Architecture and operations
Client → API pods → Redis (L2 shared cache)
↘ PostgreSQL (source of truth)On write: API → Postgres COMMIT → DEL the per-user cache key (optional pub/sub or Redis Streams to other regions).
On miss under load: API → SET NX lock → one pod loads Postgres → SET cache → release lock. Other pods poll GET until warm (or timeout → degrade).
| Piece | Owns |
|---|---|
| API layer | Cache policy (TTL, jitter, lock, metrics) |
| Redis | Shared across pods; not the source of truth |
| Primary DB | Durability + correctness |
| Observability | Hit rate, miss rate, stampede-suppressed count, lock timeouts, DB QPS, cache latency |
Scaling
- Shard Redis / use Cluster for memory + throughput
- Hot keys: local L1 (Caffeine) + Redis L2, or key sharding (
user:42:0..N) for extreme hotspots - Multi-region: invalidate locally; accept cross-region staleness or replicate invalidation events. Do not assume instant global invalidate.
Metrics that catch expiry cliffs: hit ratio, miss latency, lock acquire rate, lock wait timeouts, rebuild duration (delta), stale-serve count, DB QPS correlated with TTL boundaries.
Fail-open vs fail-closed
When Redis is unavailable you need an explicit policy:
| Policy | Behavior | Protects | Risks |
|---|---|---|---|
| Fail-open | Hit the DB | UX / availability | DB overload, cascade |
| Fail-closed | Error, or serve last-known L1 stale | The primary | Worse UX, 503s |
Often the production mix is: short circuit breaker + serve last-known L1 stale + shed load. Never silently amplify DB QPS without limits.
Other failure modes:
- Lock timeout: waiters may stampede or return 503 — prefer degrade with stale if a soft TTL exists
- Invalidate lost: stale until TTL — TTL bounds damage
- Partial write success: DB updated, cache not deleted — delete-after-commit and retry delete
Working examples (real Redis)
These sketches need a real client. The sandbox below simulates the same protocol with a dict.
# Sketch — redis-py. Not runnable here (needs Redis).
import json, random, time, uuid
ACQUIRE_LOCK = "return redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2])"
RELEASE_LOCK = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
"""
def ttl_with_jitter(base_seconds: int, jitter: int = 30) -> int:
return base_seconds + random.randint(0, jitter)
def cache_get_or_load(r, key, loader, *, ttl_seconds=300, lock_ttl_ms=2000, max_wait_ms=2000):
cached = r.get(key)
if cached is not None:
return json.loads(cached)
lock_key = f"lock:{key}"
token = str(uuid.uuid4())
acquired = r.eval(ACQUIRE_LOCK, 1, lock_key, token, lock_ttl_ms)
if acquired:
try:
cached = r.get(key) # double-check after NX
if cached is not None:
return json.loads(cached)
value = loader()
r.set(key, json.dumps(value), ex=ttl_with_jitter(ttl_seconds))
return value
finally:
r.eval(RELEASE_LOCK, 1, lock_key, token)
deadline = time.monotonic() + max_wait_ms / 1000.0
while time.monotonic() < deadline:
time.sleep(0.05)
cached = r.get(key)
if cached is not None:
return json.loads(cached)
return loader() # degraded: one load after wait, not N immediate loads
def update_user(user_id, patch):
user = db_update_user(user_id, patch) # commit source of truth first
r.delete(f"cache:user:{user_id}") # invalidate after commit
return user// Sketch — node-redis. Same mutex: SET NX PX + Lua token unlock.
function ttlWithJitter(baseSeconds: number, jitter = 30): number {
return baseSeconds + Math.floor(Math.random() * (jitter + 1));
}
// acquire: SET lockKey token NX PX lockTtlMs
// release: GET lockKey == token then DEL else 0
// waiters: poll GET, do not stampede the DB# XFetch: True => refresh now even though the value is still present
import math, random, time
def should_xfetch(expiry: float, delta: float, beta: float = 1.0) -> bool:
return time.time() - delta * beta * math.log(random.random()) >= expiryIn-memory Redis (run this)
Dict-backed GET / SET / DEL, SET NX PX, and Lua-style token unlock. No network.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Interview Q&A
Cache-aside vs write-through — when do you pick each?
Answer
Cache-aside when the working set is a small hot subset and writes are less frequent than reads — you only pay memory for what is read. Write-through when you need a warm cache after every write (for example a session just created must be immediately readable everywhere) and write volume is manageable. Write-behind is a durability/latency bet, not a default. Most product APIs: cache-aside + invalidate-on-write + TTL bound.
Why delete-on-write instead of update-the-cache?
Answer
Updating the cache races with concurrent writers and can resurrect older values (write A → write B → set cache A). Deleting forces the next read to rebuild from the committed primary. If you must set-on-write, use versioning / compare-and-set. Delete-on-write still has one race: a reader that missed loads the old row, the writer commits and deletes, then the reader SETs the old value. TTL bounds it; a versioned SET or a delayed second DEL closes it.
How do you prevent a stampede when a hot key expires?
Answer
Three layers: (1) TTL jitter so popular keys do not expire in the same second, (2) single-flight with SET key token NX PX ms so one pod rebuilds while others poll GET, (3) optional soft-TTL / stale-while-revalidate (XFetch) for ultra-hot keys so waiters serve slightly stale instead of thundering the DB. Measure suppressed stampedes and DB QPS at TTL boundaries.
How do you unlock a single-flight lock without deleting someone else's?
Answer
Store a random token as the lock value. Unlock with a Lua script: if GET lock == token then DEL lock. Bare DEL after your TTL expired can delete the next worker's lock and let two rebuilds run. Never SETNX then EXPIRE as two commands — a crash in between leaves an immortal lock.
What is TTL jitter and why do identical TTLs fail?
Answer
If every writer sets EXPIRE 60, a warm working set expires together: a cliff of misses, then a stampede. Add ttl ± random(jitter) (for example 60s ± 15%) so expiry is a slope, not a wall. Jitter is cheap; it does not replace single-flight for a single hot key, but it stops synchronized herds.
What if Redis is unavailable?
Answer
Name the policy out loud: fail-open (hit DB, risk overload) vs fail-closed (error / serve stale from local). Production default is often a short circuit breaker + last-known L1 stale + load shedding, not silent amplification of DB QPS. Timeouts and bulkheads matter as much as the cache itself.
How do you cache nulls / missing keys?
Answer
Negative-cache with a short TTL and a typed miss marker (not a fake empty object). Without it, scanners, broken clients, and bots turn misses into a DB DoS. Keep negative TTLs much shorter than positive ones so newly created rows appear quickly.
Process-local L1 in front of Redis — what goes wrong?
Answer
L1 is fast and cheap until invalidation. Each pod has its own copy, so a write that deletes Redis does not evict in-process maps. Bound L1 with a tiny TTL (1–5s), a max size, and request coalescing. Never treat L1 as consistent — it is a latency cushion with bounded staleness.
How should cache-aside behave with read replicas?
Answer
Invalidate (or TTL) against the primary's commit, then accept that a replica read after invalidate can refill the cache with slightly stale data. If that is illegal (inventory, money), read-your-writes from the primary on that path or version-stamp cached values. Do not pretend replica lag plus cache-aside is linearizable.
How do you validate correctness in production?
Answer
Metrics: hit ratio, miss latency, lock acquire rate, lock wait timeouts, rebuild duration, stale-serve count, DB QPS correlated with expiry cliffs. Alert when miss rate or DB QPS spikes at TTL boundaries. A cache that is “up” but stampeding is a correctness/availability incident, not a hit-ratio vanity chart.
When do you pick write-through over cache-aside?
Answer
When almost every write is read back immediately (new session, just-created shopping cart) and the DB can absorb the extra write latency. Otherwise you pay memory for cold keys. Details: write-through vs write-behind.
Why isn't TTL jitter enough for a viral key?
Answer
Jitter spreads many keys' expiry. One hot key still stampedes every replica when that key dies. Add single-flight (SET NX PX) and optionally XFetch / soft TTL. In-process single-flight across 200 pods is still 200 DB queries. See TTL jitter and single-flight locking.
What monitoring join proves a stampede?
Answer
Spike in origin QPS correlated with cache miss ratio and lock wait / lock timeout metrics. A miss-ratio-only alert fires on cold start; you want the join. A cache that is “up” but stampeding is a correctness/availability incident, not a hit-ratio vanity chart.
What if Redis is full?
Answer
Eviction policy is the next control plane: allkeys-lru/lfu for a pure cache, volatile-* if some keys must not evaporate, never noeviction on a cache tier. Eviction is not capacity planning. See eviction policies.
Do missing keys need the same stampede controls as hot hits?
Answer
Yes. Bots and deleted celebrities are classic miss storms. Cache a sentinel with a short TTL and DEL on create. Negative caching.
Pitfalls
When the prompt is "design Twitter / Instagram / Ticketmaster read path": (1) name cache-aside explicitly, (2) draw Redis in front of the DB, (3) say invalidate-on-write + TTL bound, (4) call out stampede + single-flight, (5) mention metrics (hit rate, stampede suppressed), (6) discuss Redis failure mode, (7) optional L1 in-process + L2 Redis and multi-region staleness.
Then implement cacheGetOrLoad with lock + poll; mentally load-test 200 concurrent misses on one key and assert DB hits ≈ 1. Add TTL jitter and sketch expiry timestamps. Implement soft TTL (serve stale) + background refresh and compare p99 DB QPS vs hard expiry.
200 pods, one hot key expires, 8 in-flight requests per pod: origin loads with (a) no coalescing = 1600, (b) in-process single-flight only = 200, (c) Redis lock plus single-flight ≈ 1. Then say what a 2s lock TTL does if load p99 is 3s.
Go Deeper
Official docs
- Redis cache-aside overview
- Cache-aside with Python (redis-py)
- Cache-aside with Node.js
- SET (NX PX)
- Redis — Distributed locks
- Go singleflight
Papers and talks
- Vattani et al., Optimal Probabilistic Cache Stampede Prevention (VLDB 2015)
- Preventing cache stampede with Redis and XFetch — Jim Nelson
In this cluster
- Next: Write-through vs write-behind
- TTL jitter · Single-flight locking
- HTTP cousins: request collapsing · SWR