The Illusion of the Transparent Cache
Caching is often introduced as a simplistic key-value band-aid: place Redis in front of a slow SQL database, set a 10-minute Time-To-Live (TTL), and assume performance issues are solved.
In production environments handling 50,000 requests per second, caching is an active distributed subsystem with its own failure modes:
- Cache Stampede (Thundering Herd): When a heavily accessed key expires, thousands of concurrent requests miss simultaneously, inundating the primary database with identical heavy queries and triggering database collapse.
- Cache Penetration: Malicious or malformed queries requesting non-existent IDs bypass the cache completely and hit the database continuously.
- Cache Avalanche: Hundreds of keys set with identical fixed TTLs expire at the exact same millisecond, causing catastrophic traffic surges on backend datastores.
1. The Cache Stampede Problem
Consider a viral product catalog page requested 10,000 times per second:
Timeline: Key Expires at T = 0+-----------------------------------------------------------------------------------+| [T = 0.00s] Key 'product:9920' expires in Redis || [T = 0.01s] 500 concurrent incoming HTTP requests execute GET 'product:9920' || [T = 0.02s] All 500 requests get cache miss (nil) || [T = 0.03s] All 500 requests independently execute: || "SELECT * FROM products JOIN inventory JOIN pricing WHERE id = 9920" || [T = 0.05s] PostgreSQL connection pool exhausted (CPU hits 100%, latency 8,000ms)|+-----------------------------------------------------------------------------------+To prevent this collapse, distributed engineers employ two primary defense patterns: Singleflight Request Coalescing and Probabilistic Early Expiration (XFetch).
2. Mitigation Strategy 1: Singleflight Request Coalescing
The Singleflight pattern ensures that for any given key, only one backend fetch operation is in flight at any given moment. All other concurrent requests sharing the same key pause and await the outcome of the single active worker:
Client 1 ----+Client 2 ----+--> [Singleflight Group] ---> [Single Database Query] ---> DB (1 Load)Client 3 ----+ | v Distribute single result to all 3 clients concurrently// Production Go implementation using golang.org/x/sync/singleflightpackage cache
import ( "context" "fmt" "time" "github.com/redis/go-redis/v9" "golang.org/x/sync/singleflight")
type ProductCache struct { rdb *redis.Client g singleflight.Group}
func (c *ProductCache) GetProduct(ctx context.Context, id string) (*Product, error) { cacheKey := fmt.Sprintf("product:%s", id)
// 1. Try reading from Redis val, err := c.rdb.Get(ctx, cacheKey).Result() if err == nil { return deserializeProduct(val), nil }
// 2. Cache Miss: Coalesce all concurrent callers into a single database execution data, err, _ := c.g.Do(cacheKey, func() (interface{}, error) { // Double check cache in case another caller populated it moments ago if v, e := c.rdb.Get(ctx, cacheKey).Result(); e == nil { return deserializeProduct(v), nil }
// Execute expensive database operation product, err := queryDatabaseForProduct(ctx, id) if err != nil { return nil, err }
// Populate cache with jittered TTL to prevent synchronization avalanches ttl := 15*time.Minute + time.Duration(deterministicJitter(id))*time.Second _ = c.rdb.Set(ctx, cacheKey, serializeProduct(product), ttl).Err()
return product, nil })
if err != nil { return nil, err }
return data.(*Product), nil}3. Mitigation Strategy 2: The XFetch Probabilistic Algorithm
Published in 2015 by Vattani et al., the XFetch algorithm computes a probabilistic decision on every read: as the key approaches its expiration time, the probability of an early asynchronous background refresh increases proportionally to the computational cost of the query:
Where:
- is the compute time taken to recompute the value (in seconds).
- is an aggression multiplier (typically set to ).
- is a uniform random float between and .
- is the seconds remaining before the cache entry expires.
// TypeScript implementation of XFetch probabilistic early recomputationinterface CachedItem<T> { value: T; delta: number; // Compute duration in seconds expiry: number; // Absolute epoch timestamp (seconds)}
async function xFetch<T>( key: string, ttlSeconds: number, computeFn: () => Promise<T>, beta: number = 1.0): Promise<T> { const raw = await redisClient.get(key); const now = Date.now() / 1000;
if (raw) { const item: CachedItem<T> = JSON.parse(raw); const remainingTTL = item.expiry - now;
// Probabilistic early expiration check const shouldRefresh = -beta * item.delta * Math.log(Math.random()) > remainingTTL;
if (!shouldRefresh) { return item.value; } }
// Trigger recomputation const startTime = Date.now() / 1000; const freshValue = await computeFn(); const delta = (Date.now() / 1000) - startTime;
const payload: CachedItem<T> = { value: freshValue, delta: Math.max(0.01, delta), expiry: now + ttlSeconds, };
// Store in Redis with safety buffer (TTL + 60s) await redisClient.set(key, JSON.stringify(payload), 'EX', ttlSeconds + 60);
return freshValue;}Example (Why XFetch Outperforms Fixed TTLs)
With XFetch, keys never expire before being refreshed during active read traffic. As load increases, the probability of an early refresh naturally scales up, ensuring the cache is always warm while executing the underlying query only once per cycle.
4. Redis Cluster Topologies & Hash Slots
When dataset memory demands exceed a single machineโs RAM, or when network interface throughput exceeds 10Gbps, Redis must scale out across a Redis Cluster.
Redis Cluster does not use consistent hashing rings. Instead, it partitions data into 16,384 discrete Hash Slots.
[Cluster Topology: 16,384 Hash Slots]+------------------------------------------------------------------------------------+| Node A: Master (Slots 0 โ 5,460) <-- Replica A (Async Read Replica) || Node B: Master (Slots 5,461 โ 10,922) <-- Replica B (Async Read Replica) || Node C: Master (Slots 10,923 โ 16,383) <-- Replica C (Async Read Replica) |+------------------------------------------------------------------------------------+
Hash Slot Mapping Formula: slot = CRC16(key) mod 16384The Hash Tag Constraint ({...})
In Redis Cluster, multi-key operations (such as transactions, MGET, or Lua scripts) are forbidden across keys residing on different nodes. If keys map to different hash slots, Redis returns a -CROSSSLOT Keys in request don't hash to the same slot error.
To force related keys into the exact same hash slot and node, wrap the partition key in curly braces:
# BAD: Keys hash to different slots across separate physical nodesSET user:100:profile "{...}" # CRC16("user:100:profile") -> Slot 3410 (Node A)SET user:100:orders "{...}" # CRC16("user:100:orders") -> Slot 9812 (Node B)MGET user:100:profile user:100:orders # FAILS WITH CROSSSLOT ERROR!
# GOOD: Hash Tag forces CRC16 to hash ONLY the content inside {...}SET {user:100}:profile "{...}" # CRC16("user:100") -> Slot 7140 (Node B)SET {user:100}:orders "{...}" # CRC16("user:100") -> Slot 7140 (Node B)MGET {user:100}:profile {user:100}:orders # SUCCEEDS AT MICROSECOND SPEED!5. Memory Eviction Policy Matrix
When Redis reaches maxmemory, it must discard data according to its configured eviction policy. Selecting the wrong policy can wipe authentication sessions or crash active workloads:
| Eviction Policy | Mechanism | Ideal Production Use Case | Risk Factor |
|---|---|---|---|
volatile-lru | Evicts least recently used keys with an explicit TTL | Mixed workloads (Sessions + Cache) | Retains eternal keys indefinitely |
allkeys-lru | Evicts least recently used keys across entire database | Pure caching tiers | May drop important metadata |
volatile-lfu | Evicts least frequently used keys with an explicit TTL | Protecting hot keys from cache misses | Takes time to build frequency data |
noeviction | Never deletes data; returns error on new writes | Persistent queues / PubSub / Redis as DB | Fails writes immediately when full |
# Production redis.conf memory settingsmaxmemory 16gbmaxmemory-policy allkeys-lfu # LFU preserves true hot keys over transient burstsmaxmemory-samples 10 # Increases precision of sampled LFU algorithmProduction Takeaways
- Jitter Every TTL: Never use flat constants like
TTL = 3600. Add 10% random jitter (3600 + rand(360)) to eliminate synchronized cache avalanches. - Coalesce with Singleflight: Prevent thundering herd traffic against your primary database during cold starts using
singleflight. - Use Hash Tags in Redis Cluster: Structure multi-key Redis Cluster keys with
{tenant_id}:entityto guarantee slot co-locality. - Prefer LFU over LRU: In high-velocity read systems,
allkeys-lfuprevents one-off batch scans from evicting genuinely popular hot keys.
Recommended for You
Explore more articles on similar topics and continue reading.
Autonomous AI Agents: Architecture, Cognitive Loops, and Multi-Agent Systems
PostgreSQL Performance Tuning at Scale: Indexing Strategies, Buffer Cache, and Connection Pooling