sehaj/learn
System design/06 Caching

06.05 · concept · 10 min

Stampedes and request coalescing

When a hot entry expires, or a whole cache starts empty, thousands of requests miss at once and all go to the database. Coalescing, leases, serving stale, early refresh and jitter keep one miss from becoming a thousand.

The problem: one expiry, a thousand queries

The viral link x7Kp gets 50,000 redirects a second. Its Redis entry has a one-hour TTL. When the hour is up, the entry vanishes, and the next request misses and asks the database. That query takes about 20 ms. During those 20 ms, another 1,000 requests arrive, find the entry still missing, and each sends its own query for the same row.

The database slows down under the burst, so the refill takes longer, so more requests miss, which slows it further. That feedback loop is a cache stampede (also called a thundering herd or dogpile). It has three common triggers:

TriggerWhat missesSnip example
A hot key expiresOne key, many requestsx7Kp's hour is up
Many keys expire togetherMany keys at onceEvery link warmed by a deploy at 09:00 with the same TTL
A cold cacheEverythingA Redis node restarts empty, or a new region opens

Fix 1: let one request do the work (coalescing)

The simplest fix: when a key is already being loaded, later requests wait for that load instead of starting their own. In one process this takes a few lines: keep a map of in-flight loads, keyed by cache key.

ts
type Entry<V> = { value: V; delta: number; expiresAt: number };

const cache = new Map<string, Entry<unknown>>(); // stands in for Redis in this sketch
const inFlight = new Map<string, Promise<unknown>>();

/** Run load() at most once at a time per key in this process; everyone else waits for the same answer. */
function coalesce<V>(key: string, load: () => Promise<V>): Promise<V> {
  const running = inFlight.get(key);
  if (running) return running as Promise<V>;
  const p = load().finally(() => inFlight.delete(key));
  inFlight.set(key, p);
  return p;
}

/** XFetch: refresh early, with a chance that rises as expiry nears and as the load gets slower. */
function shouldRefreshEarly(e: Entry<unknown>, beta = 1, now = Date.now()) {
  return now - e.delta * beta * Math.log(Math.random()) >= e.expiresAt;
}

export async function cached<V>(key: string, ttlMs: number, load: () => Promise<V>): Promise<V> {
  const e = cache.get(key) as Entry<V> | undefined;
  if (e && !shouldRefreshEarly(e)) return e.value;
  return coalesce(key, async () => {
    const start = Date.now();
    const value = await load();
    const delta = Date.now() - start; // how long a refill takes
    cache.set(key, { value, delta, expiresAt: Date.now() + ttlMs });
    return value;
  });
}

Tested with 1,000 concurrent misses on one key, the loader runs once and all 1,000 callers get the same value; if the load fails, every waiter gets the error and the next call tries again. Go ships this as singleflight, and proxies offer it as a setting: nginx's proxy_cache_lock lets only one request at a time fill a cache entry, while the others wait (up to 5 seconds by default).

Per-process coalescing caps the burst at one query per app server: 40 servers, 40 queries, instead of 1,000. That's usually enough. For a fleet-wide cap of one, the cache itself has to coordinate.

Fix 2: leases, coordinated by the cache

With leases, a miss doesn't just return "not found". The first client to miss gets a token and the job of refilling; others that miss shortly after are told to wait briefly and retry, by which time the entry is usually there. Facebook's memcache hands out a token for a key at most once every 10 seconds, and the same token, cancelled by a delete, stops the stale sets of 06.04 Invalidation and staleness. A simpler home-made version is a short lock in Redis: SET lock:x7Kp 1 NX PX 2000, where only the request that wins the lock queries the database.

Fix 3: serve the old value while refreshing

For most data, a copy a few seconds past its TTL is better than a slow page or a dead database. So keep two lifetimes:

  • Soft TTL: after this, the entry is stale. The next request triggers a refresh in the background (coalesced), but is answered immediately with the stale value.
  • Hard TTL: after this, the entry is too old to use at all.

No request ever waits for the refill except after the hard TTL. HTTP caches have the same idea built in: stale-while-revalidate (06.07 CDNs in depth), and nginx's proxy_cache_use_stale updating with proxy_cache_background_update. Serving stale is also what keeps Snip up when the database itself is down: stale-if-error.

Fix 4: refresh before expiry

Instead of waiting for the expiry, refresh a hot entry slightly early, at a random moment, so it never actually expires under load. The shouldRefreshEarly check above is the XFetch rule from a 2015 paper: each read refreshes early with a probability that grows as expiry approaches, scaled by delta (how long a refill takes) and beta (1 by default; larger refreshes earlier). With a 50 ms refill, a read 50 ms before expiry refreshes with probability about 37%; a read 500 ms before, about 0.005%. Hot keys, read thousands of times a second, are almost certain to be refreshed by one request just before they expire; cold keys rarely bother.

Fix 5: spread the expiries

Synchronised expiry is a self-inflicted stampede. Add jitter to TTLs (an hour plus or minus 10%, at random, 06.03 Eviction: LRU, LFU and TTL) so entries filled together expire apart.

Cold caches

When a whole cache is empty, none of the above is enough: every key misses at once. The options are to warm it first (copy the hottest keys from a warm replica or region before taking traffic), bring it in gradually (send it 1% of traffic, then 10%, and so on), and limit misses at the database with a concurrency cap so excess requests fail fast or wait rather than piling on (03.11 Backpressure and load shedding).

Seen in production

Facebook

2013

memcache leases

Facebook's leases address two problems, stale sets and thundering herds. A memcached server gives a client a lease token when it misses, and returns a token for a given key only once every 10 seconds; other clients that miss in that window are told to wait briefly, by which time the leaseholder has usually filled the entry. In one week of measurements on keys prone to thundering herds, the peak database query rate was 17,000 a second without leases and 1,300 a second with them. For data that can be slightly stale, clients can also be handed the recently deleted value instead of waiting.

What we foundOne token per key every 10 seconds turns a thundering herd into one database query and a short wait.

Scaling Memcache at Facebook (NSDI 2013)usenix.org ↗

Goodreads, Sapienza and Bugsnag

2015

XFetch

Andrea Vattani, Flavio Chierichetti and Keegan Lowenstein compare locks, external recomputation and probabilistic early expiration, and give an algorithm for the last that they prove optimal. Their XFetch function recomputes a value if the current time minus delta × beta × log(rand()) is at or past the expiry, with beta = 1 as the default. Because it's a local decision on each read, it needs no locks and no communication between servers.

What we foundRefreshing early at random, weighted by refill time, prevents stampedes without locks or coordination.

Optimal Probabilistic Cache Stampede Prevention (VLDB 2015)vldb.org ↗
Predict first

Snip's 40 app servers each coalesce misses per key. The viral link expires. How many database queries for it, at most, happen during the refill?

Commit to an answer before you read on.

Recap

Interview questions

SeniorfreqMust-knowConcept

What is a cache stampede, and how do you prevent one?

Report a mistakeEdit this page
☰ Caching06.05 · 51/147next →