skip to content

A cache-aside key backing a hot database query expires, and within the same millisecond thousands of concurrent requests for that key all miss the cache. Describe two distinct mechanisms an application can use to stop this from turning into a 'thundering herd' that overwhelms the database.

level: seniorimportance: should knowfreq 65%

answer

  1. per-key lock / mutex on reload
  2. single-flight / request coalescing
  3. probabilistic early expiry / jitter
  4. one DB query instead of N
  5. cross-instance vs in-process dedupe

basics

~20 s

Use a lock so only one request reloads the data while everyone else waits or gets a slightly old value. Or make one request per process share its in-flight database call with all the others asking for the same thing, so only one query actually runs.

solid answer

~40 s

Two common mechanisms: (1) A per-key lock — the first request to miss acquires a short-lived lock (e.g. Redis SETNX) and does the reload; everyone else waits and retries the cache, or serves stale data briefly, instead of independently querying the database. (2) Request coalescing / single-flighting — within a process, concurrent requests for the same key attach to one in-flight database call instead of starting their own, so N callers produce exactly one query. A complementary third option is probabilistic early expiration: refresh proactively before hard TTL expiry with increasing probability as expiry nears, so reloads get spread out instead of synchronized.

go deeper

for a junior

Should recognize that a stampede is many requests hitting the database at once after a cache miss, even without knowing named mitigations.

for a middle

Should be able to name at least one mitigation, e.g. a lock so only one request reloads the database.

for a senior

Should be able to describe two distinct mechanisms (locking and coalescing, or locking and jittered early expiry) and articulate the trade-off in added latency/complexity for waiting requests.

for a principal

Should reason about combining mechanisms (cross-instance lock plus in-process coalescing) for a fleet of many instances, and weigh mitigation cost against how hot/critical the specific key actually is.

## What a thundering herd is A **thundering herd** (also called a **cache stampede**) happens in cache-aside when a single hot key — or many keys together — transitions from cache-hit to cache-miss at the same instant, and every one of the many concurrent requests that were relying on that key independently discovers the miss and independently starts the exact same expensive fallback: 1. query the database, 2. get the same result, 3. write it back into the cache. If a key backs a query that normally serves thousands of requests per second entirely from cache, the instant it expires you get a burst of duplicate, simultaneous, identical database queries — the database sees load it was never sized for, often causing latency spikes or timeouts that cascade into further retries and make the spike worse. ## The first mitigation — a per-key lock The first common mitigation is a lock (mutex) around the reload for a given key. When a request misses the cache, before querying the database it attempts to acquire a short-lived lock specific to that key (e.g. a Redis `SETNX` with its own short TTL as a safety valve against a crashed lock-holder). Only the request that wins the lock actually queries the database and repopulates the cache; every other concurrent request that loses the lock either - waits briefly and retries the cache read (expecting the winner to have populated it by then), or - falls back to serving a slightly stale previous value if one is retained. This collapses N concurrent database queries down to exactly one, at the cost of added latency for the requests that have to wait, and at the cost of building and operating the locking mechanism itself, including handling a lock-holder that crashes or is slow. ## The second mitigation — request coalescing The second common mitigation is **request coalescing**, also called **single-flighting** or **in-flight deduplication**. Instead of coordinating through the cache with an explicit lock, the application process itself tracks, in memory, whether a database load for a given key is already underway; if a second concurrent request for the same key arrives while a load is already in flight, it doesn't start a second database query — it simply attaches to the result of the one already running and receives the same value when it completes. This achieves the same "only one database query per stampede" outcome as locking, but: - it's implemented purely in application memory (Go's `singleflight` package and similar libraries in other languages are canonical implementations) rather than via a distributed lock, so it's cheaper and simpler within a single process; - it only dedupes requests landing on the same process — a fleet of many app instances still needs a cross-instance mechanism (like the lock above) to fully collapse the herd. ## A third, complementary technique A third, complementary technique is **probabilistic early recomputation** (sometimes called early expiry with jitter): instead of waiting for a hard expiry and then having everyone discover the miss at once, each read near the end of a key's TTL computes a small, increasing probability of proactively refreshing the value early, so that reloads get smeared across a window before the hard deadline rather than synchronized to the exact expiry instant. This avoids the herd from ever forming in the first place, at the cost of some extra, usually-redundant early reloads and a bit of algorithmic complexity in the read path. ## Combining them in practice A concrete example: a news site caches the rendered homepage under one key with cache-aside; if that single key expires during a traffic spike, every concurrent visitor triggers an identical, expensive render-and-database-query at once. Production systems facing this typically combine a per-key lock (so only one process actually re-renders) with in-process request coalescing (so multiple threads/requests within that one process share the single in-flight render) — the lock solves the cross-instance duplication, coalescing solves the within-instance duplication, and together they turn thousands of duplicate database queries into exactly one. The main trade-off across all these mechanisms is added latency and complexity for the unlucky requests that arrive during the reload window versus the alternative of simply accepting the database load spike; for genuinely hot keys, that trade is almost always worth it.

  • What's the practical difference between a distributed lock approach and in-process request coalescing for stopping a cache stampede?
    A distributed lock (e.g. via the cache server itself) coordinates across every process/instance in the fleet, so only one instance anywhere does the database load. In-process request coalescing only dedupes concurrent requests landing on the same single process — it's cheaper and needs no external coordination, but a fleet of many instances would each still independently do one database load unless a cross-instance mechanism is layered on top.
  • What should the requests that lose the lock do while they wait for the winner to repopulate the cache?
    Common options are: retry the cache read after a short backoff, expecting the winner to finish shortly; or serve a stale-but-still-cached previous value if the cache retains it past its official TTL for exactly this purpose. Simply blocking forever or erroring out defeats the purpose of the mitigation.
  • How does probabilistic early expiration avoid the stampede without needing a lock at all?
    By spreading reloads out over time instead of concentrating them at one instant: as a key's TTL approaches, each read computes a small chance of triggering an early refresh, so different requests end up triggering the refresh at slightly different times well before the hard deadline, rather than all requests discovering a synchronized miss simultaneously.

Like a single ticket window opening after a long line has built up — instead of letting every person in line rush the counter at once, one clerk (the lock winner) serves the request and everyone else's order gets filled from that same answer.

saying these in an interview costs you the question

  • Only names one mitigation and calls it sufficient on its own
  • Confuses request coalescing (in-process) with a distributed lock (cross-instance)
  • Suggests just increasing the TTL as the fix for stampedes
  • Doesn't consider what waiting requests should do while the lock-holder reloads

context