Debugging a Cache Stampede at 3 AM
Hot key expiration caused a thundering herd that took down Redis and cascaded to PostgreSQL. Postmortem and fix.
Timeline
02:47 — PagerDuty: Redis memory at 98%, connection count spiking
02:52 — PostgreSQL CPU at 100%, query queue depth 4,000
03:15 — Identified hot key product:catalog:v2 expiring
03:40 — Deployed probabilistic early expiration fix
04:10 — Systems stabilized
What Happened
A popular cache key expired. 2,000 concurrent requests missed cache simultaneously. Each triggered a 2-second PostgreSQL query. Redis filled with in-flight recomputation. Death spiral.
public async Task<T?> GetWithEarlyExpiration<T>(
string key,
Func<Task<T>> factory,
TimeSpan ttl,
double beta = 1.0)
{
var cached = await _redis.GetAsync<CachedItem<T>>(key);
if (cached is null)
return await Refresh(key, factory, ttl);
var delta = ttl.TotalSeconds * beta * Math.Log(Random.Shared.NextDouble());
if (DateTime.UtcNow.ToUnixTimeSeconds() - cached.ComputeTime > ttl.TotalSeconds + delta)
_ = Refresh(key, factory, ttl); // fire-and-forget refresh
return cached.Value;
}
Prevention Checklist
- Probabilistic early expiration on hot keys
- Request coalescing (single flight pattern)
- Circuit breaker on cache miss → DB path
- Alert on cache miss rate, not just Redis memory
caching , incident , redis · Redis , .NET , PostgreSQL