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.
Short answer
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. I discussed who pushes freshness into a cache in the edge caching and invalidation question; the issue here is how many times that refresh runs.
Why
-
At expiry the cache does the opposite of its job. Its purpose is to protect the DB, yet it compresses the entire load into one second.
-
A fixed TTL manufactures synchronization. Give every key the same 10 minutes and they all fall together; they bring down the house as a group.
-
Making the user wait behind a recompute is unnecessary. If you hold a value that was correct a second ago, there’s no reason not to serve it.
What to do
-
Set up a single-flight mutex lock. On a miss the first request takes a short Redis lock (
SET NX PX, with a millisecond TTL); whoever holds it recomputes and fills the cache. So one query hits Postgres, not hundreds. Always put a TTL on the lock so a dying process can’t deadlock it forever. -
Add jitter to TTLs. 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. This also drops the “waiters” cost of the lock approach to zero.
-
Move to XFetch when recompute is genuinely expensive. With probabilistic early expiration each reader, as the TTL nears, randomly decides to refresh early with a probability proportional to the recompute cost, so the herd never forms. More elegant, but subtler to implement and test.
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. 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.