For a high-traffic application the best Redis caching strategy is cache-aside with jittered TTLs, stampede protection on expensive keys, explicit invalidation after writes, and a client that treats Redis as optional: short timeouts, a circuit breaker, and a database that can survive a cold cache at a controlled rate. The pattern does not change with scale. What changes is that every rare event (a hot key expiring, lookups for rows that do not exist, a slow Redis node) now happens many times a second and lands on the database all at once.
It builds on Redis caching: how it works and how to set it up properly and the consistency trade-offs in Redis cache patterns compared. Examples use redis-py with a client r created with decode_responses=True, and loader is the function that queries the database.
Cache stampede
A stampede (thundering herd, dogpile) happens when a popular key expires or is invalidated. Every request in flight misses, and each one runs the same expensive query. If the query takes 200 ms and the key is read 2,000 times a second, about 400 identical queries start before the first finishes. Three fixes follow.
Per-key lock
Let one caller rebuild while the rest wait briefly. The lock is SET key token NX PX ms: NX makes the set succeed only if the lock does not exist, and PX makes it expire if the holder crashes. Release must delete the lock only if it still holds your token, otherwise a caller whose lock timed out deletes a lock that now belongs to someone else. That check-and-delete has to be atomic, so it is a Lua script:
import json, random, time, uuid
RELEASE = r.register_script("""
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
""")
def get_or_rebuild(key, loader, ttl=300, lock_ms=5000, wait_s=1.0):
deadline = time.monotonic() + wait_s
while True:
raw = r.get(key)
if raw is not None:
return json.loads(raw)
token = uuid.uuid4().hex
if r.set(f"lock:{key}", token, nx=True, px=lock_ms):
try:
value = loader()
r.set(key, json.dumps(value), ex=ttl + random.randint(0, ttl // 10))
return value
finally:
RELEASE(keys=[f"lock:{key}"], args=[token])
if time.monotonic() >= deadline:
return loader() # stop waiting; do not fail the request
time.sleep(0.02 + random.random() * 0.03)
Set lock_ms above the slowest rebuild you expect. If the lock expires mid-rebuild, a second caller starts one, which costs a duplicate query and nothing worse: this lock limits work and does not protect correctness. Waiters hold a request thread while they poll, so cap the wait. Coalesce inside each process too (one in-flight future per key), so the Redis lock is contended per process, not per request.
Probabilistic early expiration
Locks act after the key is gone. The alternative is to refresh before it expires, each reader deciding independently and at random, more eagerly as expiry approaches. This is the XFetch algorithm from Vattani, Chierichetti and Lowenstein. Store the rebuild time delta and the logical expiry with the value. A reader recomputes when now + delta * beta * -ln(rand) passes the expiry:
import math
def xfetch(key, loader, ttl=300, beta=1.0):
raw = r.get(key)
if raw is not None:
entry = json.loads(raw)
# -log(1 - random()) is a positive exponential sample
early = entry["delta"] * beta * -math.log(1.0 - random.random())
if time.time() + early < entry["expiry"]:
return entry["value"]
start = time.time()
value = loader()
delta = time.time() - start # how long a rebuild takes
entry = {"value": value, "delta": delta, "expiry": time.time() + ttl}
r.set(key, json.dumps(entry), ex=ttl)
return value
Slow rebuilds start earlier because delta scales the window, and beta above 1 refreshes sooner. No coordination is involved, and a frequently read key is almost always refreshed before it expires. It does nothing for a key that has just been deleted, so pair it with a lock when you invalidate explicitly.
Stale-while-revalidate
Keep two lifetimes: a soft TTL after which the value should be refreshed, and a longer hard TTL (the real Redis expiry) until which it may still be served. After the soft TTL, one caller refreshes and everyone else gets the old value immediately:
def get_swr(key, loader, soft_ttl=60, hard_ttl=600):
raw = r.get(key)
if raw is not None:
entry = json.loads(raw)
if time.time() < entry["fresh_until"]:
return entry["value"] # fresh
if not r.set(f"refresh:{key}", "1", nx=True, px=10_000):
return entry["value"] # stale; someone is refreshing
value = loader() # we refresh, or it is a hard miss
entry = {"value": value, "fresh_until": time.time() + soft_ttl}
r.set(key, json.dumps(entry), ex=hard_ttl)
return value
The refresh: marker is never deleted. It expires on its own, which also limits refresh attempts to one per 10 seconds if the loader keeps failing.
No request waits on another, which suits hot read paths that tolerate slightly old data. In production, refresh on a background worker so the winning caller also returns the stale value. A hard miss still stampedes here, so route that branch through get_or_rebuild. To invalidate, rewrite the entry with fresh_until in the past: readers keep getting the old value while one of them rebuilds.
Cache avalanche and TTL jitter
An avalanche is a stampede across many keys: they expire in the same second because they were written in the same second, typically after a deploy, a warmup job or a restart. Spread expiries with jitter proportional to the TTL, for example int(ttl * random.uniform(0.9, 1.1)). A fixed few seconds of jitter on a one-hour TTL does nothing. Ten percent spreads those expiries over twelve minutes. The other avalanche trigger is losing the cache itself, covered under failing open and warmup.
Hot keys and big keys
A hot key is one that takes a large share of all requests: a home page fragment, a global configuration blob, a celebrity profile. Redis Cluster does not help, because one key lives on one shard. A big key has a large value or many elements. Reading it copies megabytes per request, and deleting or expiring it can stall the main thread.
Find them:
redis-cli --hotkeys # needs maxmemory-policy allkeys-lfu or volatile-lfu
redis-cli OBJECT FREQ some:key # the LFU counter for one key, same requirement
redis-cli --bigkeys # or --memkeys for size in bytes
redis-cli SLOWLOG GET 20
Avoid MONITOR on a busy production node, since it streams every command.
For big keys: split the value by the unit you read (one key per page, not one for the whole list), compress it, and delete with UNLINK.
Fixes for hot keys:
-
A local L1 cache in front of Redis. Keep the hottest values in process memory for one to a few seconds. A two-second local TTL on a key read 5,000 times a second per process turns 10,000 Redis calls into one. The cost is staleness up to the local TTL on every instance, so use it for a short, bounded list of keys.
-
Client-side caching with server invalidation. Since Redis 6,
CLIENT TRACKINGmakes the server remember which keys a connection has read and push an invalidation message when one changes. The client keeps values locally and drops them when told to, so it does not depend on a TTL guess. With RESP3 the invalidations arrive on the same connection. redis-py supports it from 5.1:import redis from redis.cache import CacheConfig r = redis.Redis(host="localhost", port=6379, protocol=3, cache_config=CacheConfig(max_size=10_000), decode_responses=True) r.get("config:flags") # network round trip; result kept locally r.get("config:flags") # served from process memoryThe server tracks keys in an invalidation table capped by
tracking-table-max-keys(default 1,000,000). When it is full, the server drops entries and sends invalidations for keys that did not change. Broadcast mode (BCASTwith key prefixes) uses no server memory per key and sends more messages. If the connection drops, the client must flush its local cache, because it may have missed invalidations. redis-py's built-in cache requires a server reporting Redis 7.4 or later. Valkey implementsCLIENT TRACKINGtoo, but check that your client library enables its cache against it. -
Replicas for reads, if slightly stale reads are acceptable.
Cache penetration and negative caching
Penetration is a stream of lookups for rows that do not exist: deleted products, sequential ID scans, a bug sending id=undefined. Nothing is ever cached, so every request reaches the database.
- Negative caching. When the database returns no row, cache that fact:
SET key <sentinel> EX 30, and treat the sentinel as "not found" on later reads. Keep the TTL short and delete the sentinel in the create path, so a new row appears quickly. An attacker who varies the ID can still fill memory with sentinels, which the short TTL and the eviction policy bound. - Validate before lookup. Reject malformed or out-of-range IDs without touching the cache or the database.
- Bloom filter. Keep a filter of all existing IDs and consult it first. A Bloom filter has no false negatives, so "not present" is definitive. Its false positives, at a configurable rate, simply fall through to the normal path. Redis 8 includes Bloom filters (
BF.RESERVE,BF.ADD,BF.EXISTS). Earlier versions need the RedisBloom module, and Valkey has a separate bloom module. You must add every new ID to the filter on creation. Items cannot be removed from a standard Bloom filter, so deleted IDs keep passing it until you rebuild.
Pipelining and MGET
Round trips cost more than Redis execution. A page that needs 50 cached objects should not make 50 sequential calls. MGET fetches many string keys in one command, and a pipeline sends any mix of commands in one round trip:
def get_products(ids, load_many):
keys = [f"shop:product:v1:{i}" for i in ids]
found = dict(zip(ids, r.mget(keys)))
result = {i: json.loads(raw) for i, raw in found.items() if raw is not None}
missing = [i for i in ids if i not in result]
if missing:
rows = load_many(missing) # one SQL query: WHERE id IN (...)
pipe = r.pipeline(transaction=False)
for i, row in rows.items():
pipe.set(f"shop:product:v1:{i}", json.dumps(row),
ex=300 + random.randint(0, 30))
pipe.execute() # one round trip for all SETs
result.update(rows)
return result
Keep batches to hundreds of keys, not tens of thousands: one huge MGET holds the main thread while other clients wait. In Redis Cluster, a multi-key command fails unless all keys are in the same hash slot. Split the batch per slot in the client (redis-py has mget_nonatomic) or use hash tags, discussed below.
Connection pools and timeouts
Every application process holds a pool, and the pools multiply: 200 processes with 50 connections each is 10,000 connections, the default maxclients. Size the pool to the process's real concurrency.
import redis
from redis.backoff import ExponentialBackoff
from redis.exceptions import ConnectionError, TimeoutError
from redis.retry import Retry
pool = redis.BlockingConnectionPool(
host="localhost", port=6379,
max_connections=50,
timeout=0.05, # wait for a free connection, then raise
socket_connect_timeout=0.1,
socket_timeout=0.1, # per command
health_check_interval=30,
retry=Retry(ExponentialBackoff(cap=0.05, base=0.01), 1),
retry_on_error=[ConnectionError, TimeoutError],
decode_responses=True,
)
r = redis.Redis(connection_pool=pool)
Set every timeout explicitly: connect, per command, and waiting for a pooled connection. A cache call with no timeout turns a slow Redis into a full outage, because request threads pile up waiting. Allow at most one fast retry, since retries multiply load on a node that is already struggling.
Failing open with a circuit breaker
When Redis is slow, even a 100 ms timeout adds up across a request that makes several calls. A circuit breaker stops calling Redis after repeated failures and goes straight to the fallback for a cooldown period:
class Breaker:
def __init__(self, threshold=5, cooldown=5.0):
self.threshold, self.cooldown = threshold, cooldown
self.failures, self.open_until = 0, 0.0
def call(self, fn, fallback=None):
if time.monotonic() < self.open_until:
return fallback # open: skip Redis entirely
try:
result = fn()
except (redis.ConnectionError, redis.TimeoutError):
self.failures += 1
if self.failures >= self.threshold:
self.open_until = time.monotonic() + self.cooldown
self.failures = 0
return fallback
self.failures = 0
return result
cache = Breaker()
raw = cache.call(lambda: r.get("shop:product:v1:42")) # None means "treat as a miss"
Failing open only helps if the database can take the traffic, and at high scale it usually cannot take all of it. So bound the fallback too: limit concurrent database loads (a semaphore per query type), and give requests over the limit a degraded response or a fast error. Shedding part of the load is better than losing the database. Invalidations issued during the outage were lost, so entries that survive it may be stale until their TTL.
Cold-start warmup
A cache becomes empty through a restart without persistence, a failover to an empty node, a flush, or a deploy that bumps every key version at once. Plan for it:
- Preload the known-hot keys (from access logs or a top-entities list) with jittered TTLs before taking traffic.
- Change key versions per entity type, not globally, so one deploy cools only part of the cache.
- Keep the database concurrency limit from the previous section. It makes a cold start slow instead of fatal.
- If a cold cache is not survivable, run a replica so a failover promotes a warm node, and keep RDB snapshots for restarts.
Scaling out
Replicas for reads. Replication is asynchronous, so a replica can return a value the primary has already overwritten or deleted. That adds replication lag to your staleness, and it breaks read-your-writes: a client can invalidate a key on the primary and read the old value back from a replica. Send lock operations and anything that needs the latest value to the primary.
Redis Cluster. Cluster spreads keys over 16384 hash slots across primaries. Multi-key commands, transactions and Lua scripts must touch keys in a single slot. A hash tag forces keys into one slot by hashing only the part in braces: {user:42}:profile and {user:42}:orders always live together. Use tags only for keys you must read or change together. A tag shared by a large fraction of keys, such as {tenant1}, puts them all on one shard and recreates the hot-node problem.
Memory headroom. Size for the working set you want cached, then leave room between maxmemory and the instance's RAM for allocator fragmentation, replication buffers, and copy-on-write during a fork if persistence or replica synchronization is in use. Running at maxmemory is normal for a cache under allkeys-lru. Running out of RAM is not, and How do I fix Redis "OOM command not allowed when used memory > maxmemory"? covers the misconfiguration behind the usual error.
What to alert on
| Signal | Source | Why it matters |
|---|---|---|
| Hit ratio drops | Rate of keyspace_hits and keyspace_misses | The database takes the difference |
| Client latency, timeouts, breaker state | Application metrics | The only view that includes network and pool waits |
| Eviction rate jumps | Rate of evicted_keys | The working set no longer fits |
| Connections | connected_clients, rejected_connections | Pool leaks, approaching maxclients |
| Slow commands | SLOWLOG LEN growth | Big keys or blocking commands |
| CPU of the Redis process | Host metrics | Commands run on one thread, so one saturated core is the ceiling |
| Replication | master_link_status, offset lag | Stale replica reads, failover readiness |
Common mistakes
- A lock released with plain
DEL, which removes another caller's lock after a timeout. - A lock with no expiry. A crashed holder blocks the key indefinitely.
- Not caching "not found", leaving a free path to the database.
- Missing client timeouts, and retries without a limit.
- Failing open with no limit on database concurrency.
- One global key version bumped on every deploy, which empties the cache.
Checklist
- Use cache-aside with a jittered TTL on every key and delete-after-commit invalidation.
- Protect expensive keys from stampedes: a lock with safe release, probabilistic early refresh, or stale-while-revalidate.
- Find hot and big keys, split or compress big ones, and put an L1 or client-side cache in front of the hottest.
- Cache negative results briefly, validate IDs, and add a Bloom filter if lookups for nonexistent rows are common.
- Batch with
MGETand pipelines, set connect, command and pool timeouts explicitly, and limit retries. - Wrap cache calls in a circuit breaker, and cap concurrent database loads behind it.
- Rehearse a cold start, treat replica reads as stale, and design hash tags before moving to Cluster.
- Alert on hit ratio, client latency, evictions, connections, slow commands and CPU.
References
- SET (
NX,PX) and Distributed locks with Redis, including the safe release script - Optimal Probabilistic Cache Stampede Prevention (Vattani, Chierichetti, Lowenstein), the XFetch paper
- Client-side caching reference, client library support and CLIENT TRACKING
- Bloom filter (Redis) and Bloom filters (Valkey)
- Pipelining and MGET
- Redis replication, Scale with Redis Cluster and the Cluster specification (hash slots and hash tags)
- INFO and Diagnosing latency issues
- redis-py connection pools and retry helpers
Get the weekly commit
New database deep dives every week.
