High-Throughput Distributed Caching: Stampede Mitigation, Probabilistic Early Expiration, and Redis Cluster

High-Throughput Distributed Caching: Stampede Mitigation, Probabilistic Early Expiration, and Redis Cluster
index

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/singleflight
package 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:

Evaluateย Refreshย Condition:ย โˆ’ฮฒร—ฮดร—lnโก(rand())>remaining_TTL\text{Evaluate Refresh Condition: } -\beta \times \delta \times \ln(\text{rand}()) > \text{remaining\_TTL}

Where:

  • ฮด\delta is the compute time taken to recompute the value (in seconds).
  • ฮฒ>0\beta > 0 is an aggression multiplier (typically set to 1.01.0).
  • rand()\text{rand}() is a uniform random float between 00 and 11.
  • remaining_TTL\text{remaining\_TTL} is the seconds remaining before the cache entry expires.
// TypeScript implementation of XFetch probabilistic early recomputation
interface 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 16384

The 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:

Terminal window
# BAD: Keys hash to different slots across separate physical nodes
SET 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 PolicyMechanismIdeal Production Use CaseRisk Factor
volatile-lruEvicts least recently used keys with an explicit TTLMixed workloads (Sessions + Cache)Retains eternal keys indefinitely
allkeys-lruEvicts least recently used keys across entire databasePure caching tiersMay drop important metadata
volatile-lfuEvicts least frequently used keys with an explicit TTLProtecting hot keys from cache missesTakes time to build frequency data
noevictionNever deletes data; returns error on new writesPersistent queues / PubSub / Redis as DBFails writes immediately when full
# Production redis.conf memory settings
maxmemory 16gb
maxmemory-policy allkeys-lfu # LFU preserves true hot keys over transient bursts
maxmemory-samples 10 # Increases precision of sampled LFU algorithm

Production Takeaways

  1. Jitter Every TTL: Never use flat constants like TTL = 3600. Add 10% random jitter (3600 + rand(360)) to eliminate synchronized cache avalanches.
  2. Coalesce with Singleflight: Prevent thundering herd traffic against your primary database during cold starts using singleflight.
  3. Use Hash Tags in Redis Cluster: Structure multi-key Redis Cluster keys with {tenant_id}:entity to guarantee slot co-locality.
  4. Prefer LFU over LRU: In high-velocity read systems, allkeys-lfu prevents one-off batch scans from evicting genuinely popular hot keys.