R1.05 · Caching — module hub
Cache stampede prevention.
Prevent multiple clients from simultaneously regenerating an expired cache key using locking, probabilistic early refresh, or request coalescing.
Grounding: echo/apps/echo_store · table.ex (the single-flight flights map,
the jittered expires_at) · bcs.4.md — every figure verbatim from a committed
record.
The problem
source · the thundering herdConsider a cache key accessed ten thousand times a second. While it is present every read is a hit and the source is untouched. The instant it expires, the picture inverts:
- Thousands of requests arrive within milliseconds.
- All take a cache miss.
- All query the source simultaneously.
- The source becomes overloaded.
- Response times spike across the entire system.
This is particularly dangerous for keys with expensive regeneration costs — complex queries,
aggregations, external API calls. The danger scales with two factors: how hot the key is, and how expensive
its regeneration is. Plain cache-aside has no defence here — every miss is independent, so N
concurrent misses become N source reads. The cost of a miss is what makes the herd dangerous: in the
measured EchoStore record an L1 hit costs 762 ns, while the L2 GET it replaces costs
31 us — the L1 hit is 40 times cheaper, and a herd pays that round trip
N times at once.
With no protection, every miss is its own source read, so a hot key turns one expiry into a flood the size of the request rate over the regeneration window.
Solution 1 — probabilistic early expiration
source · X-FetchThis technique decouples logical expiration from physical expiration. The idea is to give any request a small probability of refreshing the cache before it actually expires.
How it works. Set the physical TTL longer than needed — one hour, say. As requests arrive, each one calculates whether it should proactively refresh based on how close the key is to expiration and a random factor. When the key is fresh the probability of refresh is near zero; as expiration approaches the probability increases, until one request triggers a refresh while all others continue serving the slightly stale value.
The algorithm. The decision to refresh is based on:
current_time - (expiry_time - delta * beta * log(random()))
Where delta is the time needed to regenerate the value, beta is a tuning
parameter (typically 1.0), and random() produces a value between 0 and 1. The formula ensures
that as the gap to expiration shrinks, the probability of any single request triggering a refresh increases.
Instead of thousands of simultaneous source reads at expiration, a single request refreshes the cache moments
before expiration while others continue serving cached data. The traffic spike is smoothed away.
X-Fetch beats the deadline: one read rebuilds early, the rest serve the fresh value, no lock and no waiting — the early-refresh dive computes the formula, then grounds the applied part in EchoStore’s jitter.
Solution 2 — mutex locking
source · SET NX PXA deterministic approach where only one process is allowed to regenerate the cache. When a cache miss occurs:
- Attempt to acquire a lock using
SET lock:mykey <token> NX PX 5000. - If the lock is acquired, query the source and update the cache.
- Other requests either wait and poll, or return a stale or default value.
- Release the lock after the cache is populated.
Redis commands. The acquire is one command:
SET lock:popular_key abc123 NX PX 5000
The NX ensures only one client acquires the lock. The PX 5000 sets a five-second
timeout to prevent deadlocks if the lock holder crashes. Clients that fail to acquire the lock have options:
poll (sleep briefly and check the cache again), return stale data (serve a
stale copy if one is available), return a default (serve a degraded response), or
fail fast (return an error immediately).
Releasing the lock. The lock must be released safely. A plain DEL risks
deleting another client’s lock if yours already expired. Use a Lua script that checks the token matches
before deleting:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0
Mutex locking is the deterministic defence: one winner reads the source, the losers wait or serve stale, and a token-checked release deletes only the lock this request still owns — the lock-on-miss dive runs the race.
Comparison
source · the three regimesThe three regimes trade along three axes — complexity, source reads at expiry, and staleness:
| Approach | Complexity | Database queries | Staleness |
|---|---|---|---|
| No protection | Low | N (one per request) | None |
| X-Fetch | Medium | 1–2 | Brief window |
| Mutex lock | Higher | Exactly 1 | None |
All three converge a flood of misses to a single regeneration; they differ in complexity, in whether any request reads a stale value, and in whether the guard spans one process or the whole fleet.
Recommendation
source · what to reach forFor most applications, start with mutex locking — it is easier to reason about and provides strong protection. Consider X-Fetch for extremely high-traffic keys where even brief lock contention is problematic. Some systems combine both: X-Fetch for normal operation, with a mutex fallback for hard cache misses. The early-refresh formula is a probability, so an unlucky run can still let the key physically expire — the lock catches that hard miss.
Notes on ValkeySET key value PX <ms> writes the
value and its millisecond expiry atomically — the form EchoStore uses for the jittered TTL. A separate
EXPIRE after SET leaves an immortal key if the process dies between the two
— valkey.io/commands/set.
EchoStore stops the herd by construction
EchoStore does not bolt a lock on after the fact: misses route through the table’s owner, concurrent misses on one id coalesce onto a single in-flight load, and rows expire on a jittered clock so a cohort never re-herds. The cost the herd would have paid is the committed figure — an L1 hit against the L2 GET it replaces:
L1 hit reads/s 1311621 ← 762 ns each, caller-side ETS L2 GET 31 us ← the round trip a herd multiplies the L1 hit is 40 times cheaper than the round trip it replaces
The three dives
lock → refresh early → coalesceEach dive takes one solution and runs it on a hot key, in the arc lock → refresh early → coalesce: a deterministic per-key lock, a probabilistic refresh that beats expiry, and the coalescing EchoStore really does. Read them in order — the lock is the one to reach for first.
Lock-on-miss
A per-key regeneration lock with SET NX PX: one request wins and rebuilds, the
rest wait or serve stale, and a token-checked release stops a stale lock deleting another holder’s.
Probabilistic early refresh
X-Fetch the pattern: recompute before expiry with a rising probability, using the real
delta·beta·−log(rand) formula — then grounded in EchoStore’s real
expiry jitter.
Request coalescing
Concurrent misses on one id collapse into a single in-flight fill — the EchoStore
flights map and the :coalesced counter; every waiter reads the one answer.
When a hot key expires, do not let every reader rebuild it. Elect one regeneration — by a lock, by a probabilistic early refresh, or by coalescing — and let the rest wait or serve the previous value.
EchoStore fuses two of these into the owner: the single-flight flights map coalesces
concurrent misses onto one load (the :coalesced counter), and the jittered
expires_at spreads a cohort’s expiry — one fill per herd, no synchronized
re-herd.
Three dives, one rule: a hot key expires once, and it is rebuilt once too — not once per reader.
References
sources · relatedSources
- Valkey — SET — the
NXandPXoptions for the regeneration lock, andPXfor the jittered millisecond TTL EchoStore writes. - Redis — SET — the canonical command reference for the self-expiring lock.
- Redis — Documentation — expiry, key TTL, and the string commands the lock and refresh are built from.
- Sanfilippo, S. — antirez weblog — the Redis creator on locks, TTL, and atomic check-and-delete with a token.
- llmstxt.org — The llms.txt convention — the machine-readable map format the course follows.
Related in this course
- R1.05.1 · Lock-on-missthe regeneration lock
- R1.05.2 · Probabilistic early refreshX-Fetch the pattern, jitter the mechanism
- R1.05.3 · Request coalescingone fill per herd, every waiter served
- R1 · Cachingthe chapter
- R0 · OverviewValkey under codemojex
- /echomq/cacheEchoStore single-flight in the near-cache, in depth