Cache stampedes explained: protect your database with Redis, TTL jitter, and singleflight
Why does database load spike when a Redis key expires? Compare TTL jitter, Go singleflight, stale-while-revalidate, and distributed locks using a product API receiving 10,000 reads per second, with worked examples and implementation guidance.
What is a cache stampede? Why database load spikes when a TTL expires
A product endpoint checks Redis, reads the database on a miss, and populates Redis with the result. With this cache-aside pattern, more requests can arrive before the first refill finishes. When a popular product expires and every reader starts the same database fetch, the result is a cache stampede.
- API / Redis: The popular product key expires
- Client: Request A arrives and misses the cache
- Database: Fetching the product for A
- Client: Requests B and C miss the same key
- Database: B and C start duplicate fetches, increasing load
- API / Redis: Duplicate fetches continue until A populates the cache
A slower database extends the refill window, allowing more misses to accumulate. Correlate expiration times with fetch starts and database waits. Repeated spikes after expiration are a useful clue, but evictions and Redis network failures can also increase misses; investigate those paths as well.
Worked example: 10,000 reads per second and a 500ms refill
During that window, 10,000 requests/s × 0.5s = about 5,000 requests arrive. Without protection, each tries to fetch the same data. This estimates requests within a window, not 5,000 SQL statements necessarily executing simultaneously. Some may wait for connections, and saturation can extend the assumed 500ms window.
Now consider steady traffic of 10,000 reads/s across the whole API. In a simple model with one database read per miss, no coalescing, and no retries, database reads equal request rate × (1 − hit rate). A 99% hit rate produces 100 reads/s, 90% produces 1,000, and a completely unusable cache produces 10,000 if every request proceeds to the database: up to 100 times the 99% baseline.
- 99% hit rate: 100 reads/s
- 90% hit rate: 1,000 reads/s
- 0% hits; all requests reach DB: 10,000 reads/s
Time-averaged hit rates hide short bursts after expiration. Inspect database arrivals over short windows and duplicate fetches for the same key. Avoid adding every product ID as a metric label; use traces or sampled logs to investigate key skew.
Compare the defenses: TTL jitter, singleflight, stale responses, and locks
| Problem | First mechanism to consider | What remains |
|---|---|---|
| Many keys created together expire together | Add a random range to TTLs | A single hot key can still stampede |
| Duplicate expensive fetches for one key | Share in-flight work with singleflight | Other Groups and processes can still fetch independently |
| Readers cannot wait for a refill | Serve permitted stale data and refresh asynchronously | Bound staleness and coalesce refresh work |
| Fetches must also be reduced across processes | Acquire a time-limited refresh lease through Redis or another coordinator | Expiry and failures can allow duplicates or stale writes |
| Many distinct misses or a Redis outage saturate the database | Bound DB concurrency and queue length | Some requests require degraded responses or rejection |
TTL jitter spreads expiration across multiple keys
A batch that creates many product entries with a 300-second TTL also synchronizes their expiration five minutes later. Sampling TTLs uniformly from 270 to 300 seconds spreads expiration across a 30-second window. If 300 seconds is the permitted maximum, avoid adding positive jitter beyond that ceiling.
ttl_seconds = uniform_random(270, 300)
cache.set(key, value, ttl=ttl_seconds)All readers of one hot key still see one expiration time. Jitter alone does not eliminate duplicate fetches after that key expires. Longer TTLs can reduce how often it happens, at the cost of older data, but the miss path still needs protection.
Coalesce concurrent fetches with Go singleflight
Go singleflight shares the result of in-flight work for the same Group and key. Share the Group through a service instance; creating one per request defeats coalescing. Include tenant, language, and permission scope when they affect the result so unrelated callers do not share data.
// Service.flights: one shared singleflight.Group per process.
// Service.lifecycle: canceled when this service shuts down.
func (s *Service) onCacheMiss(ctx context.Context, key string) ([]byte, error) {
result := s.flights.DoChan(key, func() (any, error) {
refreshCtx, cancel := context.WithTimeout(s.lifecycle, 800*time.Millisecond)
defer cancel()
// Recheck the cache, acquire a bounded DB permit on a miss,
// read the DB, then populate the cache before returning.
return s.fetchAndCache(refreshCtx, key)
})
select {
case <-ctx.Done():
return nil, ctx.Err()
case r := <-result:
if r.Err != nil {
return nil, r.Err
}
return r.Val.([]byte), nil
}
}This excerpt omits surrounding definitions using context, time, and golang.org/x/sync/singleflight. The application-defined fetchAndCache rechecks the cache, acquires a bounded DB permit on a remaining miss, reads the database, and populates the cache. Each I/O must respect refreshCtx. Callers treat the returned []byte as immutable, and the function also needs a defined cache-error policy.
With DoChan, callers can stop waiting through ctx.Done(). Shared work uses the service lifecycle and a separate 800ms deadline, so the initiating client disconnecting does not cancel everyone’s refresh. Work can continue until its deadline even if all waiters leave; database and cache clients must observe that deadline too.
Stale-while-revalidate: define separate fresh and stale deadlines
Rankings and product descriptions may tolerate returning an older value while refreshing. Consider an application policy with freshUntil 60 seconds and staleUntil 90 seconds after population. A physical Redis TTL of 60 seconds would delete the fallback too early, so retain the entry at least until staleUntil. Earlier loss through memory pressure or other causes remains an ordinary miss.
| Entry state | Response | Refresh behavior |
|---|---|---|
| Less than 60 seconds since population | Cached value | Usually none |
| At least 60 but less than 90 seconds | Permitted stale value | Coalesce and refresh asynchronously |
| 90 seconds or older, or entry missing | Wait within limits, degrade, or return an error | Fetch only if a DB permit is available |
Starting an unbounded background refresh per request merely relocates the stampede. Coalesce by key and cap total workers and queued jobs across keys. Failed refreshes must not silently extend stale deadlines. If population already read a lagging replica, a 90-second retention limit does not bound age relative to the source; use source versions or observation timestamps when that distinction matters.
Redis distributed locks: acquisition, expiry, and release belong together
To reduce duplicates across processes, coordinate refresh ownership. A basic single-Redis approach uses SET NX PX with a unique random owner token and an expiry. Only the successful caller refreshes, rechecking the cache after acquisition. The 1,500ms below is illustrative; choose the lease duration from observed refill times and a failure policy.
SET lock:product:42 <unique-owner-token> NX PX 1500
-- Release atomically, only if this caller still owns the lock.
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0Invoke the release script with the lock key in KEYS[1] and the acquisition token in ARGV[1]. Compare and delete atomically so an expired owner cannot delete a successor’s lock. Unsuccessful callers can serve permitted stale data or recheck the cache with a short overall deadline and jittered delays. Sending every loser directly to the database defeats the mechanism.
What if Redis goes down? Bound database access before forwarding misses
Coalescing one key does not limit fetches for many different products. Unconditionally forwarding all requests during a Redis outage can still overwhelm the database. Bound DB concurrency and waiting requests; if a permit cannot be obtained before the deadline, degrade the response or return an error such as 503. For per-process limits, account for the maximum process count when calculating the fleet-wide total.
Apply these controls to both cache-error fallback and background refresh. A Redis outage may also make Redis-based locks unavailable. During recovery, warm popular keys gradually and restore traffic within database headroom. Add backoff or briefly shared failures to prevent successive requests from hammering the source after a failed shared refresh.
Load tests and monitoring: look beyond cache hit rate
| Test condition | What to verify |
|---|---|
| Expire one hot key | Concurrent refills stay within the coalescing scope and waiters respect deadlines |
| Create and expire many keys together | TTL spreading and total refresh limits work |
| Delay or fail database reads | Shared refreshes finish and failures do not trigger immediate repeated fetches |
| Fail Redis communications | Database limits hold and degradation or rejection takes over |
| Recover from an empty cache | Warm-up and foreground requests do not exhaust shared database permits |
| Stop a refresher or exceed a lease | Refilling resumes and stale writes are handled |
- Client: success rate, p95 and p99, waiter timeouts, and the fraction and age of stale responses.
- Cache: distinguish hits, misses, communication errors, expirations, and evictions.
- Refresh: actual fetch starts, coalesced waiters, duration, failures, and queue length.
- Database: read arrivals, active work, connection waits, CPU and I/O, and rejected requests.
Record process count, key distribution, data volume, TTLs, database conditions, and test duration with results. A lower client p99 is not success if responses exceed their permitted stale age. Verify ordinary performance and source protection during expiration and outages separately.
FAQ: will more Redis capacity or shorter TTLs prevent stampedes?
- Will adding Redis capacity solve it? It helps with memory or Redis throughput bottlenecks. Duplicate source fetches after a miss still need coalescing.
- Should TTLs be shorter? Shorter TTLs reduce staleness but can increase expiration and source reads. Choose freshness and refill load together.
- Is this a cache avalanche? That term sometimes describes synchronized expiration across many keys. Terminology varies; establish whether one hot key or many keys are involved before choosing a defense.
- Does singleflight remove the need for distributed locks? It may be sufficient if the database can handle one refill per process. Decide from fleet size, fetch cost, and the duplicates you can tolerate.
In a design interview: what happens if the cache fails?
Primary sources for API behavior and failure assumptions
- Go — singleflight.Group / DoChan
- Redis Docs — Distributed Locks: single-instance implementation and failure assumptions
- AWS Builders’ Library — Caching challenges and strategies
- RFC 5861, Section 3 — stale-while-revalidate
Tips this post digs into
- An expiring hot key turns into a database stampede
- Raising a cache hit ratio from 90% to 99% cuts database reads to a tenth
- "What happens if the whole cache goes down?"
- Under overload, reject some requests quickly instead of serving all slowly
- Interpret load tests with representative data, caches, skew, and duration