The Synchronized Expiration Storm: When TTLs Freeze the Single-Threaded Engine
Redis is celebrated for its blistering, sub-millisecond in-memory throughput, frequently handling hundreds of thousands of operations per second on a single CPU core. Yet, in high-scale production systems, engineering teams occasionally witness mysterious, catastrophic latency spikes where P99 latency surges from 0.8ms to over 600ms, triggering cascading connection timeouts in web application clusters.
In a large percentage of these incidents, the culprit is not CPU exhaustion, memory limits, or slow Lua scripts. It is the Synchronized Expiration Storm—a silent performance hazard triggered by writing millions of keys with the exact same Time-To-Live (TTL).
1. How Redis Actually Expires Keys: Passive vs. Active Eviction
Redis does not maintain a real-time, tick-by-tick timer for every individual key in memory. Maintaining billions of concurrent hardware timers would consume immense CPU overhead. Instead, Redis combines two complementary eviction algorithms:
- Passive Expiration: When a client attempts to read a key (via
GET,HGET, etc.), Redis inspects the key’s expiration metadata. Ifnow() > expires_at, the key is freed immediately, andnilis returned. - Active Expiration Cycle: Executed 10 times per second (governed by the internal
hzparameter, default 10Hz). In each cycle, Redis samples 20 random keys with configured TTLs from the expiration dictionary. If more than 25% (5 keys) of the sample are expired, Redis immediately repeats the loop, blocking the event loop until the percentage of expired keys drops below 25% or an internal 25ms execution deadline is reached.
2. The Cascading Failure Mode
Consider an enterprise API where automated background jobs cache millions of user sessions or API tokens with a static 24-hour expiration (e.g., EXPIRE key 86400) at exactly midnight. Exactly 24 hours later, millions of keys expire simultaneously:
| Timeline | Redis Internal State | Client Impact |
|---|---|---|
| T = 00:00:00 | 3,000,000 keys expire at the exact same epoch millisecond. | Normal operations for the first 100ms. |
| T = 00:00:01 | Active expiration cycle triggers. 100% of sampled keys are expired. Redis loops repeatedly, hitting maximum 25ms block boundaries. | Client command buffers begin queueing up. Latency spikes to 25-50ms. |
| T = 00:00:05 | Memory deallocation overhead (freeing memory blocks in jemalloc) monopolizes CPU. | Connection timeouts in Django / FastAPI web workers. 504 Gateway errors. |
3. Mitigation Strategy 1: Cryptographic / Random TTL Jitter
The primary architectural defense is to introduce pseudo-random jitter into every TTL calculation. Instead of setting a static 86,400 seconds, add a controlled variance (e.g. ±10%):
# Python / Django utility for TTL Jittering
import random
def get_jittered_ttl(base_ttl_seconds: int, jitter_percentage: float = 0.15) -> int:
"""
Applies symmetric random jitter to prevent synchronized Redis expiration spikes.
e.g. 86400 with 15% jitter distributes expirations evenly across 73,440 to 99,360 seconds.
"""
delta = int(base_ttl_seconds * jitter_percentage)
return base_ttl_seconds + random.randint(-delta, delta)
# Usage in caching layer:
cache.set(f"user_session:{user_id}", session_data, timeout=get_jittered_ttl(86400))
4. Mitigation Strategy 2: Asynchronous Deallocation (Lazyfree)
Freeing complex Redis data structures (large hashes, sets, or sorted sets with thousands of elements) takes significant CPU time in the main thread. To eliminate this stall, enable asynchronous memory reclaiming in redis.conf:
# redis.conf
# Deallocate expired keys in a background pthread
lazyfree-lazy-expire yes
lazyfree-lazy-eviction yes
lazyfree-lazy-server-del yes
5. Verifying Expiration Health via Telemetry
Monitor your Redis instance using INFO stats and Prometheus exporters to track the ratio between passive and active expirations:
redis-cli info stats | grep -E "expired_keys|evicted_keys"
If expired_keys exhibits steep vertical cliffs accompanied by spikes in instantaneous_ops_per_sec drops, your platform is experiencing expiration contention. Introducing TTL jitter flattens the eviction curve into a continuous, imperceptible background trickle, preserving rock-solid sub-millisecond P99 latency.