In high-throughput e-commerce or real-time gaming architectures, few outages are as sudden and catastrophic as a Redis Cache Stampede (also known as the Thundering Herd problem).
The instant a highly cached top-page key (e.g., banner:main:top or product:catalog:hot) reaches its 300-second TTL expiration, 20,000 concurrent requests simultaneously register a cache miss and surge into the backend relational database. DB connection pools collapse within one second, CPU spikes to 100%, and application gateways trigger cascading 504 Gateway Timeout errors.
In this deep architectural post-mortem, we analyze why standard Cache-Aside and distributed mutex locking fail under extreme load, and how implementing the XFetch Probabilistic Early Expiration algorithm eliminates cache stampedes completely.
1. Symptom & Failure Reproduction
When a hot key expires in a naive Cache-Aside pattern, the database immediately bears the full brunt of all incoming traffic:
2026-09-25 18:00:01.012 [http-nio-8080-exec-104] ERROR c.z.h.p.HikariPool - Connection is not available, request timed out after 3000ms.
org.springframework.dao.QueryTimeoutException: Redis key "banner:main:top" expired; fallback query to MySQL failed: Connection pool exhausted.
2026-09-25 18:00:01.015 [http-nio-8080-exec-115] ERROR c.z.h.p.HikariPool - Connection is not available, request timed out after 3000ms.
# Redis latency check during the incident
$ redis-cli --latency -h 10.0.1.10
min: 0, max: 2, avg: 0.18 (845 samples) -- Redis remains completely healthy while the DB is crushed!
2. Root Cause: The Flaws of Naive Cache-Aside
[Incoming Requests (20,000 req/s)]
│
▼
[Redis Cache Hit?]
│ │
Yes No (Key expired at t0!)
│ │
[Return Cache] ▼
┌──────────┴────────────────────────┐
│ 20,000 Concurrent Threads │
│ Simultaneously Query Postgres/MySQL│
└──────────┬────────────────────────┘
│
▼
[💥 DB HikariPool Exhaustion]
Why Naive Distributed Mutexes (SETNX) Fall Short:
While locking with SET key lock NX PX 5000 ensures only one worker thread queries the DB, thousands of other requests are forced into spin-lock sleep cycles (Thread.sleep(50)), severely inflating p99 tail latency and saturating application worker threads.
3. The Optimal Solution: XFetch (Probabilistic Early Expiration)
The XFetch algorithm (formulated by Vitter et al.) enables worker threads to evaluate whether they should proactively refresh the cache in the background before the key physically expires.
As the remaining TTL decreases and computation duration (delta) increases, the probability of an early refresh approaches 1.0:
shouldRefresh = -delta * beta * ln(rand()) > ttlRemaining
Where:
-
delta: The computation/fetch duration in ms -
beta: Aggressiveness multiplier (default 1.0) -
rand(): Uniform random number between 0 and 1 -
ttlRemaining: Time remaining until key TTL expiration
4. Production TypeScript / Node.js Implementation
interface CachePayload<T> {
data: T;
delta: number; // Computation execution duration in ms
expiry: number; // Absolute expiration timestamp in ms
}
export async function getOrComputeWithXFetch<T>(
redisClient: any,
key: string,
ttlSeconds: number,
computeFn: () => Promise<T>,
beta: number = 1.0
): Promise<T> {
const raw = await redisClient.get(key);
const now = Date.now();
if (raw) {
const cached: CachePayload<T> = JSON.parse(raw);
const ttlRemaining = cached.expiry - now;
// XFetch check: early refresh triggered by probability
const shouldRefreshEarly = (cached.delta * beta * -Math.log(Math.random())) > ttlRemaining;
if (!shouldRefreshEarly) {
return cached.data; // Serve warm cache instantly
}
}
// Background or inline atomic re-computation
const startTime = Date.now();
const freshData = await computeFn();
const delta = Date.now() - startTime;
const expiry = Date.now() + (ttlSeconds * 1000);
const payload: CachePayload<T> = { data: freshData, delta, expiry };
// Set physical expiration to 2x TTL to guarantee safety margin
await redisClient.set(key, JSON.stringify(payload), 'EX', ttlSeconds * 2);
return freshData;
}
5. Prevention & Observability Checklist
-
TTL Jitter: Never configure static TTLs for bulk-generated caches. Always add randomized jitter:
ttl = baseTTL + Math.floor(Math.random() * maxJitter) - Prometheus Alerting: Monitor keyspace miss ratios to detect unexpected spikes:
- alert: RedisCacheMissRatioSpike
expr: rate(redis_keyspace_misses_total[1m]) / (rate(redis_keyspace_hits_total[1m]) + rate(redis_keyspace_misses_total[1m])) > 0.20
for: 2m
labels:
severity: critical
annotations:
summary: "Redis cache miss ratio exceeded 20% on {{ $labels.instance }}"
🛠️ Free Developer Utilities & Production Playbooks
If you are diagnosing distributed systems, HTTP archives, CORS preflights, or SQL plans, check out our zero-trust browser developer utilities:
- Interactive CORS Simulator & Config Generator: https://global-utils.com/en/cors-simulator
- Zero-Trust HAR File Sanitizer & Waterfall: https://global-utils.com/en/har-analyzer
- SQL to Drizzle & Prisma Schema Converter: https://global-utils.com/en/sql-to-drizzle
- Full Architecture & Distributed Systems Blog: https://global-utils.com/en/blog
Originally published at NerdKit Engineering.
Top comments (2)
Probabilistic early expiration là hướng tiếp cận thú vị vì nó giải quyết bài toán "khi nào thì refresh" thay vì "ai được refresh" — chuyển bottleneck từ coordination sang probability distribution.
Một vài điểm thực tế hay gặp khi triển khai XFetch hoặc biến thể:
Parameter tuning không hề trivial:
betadistribution parameters (thường làa=1, b=β) cần calibrate theo traffic pattern. E-commerce flash sale có burst cực ngắn →βcần nhỏ hơn so với steady traffic. Production thường dùng adaptive beta dựa trên observed hit rate thay vì hardcode.Clock skew giữa instances: Nếu các app server không đồng bộ NTP tốt (drift > 100ms), probabilistic window bị lệch → stampede vẫn xảy ra ở tail. Giải pháp nhẹ: embed
issued_attrong cached payload và compute expiration relative to đó thay vìDate.now()local.Cold start amplification: Khi deploy mới (cache empty), tất cả instances cùng rơi vào probabilistic window đầu tiên. Thường combine với stale-while-revalidate (serve stale ngay, background refresh) cho lần đầu — giảm P99 latency spike đáng kể.
Observability gap: Hầu hết team log cache hit/miss nhưng quên log
early_expiration_triggeredvsnatural_expiration. Không có metric này PS: the tool I meant is on labagent .techNice writeup — this is one of the cleaner explanations of XFetch I've seen. Two additions from the Go world: (1) most Go shops reach for singleflight first — coalesce concurrent recomputations so only one goroutine hits the DB. It doesn't replace XFetch; they compose well (singleflight for the thundering moment, XFetch to spread refresh load). (2) XFetch's blind spot is cold start: after a full flush, everything is expired at once and probabilistic early refresh can't help — there's nothing fresh to serve. For that case you need stale-while-revalidate or a distributed lock (SET NX) around recomputation. Also worth flagging: the formula needs expected recompute time β, which for p99 DB queries is a guess — a wrong β either refreshes too aggressively or still stampedes.