Failure Modes
A cache changes how the system fails. Without it, the database degrades smoothly as load grows. With it, the database is sized for the misses β and anything that suddenly turns hits into misses (a restart, an expiry, a new kind of request, a cache node dying) sends the full load to a database that was never sized for it. Most cache incidents are not bugs in the cache; they are the moment the system discovers how much it depended on the cache.
This page goes through the classic failure modes in the order they tend to appear in a flash sale, each with its symptom and its fix.
| Failure | What happens | Main fix |
|---|---|---|
| Cold cache | empty cache after restart or deploy | warm-up, gradual traffic shift |
| Stampede | a hot key expires; thousands recompute it at once | lock/lease, coalescing, stale-while-revalidate, XFetch |
| Avalanche | many keys expire at the same moment | TTL jitter |
| Penetration | requests for keys that never exist | negative caching, Bloom filter, validation |
| Hot key | one key overloads one cache node | local L1 cache, key replication |
| Big key | huge values block the cache | split, compress, UNLINK |
| Cache down | the cache cluster fails | timeouts, fallback with load shedding, replicas, gutter pool |
| Memory pressure | evictions and fragmentation | sizing, separate instances, eviction policy |
Cold cache
A cold cache is an empty one: after a restart of Redis without persistence, a deploy that changes the key version (:v3 β :v4), a new region, or a cache node replaced by autoscaling. The hit ratio starts at zero and climbs as keys are read.
The danger is the gap between the load the database was sized for (the misses) and the load it receives (everything). With 50,000 reads/s and a usual 98% hit ratio, the database is sized for ~1,000 QPS; a cold start sends it 50,000.
Think first: when is the safest moment to deploy a new cache key version?
BoxOffice needs to change the format of the cached event (:v3 β :v4). The release is ready at 09:30; the stadium on-sale starts at 10:00.
Not at 09:30. A new key version is a cold cache for every event, and the hottest moment of the month is half an hour away. Deploy after the on-sale peak (or before, with a warm-up and enough time to verify the hit ratio). More generally: schedule cache-invalidating deploys outside known peaks, and roll them out gradually so old and new versions warm up in parallel.
Fixes:
- Warm-up: before a known peak, preload the keys you know will be hot β the events going on sale today, the home page lists β with a job that reads them through the normal path.
- Gradual traffic shift: send 5%, then 25%, then 100% of traffic to a new cache node or region (canary), so it warms under a fraction of the load.
- Persistence for big caches: a Redis restart that reloads an RDB snapshot comes back warm (Redis β persistence).
- Never restart all cache nodes at once.
- Capacity floor: know how many QPS the database can take with an empty cache, and protect it with load shedding (see cache down). The Google SRE book lists cold caches among the classic triggers of cascading failures1.
Cache stampede
A cache stampede (also thundering herd or dog-piling) happens when a hot key expires or is deleted. Every request that arrives before someone puts the value back misses β and each one goes to the database to recompute the same value.
sequenceDiagram
participant A as 4,000 requests
participant R as Redis
participant D as PostgreSQL
Note over R: event:42 expires at 10:00:00.000
A->>R: GET event:42 (Γ4,000 within 200 ms)
R-->>A: miss (Γ4,000)
A->>D: same SELECT (Γ4,000)
Note over D: CPU 100%, query time 3 ms to 2 s
D-->>A: rows, slowly
A->>R: SET event:42 (Γ4,000, all identical) The arithmetic is Little's law. If event 42 is read 20,000 times per second and recomputing it takes 200 ms, then about \(20{,}000 \times 0.2 = 4{,}000\) requests miss before the first one finishes. Worse: under that load the query slows down, the window grows, and more requests pile in. A stampede can take down a database that was serving the same page comfortably one second before.
There are four standard fixes. They are complementary.
1. Lock (or lease): only one recomputes
On a miss, a request tries to acquire a short lock for the key. The winner recomputes; the others wait a few milliseconds and read the cache again (or serve a stale copy).
import json
import time
import uuid
RELEASE = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
"""
def get_event(event_id: int, db, r) -> dict:
key = f"boxoffice:catalog:event:{event_id}:v3"
lock = f"lock:{key}"
for _ in range(50): # wait at most ~1 s
cached = r.get(key)
if cached is not None:
return json.loads(cached)
token = str(uuid.uuid4())
if r.set(lock, token, nx=True, px=5_000): # only one winner
try:
event = db.fetch_event_with_venue_and_prices(event_id)
r.set(key, json.dumps(event), ex=300)
return event
finally:
r.eval(RELEASE, 1, lock, token) # release only our own lock
time.sleep(0.02) # losers wait and re-read
return db.fetch_event_with_venue_and_prices(event_id) # last resort
SET ... NX PX 5000is atomic: set only if absent, with a 5 s expiry so a crashed winner does not block the key forever.- The release checks the token, so a slow winner whose lock already expired does not delete someone else's lock.
- Cost: losers wait (latency) and poll the cache. Facebook's memcache leases are a refined version of this, built into the cache server2.
In the BoxOffice code: the stampede lock in catalog (lab 5)
Hands-on 5 β Stampede first reproduces the stampede (a hot listing with a 3 s TTL, hammered by 200 users) and then sets CACHE_STAMPEDE_PROTECTION=true, which runs this code: in a test run (60 s), the misses dropped from 2,238 to 19, one database query per expiration instead of about a hundred and ten. Same idea as the Python above, in Java: SET lock NX PX 2000, re-read after winning (another request may have filled the key), losers poll for at most 20 Γ 25 ms and then load anyway. Note the honest comment in unlock: get + delete is not atomic, the Lua script above is.
if (stampedeProtection) {
var lockKey = key + ":lock";
var token = UUID.randomUUID().toString();
if (tryLock(lockKey, token)) {
try {
cached = read(key); // someone may have filled it meanwhile
if (cached != null) {
return count(new Result(cached, "HIT"));
}
return count(load(key, ttl, loader));
} finally {
unlock(lockKey, token);
}
}
// another request is already loading this key: wait for it instead of
// sending one more identical query to the database
for (int i = 0; i < 20; i++) {
sleep(Duration.ofMillis(25));
cached = read(key);
if (cached != null) {
return count(new Result(cached, "HIT"));
}
}
}
private boolean tryLock(String lockKey, String token) {
try {
// SET lockKey token NX PX 2000: only one request in the whole cluster gets it
return Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(lockKey, token, Duration.ofSeconds(2)));
} catch (DataAccessException e) {
return true; // no Redis, no lock: just load
}
}
private void unlock(String lockKey, String token) {
try {
// delete only our own lock (it may have expired and been taken by someone else);
// get + delete is not atomic: production code uses a Lua script for this
if (token.equals(redis.opsForValue().get(lockKey))) {
redis.delete(lockKey);
}
} catch (DataAccessException e) {
log.debug("unlock failed: {}", e.getMessage());
}
}
2. Request coalescing (single-flight)
Inside one process, concurrent misses for the same key can share one in-flight computation: the first caller starts it, the others wait for the same future. Caffeine's LoadingCache and AsyncLoadingCache do this automatically; Go has singleflight; in Java you can use a ConcurrentHashMap<Key, CompletableFuture<V>>.
Coalescing does not need Redis: with 20 replicas, a stampede of 4,000 requests becomes 20 database queries β usually harmless. It is the cheapest fix and should be the default for hot paths.
3. Stale-while-revalidate
Keep two expirations: a soft TTL stored inside the value (e.g. 60 s) and a longer hard TTL on the key (e.g. 10 min). After the soft TTL, the first reader triggers a background refresh (guarded by a lock) and everyone, including that reader, keeps getting the stale value until the new one arrives. Readers never wait; the database sees one query. The same idea exists in HTTP as Cache-Control: max-age=60, stale-while-revalidate=600.
4. Probabilistic early expiration (XFetch)
Instead of a lock, let each reader decide at random to refresh the value a little before it expires, with a probability that grows as expiry approaches. Vattani, Chierichetti and Lowenstein showed that the following rule is optimal in a precise sense3. Store with the value the time \(\delta\) it took to compute it and its expiry time; on each read, recompute if
Since \(-\ln u\) is an exponential random variable with mean 1, each read "looks ahead" by a random amount of about \(\delta \beta\). The parameter \(\beta\) (default 1) trades earlier refreshes for fewer stampedes. The probability that a single read refreshes, with \(r\) seconds left before expiry, is \(e^{-r/(\delta \beta)}\).
The right panel is the elegant part. If reads arrive at rate \(\lambda\), the probability that nobody refreshed early before expiry is \(e^{-\lambda \delta \beta}\). A hot key (hundreds of reads per second) is almost surely refreshed just before it expires β by one reader, with no lock and no coordination β while a cold key, where a stampede cannot happen anyway, simply expires. The algorithm adapts to the traffic by itself.
import json
import math
import random
import time
def xfetch(r, key: str, recompute, ttl: int = 300, beta: float = 1.0):
entry = r.hgetall(key) # {"data": ..., "delta": ..., "expiry": ...}
now = time.time()
if entry:
delta, expiry = float(entry["delta"]), float(entry["expiry"])
u = 1.0 - random.random() # in (0, 1], so log(u) is defined
if now - delta * beta * math.log(u) < expiry:
return json.loads(entry["data"]) # serve the cached value
start = time.time()
value = recompute() # miss, or chosen to refresh early
delta = time.time() - start
r.hset(key, mapping={"data": json.dumps(value), "delta": delta, "expiry": start + ttl})
r.expire(key, ttl)
return value
| Fix | Readers wait? | DB queries per expiry | Complexity | Best for |
|---|---|---|---|---|
| Lock / lease | yes (losers) | 1 | medium | any shared hot key |
| Coalescing | yes, per process | 1 per replica | low (library) | always, as a baseline |
| Stale-while-revalidate | no | 1 | medium | data that tolerates seconds of staleness |
| XFetch | no | ~1 | low | hot keys with a known recompute cost |
Try it
The run follows lab 5's stampede test: a warm-up caches the hot event page (500 reads/s) and 40 others with the same 3 s TTL, and the database runs 20 queries of 200 ms at a time (100/s). Press play with every switch off: at t = 3 s the queries jump to about 1,060/s, hundreds wait in line, and p99 reaches about 5 s, with 1,229 queries in 10 s for 41 pages. Turn on request coalescing (lab 5's CACHE_STAMPEDE_PROTECTION=true): 122 queries, p99 200 ms, and 543 requests wait for another request's load instead. The 280/s peak that remains is the 40 pages expiring together, the avalanche below: add TTL jitter (lab 5's 10%) and it falls to 120/s. XFetch alone needs no lock and nobody waits: the hot page is refreshed before it expires (314 queries, peak 230/s, since several readers refresh it at once). With all three, the peak stays under capacity, at 90/s.
Cache avalanche
A cache avalanche is a stampede on many keys at once: a large set of keys expires at the same moment, or the cache is flushed, and the misses for all of them hit the database together.
BoxOffice: the warm-up that caused the outage
To prepare for the 10:00 on-sale, a job warms the cache at 09:50 with the 5,000 hottest pages, all with TTL = 600 s. Every one of them expires at 10:00:00 β the exact moment 2 million fans press F5. The warm-up made things worse than no warm-up at all.
The fix is jitter: add a random component to every TTL, e.g. ttl = 600 + random(0, 120), so expirations spread over two minutes. Combine with refresh-ahead or XFetch for the very hottest keys, and with stampede protection for each individual key.
In the BoxOffice code: jitter on every TTL (lab 5)
Every key that catalog writes in Hands-on 5 gets up to 10% of random extra TTL, so keys written by the same warm-up do not expire in the same second:
/** TTL plus up to 10% random extra, so that keys written together do not expire together. */
private static Duration withJitter(Duration ttl) {
long extra = (long) (ttl.toMillis() * 0.10 * ThreadLocalRandom.current().nextDouble());
return ttl.plusMillis(extra);
}
Cache penetration
Cache penetration is traffic for keys that do not exist in the database. They are never cached (there is nothing to cache), so every request goes through to the database. A bot enumerating GET /events/1, /events/2, β¦, /events/99999999, or a buggy client asking for an id that was deleted, can generate a database query per request at the rate of the cache.
Fixes, in layers:
- Validate input before touching anything: malformed or out-of-range ids are rejected at the gateway.
- Negative caching: cache "not found" with a short TTL (e.g. 30β60 s) β see patterns.
- Bloom filter of existing ids: a compact probabilistic set that answers "definitely not present" or "probably present". A request for an id the filter says is absent never reaches the cache or the database. Sizing: about \(-\ln p / (\ln 2)^2 \approx 9.6\) bits per element for a 1% false-positive rate β ~120 KB for 100,000 events4. Redis Stack / Redis 8 offer Bloom filters natively (
BF.ADD,BF.EXISTS). - Rate limiting and bot mitigation at the edge (WAF), since penetration is often abuse (Traffic & Edge).
Hot keys
A distributed cache spreads keys over nodes, but one key lives on one node. When a single key is extremely popular, that node saturates while the rest of the cluster is idle.
BoxOffice: one key, one node
Illustrative numbers: 2 million fans refresh the event page about every 5 seconds β 400,000 reads/s of event:42. The cached value is 4 KB. On a single Redis node that means 400,000 Γ 4 KB β 1.6 GB/s β 13 Gbit/s of outgoing traffic β more than a 10 Gbit/s network interface β plus far more commands than one node's main thread is comfortable with. Adding nodes to the cluster does nothing: the key still lives on one of them.
Fixes:
- L1 in-process cache with a short TTL (1β2 s) in each
catalogreplica (two-level cache). With 50 replicas and a 1 s TTL, Redis sees ~50 reads/s for that key instead of 400,000. This is by far the most effective fix. - Key replication: store N copies under different keys (
event:42#0β¦event:42#7), which hash to different nodes; readers pick one at random. Writes and invalidations must touch all N copies. - Read replicas of the cache shard (Redis replicas with
READONLYreads in Cluster), accepting slightly staler reads. - Shrink the value: send the static part (description, images) through the CDN and cache only the small dynamic part.
- Cache higher up: the public event page can be cached at the CDN or gateway for a few seconds, so the hot key never reaches the application.
Detecting hot keys: client-side metrics per key prefix, redis-cli --hotkeys (requires an LFU eviction policy), or the MONITOR command on a replica for a few seconds (never on a busy primary).
Big keys
A big key is a value of megabytes, or a collection (hash, list, set, sorted set) with millions of members. Redis executes commands on a single main thread, so reading, writing, expiring or deleting a big key blocks every other client for its duration; transferring it saturates the network.
- Find them with
redis-cli --bigkeysorMEMORY USAGE key. - Split large objects (one key per section instead of one per venue), paginate collections (
HSCAN,ZRANGE ... LIMIT), compress values above a few KB. - Delete with
UNLINK(frees memory in a background thread) instead ofDEL. - Keep values small β a common guideline is to stay well below 100 KB per value and avoid collections with more than ~10,000 elements; treat these as rules of thumb, not limits.
The cache goes down
Sooner or later, the Redis primary fails, the network partitions, or a failover takes 10β30 seconds. What happens next depends on decisions made long before:
flowchart TD
F["Redis unavailable"] --> T{"Cache calls have<br/>a short timeout?"}
T -->|"no"| X1["threads block on Redis<br/>whole service stalls"]
T -->|"yes"| B{"Fallback on error?"}
B -->|"no"| X2["every read fails<br/>outage"]
B -->|"read the DB"| C{"Can the DB take<br/>the full load?"}
C -->|"yes"| OK1["slower but correct"]
C -->|"no"| S["shed load and serve degraded:<br/>static page, waiting room,<br/>rate-limited misses"] The uncomfortable truth: once the database is sized for the misses, the cache is no longer optional. It is a load-bearing dependency, and it needs the same reliability engineering as the database:
- Timeouts on every cache call (tens of milliseconds, not the client default) and a circuit breaker, so a sick cache fails fast instead of holding threads (Reliability).
- Replication with automatic failover (Redis Sentinel or Cluster; managed services do this for you) β see Redis.
- Protected fallback: falling back to the database for every miss during an outage is a self-inflicted stampede. Limit concurrent fallback queries (a bulkhead), serve degraded content for non-essential parts, and put the waiting room in front of the on-sale.
- Gutter pool: Facebook keeps a small pool of idle cache servers; when a server fails, clients send its keys to the gutter pool (with short TTLs) instead of to the database2.
- Know the number: test what the database can take with the cache off. That number is your real capacity floor.
Memory pressure, evictions and stale failovers
- Eviction storms: when the working set outgrows
maxmemory, the cache evicts keys that will be needed again; the hit ratio falls, the database load rises. Watchevicted_keysand the hit ratio together. - Fragmentation: memory used by the process can exceed the data size (
mem_fragmentation_ratioinINFO memory); leave headroom. - Snapshot fork: an RDB snapshot or AOF rewrite forks the process; with copy-on-write, a write-heavy instance can need up to twice its memory during the snapshot. Size for it, or run persistence on a replica.
- Stale data after failover: Redis replication is asynchronous. If the primary acknowledged a
DELand failed before replicating it, the promoted replica still has the old value β invalidations can be lost. One more reason every key needs a TTL.
See it in the playground
Try it: this scenario runs the read-heavy-cache design under a spike. First watch the steady state: hit ratio, database QPS and p99. Then (1) flush the cache at the peak and watch the database load jump to the full request rate β a cold cache under load; (2) shorten the TTL of the hot keys and look for stampede spikes in the database QPS each time they expire; (3) enable chaos and watch what happens to p99 while the cache is unavailable.
The full playground lets you add a second cache layer or a waiting room and compare.
BoxOffice: an on-sale, minute by minute
| Time | Event | Failure mode avoided |
|---|---|---|
| 09:30 | no cache-invalidating deploys until 11:00 | cold cache |
| 09:45 | warm-up job loads today's on-sale events, TTL 600 s + jitter 0β120 s | cold cache, avalanche |
| 09:59 | 2 M fans on the event page; catalog serves it from a 1 s L1 cache in each replica | hot key |
| 10:00 | the price table is edited; delete after commit, CDC broadcasts to L1 caches | stale data |
| 10:02 | event:42 reaches its soft TTL; one replica refreshes it in the background, others serve stale | stampede |
| 10:05 | bots probe /events/<random id>; WAF rate-limits, Bloom filter and negative cache absorb the rest | penetration |
| 10:11 | Redis primary fails; failover in 15 s; cache calls time out at 50 ms; circuit breaker opens; misses are limited to 2,000 QPS, the rest see the waiting room | cache down |
In the interview
After you draw a cache, the interviewer will often ask "what happens when a hot key expires?" or "what if Redis goes down?". Name the failure (stampede, avalanche, hot key, penetration), give the number that makes it dangerous (Little's law: reads/s Γ recompute time), and the fix (coalescing or a lock, TTL jitter, L1 cache, negative caching / Bloom filter). For "Redis goes down", say that the database is sized for the misses, so you need timeouts, a circuit breaker and load shedding β not a naive fallback.
Best practices
- Put a short timeout on every cache call (milliseconds, not the client's default seconds) and treat an error as a miss. In BoxOffice:
timeout: 200msandconnect-timeout: 200msincatalog'sapplication.yaml, andread()inEventCache.javareturnsnullon any Redis error. - Add jitter to every TTL and protect each hot key with a lock or request coalescing (boxes above).
- Rehearse the cold cache: flush Redis in a load test at the expected peak and check the database survives; if it does not, keep a warm-up plan and a rate limit on the miss path.
- Watch the cache like a database: hit ratio, evictions, memory, latency per command, and the hottest keys (
redis-cli --hotkeys, which needs an LFU policy; big keys with--bigkeys). - Answer unknown keys cheaply (negative caching or a Bloom filter) when ids come from the outside.
Common pitfalls
- Warm-ups with identical TTLs β they schedule an avalanche.
- Locks without expiry β a crashed winner blocks the key forever. Always
PX. - Fallback to the database on every cache error β a cache outage becomes a database outage.
- Default client timeouts (seconds) on cache calls β a slow Redis stalls every thread.
- Ignoring keys that do not exist β bots will find them.
- Scaling the cluster to fix a hot key β the key still lives on one node.
DELof a huge collection,KEYS *,MONITORon the primary β they block the single main thread.
How it connects
flowchart LR
COLD["cold cache<br/>warm before traffic"] --> DB["the database takes the misses"]
STAMP["stampede: a hot key expires<br/>reads/s Γ recompute time"] --> FIX["lock, coalescing,<br/>stale-while-revalidate, XFetch"]
AVA["avalanche: many keys<br/>expire together"] --> JIT["TTL jitter"]
PEN["penetration: keys<br/>that never exist"] --> NEG["negative caching,<br/>Bloom filter"]
HOT["hot key: one node"] --> L1["L1, key copies, CDN<br/>β Patterns, Edge"]
DOWN["the cache goes down"] --> RES["timeouts, circuit breaker,<br/>bulkhead β Reliability"]
DOWN --> DB
DB --> SHED["shed load, waiting room<br/>β Messaging"] Palette components: cache (a load-bearing dependency once the database is sized for the misses) Β· sql (what it can take with the cache off is the real capacity floor) Β· app (single-flight, an L1, short cache timeouts) Β· cdn Β· apigw (cache the hot public page higher up) Β· queue (the waiting room when the fallback would drown the database) Β· metrics (hit ratio, evicted_keys). Scenarios: read-heavy-cache, flash-sale, cascading-failure. Continues in: Redis β replication, Reliability β circuit breaker, Estimation β queueing (Little's law), Case study: distributed cache, Hands-on 5 (reproduce and fix a stampede).
-
BEYER, B. et al. Site Reliability Engineering, chapter 22, "Addressing Cascading Failures". O'Reilly, 2016.Β β©
-
NISHTALA, R. et al. Scaling Memcache at Facebook. USENIX NSDI, 2013. Leases (Β§3.2.1) and gutter pools (Β§3.3).Β β©β©
-
VATTANI, A.; CHIERICHETTI, F.; LOWENSTEIN, K. Optimal Probabilistic Cache Stampede Prevention. Proceedings of the VLDB Endowment, v. 8, n. 8, 2015.Β β©
-
BLOOM, B. H. Space/Time Trade-offs in Hash Coding with Allowable Errors. Communications of the ACM, v. 13, n. 7, 1970.Β β©