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 (3)
Spot on! The Go
singleflight.Grouppattern is such an elegant way to coalesce duplicate in-flight requests locally before they even touch the network or cache.Pairing
singleflightfor immediate concurrent deduplication with XFetch for spreading background regeneration load is essentially the gold standard for high-throughput distributed caching. You are also 100% right about cold starts and β tuning — an adaptive EMA of compute durations plus stale-while-revalidate solves that beautifully. Thanks a ton for adding this nuance to the thread! 🚀XFetch là một hướng tiếp cận thú vị vì nó chuyển bài toán từ "khóa toàn cục" sang "xác suất cục bộ", giảm contention trên Redis lock key. Tuy nhiên có vài điểm thực tế cần cân nhắc:
Jitter parameter tuning: Công thức
expire_at - now < ttl * beta * rand()nhạy cảm vớibeta. Beta quá lớn → refresh quá sớm, lãng phí compute; quá nhỏ → vẫn bị stampede. Trong production, tôi thường để beta = 0.1–0.3 và monitor tỷ lệearly_refresh / total_requestsđể điều chỉnh.Clock skew giữa các instance: Nếu app servers không đồng bộ NTP tốt,
nowlệch nhau làm phân bố refresh bị vỡ. Giải pháp đơn giản: dùngTIMEcommand của Redis làm giờ tham chiếu thay vì system clock.Cold start / deploy mới: Khi deploy, toàn bộ cache rỗng → XFetch không có giá trị
expire_atđể tính xác suất. Cần pre-warm hoặc fallback về mutex lock cho lần đầu.Memory overhead: Mỗi key cần lưu thêm
expire_at(hoặc encode vào value). Với hàng — found it via LabAgent, site: labagent .techNice writeup, this is one of the cleaner explanations of XFetch I've seen. Two additions from the Go world. 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 and XFetch to spread refresh load. XFetch's blind spot is cold start though. 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 beta either refreshes too aggressively or still stampedes.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.