How do I prevent a Redis cache stampede (thundering herd) — mutex lock or XFetch?
Question
We cache a high-traffic e-commerce homepage's product list in Redis for 10 minutes. The instant the cache expires, thousands of concurrent requests get a cache miss and all hammer PostgreSQL at once, taking the DB down. How do I solve this cache stampede at the architecture level with a mutex lock or probabilistic early expiration (XFetch)?
Answer
Short answer: the problem isn’t the “miss” itself, it’s that the key falls off a cliff at the same instant and the whole herd hammers the DB at once. The fix is to reduce that moment to a single request and spread the herd over time.
What you’re hitting is the classic thundering herd: when the TTL expires, not one user but thousands get a cache miss in the same millisecond and fire the same query at PostgreSQL. Hundreds run when one would do, and the DB buckles.
- Single-flight mutex lock. On a miss, the first request takes a short Redis lock (
SET NX PX, with a millisecond TTL). Whoever holds the lock recomputes and fills the cache; everyone else either briefly serves stale data or waits and reads the new value. So one query hits Postgres, not hundreds. Always put a TTL on the lock so that even if the recomputing process dies, the lock doesn’t deadlock forever. - Probabilistic early expiration (XFetch). Each reader, as the TTL nears, randomly decides “should I refresh early?” with a probability proportional to the recompute cost. The refresh gets spread out without ever converging on one instant, so the herd never forms. When recompute is expensive and traffic is very high, this is the most elegant fix.
- Add jitter to TTLs. If you give every key a fixed 10 minutes, they all expire in lockstep — they bring down the house together. Make the TTL
10min ± a few random minutesand keys expire at different moments, spreading the pressure naturally. One-line change, big payoff. - Serve-stale-while-revalidate so users don’t wait. When a key expires, keep serving the old value and refresh in the background. The user never waits on a recompute; the process holding the lock quietly refreshes the cache. This also drops the “waiters” cost of the lock approach to zero.
Bottom line: if it were me, I’d start with single-flight lock + TTL jitter in most cases — together they cover the vast majority of cases, are operationally simple, and are easy to reason about. Reach for XFetch when recompute is genuinely expensive and traffic is genuinely huge; it’s more elegant but subtler to implement and test. And whichever you pick, add serve-stale-while-revalidate: never make a user wait behind a database query.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.