All posts

Cache stampedes explained: protect your database with Redis, TTL jitter, and singleflight

· 12 min read

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.

Without protection: later requests also read the database before the first refill completes
  1. API / Redis: The popular product key expires
  2. Client: Request A arrives and misses the cache
  3. Database: Fetching the product for A
  4. Client: Requests B and C miss the same key
  5. Database: B and C start duplicate fetches, increasing load
  6. 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.

Illustrative calculation: database reads per second for 10,000 API requests/s, with one database read per miss
  • 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

ProblemFirst mechanism to considerWhat remains
Many keys created together expire togetherAdd a random range to TTLsA single hot key can still stampede
Duplicate expensive fetches for one keyShare in-flight work with singleflightOther Groups and processes can still fetch independently
Readers cannot wait for a refillServe permitted stale data and refresh asynchronouslyBound staleness and coalesce refresh work
Fetches must also be reduced across processesAcquire a time-limited refresh lease through Redis or another coordinatorExpiry and failures can allow duplicates or stale writes
Many distinct misses or a Redis outage saturate the databaseBound DB concurrency and queue lengthSome 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.

Pseudocode: randomize TTL without exceeding a 300-second limit
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.

Go miss-path excerpt: DoChan separates each caller’s wait from the shared refresh deadline
// 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 stateResponseRefresh behavior
Less than 60 seconds since populationCached valueUsually none
At least 60 but less than 90 secondsPermitted stale valueCoalesce and refresh asynchronously
90 seconds or older, or entry missingWait within limits, degrade, or return an errorFetch 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.

Redis: time-limited acquisition and owner-checked Lua release, as separate operations
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 0

Invoke 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 conditionWhat to verify
Expire one hot keyConcurrent refills stay within the coalescing scope and waiters respect deadlines
Create and expire many keys togetherTTL spreading and total refresh limits work
Delay or fail database readsShared refreshes finish and failures do not trigger immediate repeated fetches
Fail Redis communicationsDatabase limits hold and degradation or rejection takes over
Recover from an empty cacheWarm-up and foreground requests do not exhaust shared database permits
Stop a refresher or exceed a leaseRefilling resumes and stale writes are handled

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?

In a design interview: what happens if the cache fails?

Primary sources for API behavior and failure assumptions

Tips this post digs into