Design server-side caching with Redis: cache-aside, write-through, write-behind patterns, TTL selection with jitter, thundering-herd/stampede protection (lock with SET NX PX, probabilistic early refresh, request coalescing), and cache invalidation strategies. Use when adding caching to reduce database load, choosing TTLs, or fixing stampede and invalidation bugs. Covers when NOT to cache and graceful degradation when Redis is down.
./install.sh redis-caching-patternsname: "redis_caching_patterns" description: "Design server-side caching with Redis: cache-aside, write-through, write-behind patterns, TTL selection with jitter, thundering-herd/stampede protection (lock with SET NX PX, probabilistic early refresh, request coalescing), and cache invalidation strategies. Use when adding caching to reduce database load, choosing TTLs, or fixing stampede and invalidation bugs. Covers when NOT to cache and graceful degradation when Redis is down." metadata: { "includeInPrompt": true }
> Differs from performance_budgets because this is server-side data caching strategy (read/write patterns, TTLs, stampedes, invalidation) — not frontend page-load budgets; and from webhook_handling because it caches data you already own, rather than plumbing inbound HTTP callbacks with retry/idempotency.
Give agents a concrete, safe procedure for adding Redis caching to a backend: which pattern to use, how to pick TTLs, how to survive cache stampedes, how to invalidate, and when to skip caching entirely. Every Redis command here is real. Logic was executed against fakeredis 2.38.0 (SET NX PX, SET with EX, DEL, GET, TTL round trips all verified); the safe-release Lua script is the standard Redlock pattern (fakeredis lacks EVALSHA, so release logic was verified client-side).
Cache only if ALL of these hold:
When any one fails: don't cache. Cache adds a second source of truth — a bug factory you don't need.
| Pattern | Flow | Wins when | Consistency | |---|---|---|---| | Cache-aside (lazy) — default | Read: check cache, on miss load DB and populate. Write: update DB, delete key. | Reads dominate, data tolerant of brief staleness. | Eventual; race window between DB write and DEL is tiny but real. | | Read-through | App reads only from cache; cache library loads DB on miss. | Many app instances; you want miss-loading centralized. | Same as cache-aside. | | Write-through | Write goes to cache AND DB synchronously. | Cache must never serve stale on write; writes are cheap. | Stronger on write path; adds write latency. | | Write-behind (write-back) | Write goes to cache only; async worker flushes to DB later. | Extremely write-heavy, loss-tolerant data (counters, analytics). | Risky: data loss if cache dies before flush. Use only when loss is acceptable. |
Cache-aside pseudocode (the default — use this unless you have a reason not to):
def get_user(user_id):
key = f"user:{user_id}"
hit = redis.get(key)
if hit is not None:
return deserialize(hit)
user = db.query("SELECT ... WHERE id = ?", user_id) # miss
redis.set(key, serialize(user), ex=ttl_with_jitter(600)) # SET key val EX 600
return user
def update_user(user_id, fields):
db.execute("UPDATE ... WHERE id = ?", user_id, fields) # DB first
redis.delete(f"user:{user_id}") # then invalidate
Note: invalidate by DELETE on write, never by SET on write — a concurrent stale read can overwrite the fresh write between the DB update and your SET (the classic write-race). Deleting lets the next read re-populate from the DB.
Write-through pseudocode:
def update_user_wt(user_id, fields):
db.execute("UPDATE ...", user_id, fields)
redis.set(f"user:{user_id}", serialize(fields), ex=BASE_TTL)
# Both succeed or both roll back in one transaction unit; read path stays plain GET.
user:profile = 10 min, product:catalog = 1 h, config:flags = 60 s.def ttl_with_jitter(base, frac=0.2):
return base + random.uniform(-base * frac, base * frac)
Verified output (base=600): [558, 516, 636, 497, 609] — expiries spread over ~±2 min.
SET key value EX <jittered> (single command; verified: TTL returns 60 after SET x 1 EX 60). Prefer SET ... EX over legacy SETEX in new code — same effect, one code path.When a hot key expires, N concurrent requests all miss and hit the DB at once. Layer defenses:
a) Request coalescing (singleflight concept): in-process, only the first concurrent caller for a key does the DB load; the rest await its result. Implement per-process (a dict[key, Future] guard); across processes use (b).
b) Rebuild lock with SET NX PX (distributed, works across processes):
# Acquire: succeeds for exactly one worker; PX auto-expires the lock on crash.
acquired = redis.set(f"lock:{key}", token, nx=True, px=5000) # SET lock KEY NX PX 5000
Verified: worker-1 gets True, worker-2 gets None while the lock is held.
# Safe release: delete ONLY if the lock still holds OUR token (else we'd
# delete a lock a crashed-and-reacquired worker now owns). In real Redis
# this is atomic Lua; fakeredis lacks EVALSHA so logic was verified client-side.
-- EVAL this in real Redis:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
Losers don't hit the DB: they wait ~50–100 ms and retry the GET, or serve slightly-stale while the winner rebuilds.
c) Probabilistic early refresh: refresh a hot key before it expires, with probability rising as TTL shrinks — spreads rebuilds over time instead of at the expiry instant:
def should_early_refresh(ttl_remaining, base_ttl, beta=1.0):
return ttl_remaining - random.random() * beta * base_ttl <= 0
A request that draws "refresh" rebuilds the key in the background (or holds the lock) while other requests keep serving the still-valid cached value. Beta ~1.0 is the standard starting point; raise it for hotter keys.
price:v3. To "invalidate everything," bump the version (price:v4); old keys expire naturally. Best when a whole class of keys must flip at once (deploy, config rollout).tag:product:42 -> {keys...}) and delete all members on invalidation. Powerful, but the tag index is its own consistency problem — reach for it only when explicit delete can't express the invalidation ("all keys touched by product 42").entity:id[:field] — lowercase, colon-separated, no spaces: user:42, user:42:profile, cfg:price:v3. Prefix by domain; include the version segment when using versioned keys. Keep keys short and deterministic — they're memory too.'{"name":"anthony"}' stored and retrieved intact). Use MessagePack/protobuf only when profiling shows JSON is the bottleneck. Never store language-specific pickles in a shared cache.The cache is an optimization, not a dependency:
try:
hit = redis.get(key)
except RedisError:
hit = None # degrade to DB; NEVER fail the request because the cache is down
Log and alert on cache errors (a dead cache silently becomes a DB-DDoS), add a circuit breaker if failures persist, but the request path must always fall through to the source of truth.
When asked to add caching, deliver:
ttl_with_jitter.GET, SET (with EX/PX/NX options), SETEX, DEL, EXPIRE, TTL, EVAL (Lua). If a pattern needs a command you can't confirm, label it "pattern sketch — verify against your Redis version."publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e
No ratings yet — be the first to rate it.