Patterns — Caching
Contents
- Cache-aside with fallback
- TTL jitter
- Single-flight rebuild
- Versioned keys
- Purge-on-write
- Negative caching
- Metrics
- Gotchas
Cache-aside with fallback
python
import json, random
CACHE_TIMEOUT_S = 0.05 # cache slower than 50 ms is worse than origin
TTL_S = 300 # staleness tolerance for this data class
def get_product(pid: int):
key = f"product:v2:{pid}"
try:
raw = redis.get(key) # timeout=CACHE_TIMEOUT_S on the client
if raw is not None:
return json.loads(raw)
except RedisError:
pass # cache down → serve from origin
value = db.fetch_product(pid)
try:
redis.setex(key, jittered(TTL_S), json.dumps(value))
except RedisError:
pass # failing to cache is not an error
return valueTTL jitter
python
def jittered(ttl: int) -> int:
return int(ttl * random.uniform(0.9, 1.1)) # ±10% desynchronizes expirySingle-flight rebuild
python
LOCK_TTL_S = 10 # > rebuild p99 so a crashed builder's lock self-clears
def get_report(rid: str):
key = f"report:v1:{rid}"
raw = redis.get(key)
if raw is not None:
return json.loads(raw)
if redis.set(f"lock:{key}", "1", nx=True, ex=LOCK_TTL_S):
value = build_report(rid) # only this process rebuilds
redis.setex(key, jittered(600), json.dumps(value))
redis.delete(f"lock:{key}")
return value
time.sleep(0.1) # others: brief wait then retry once
raw = redis.get(key)
return json.loads(raw) if raw else build_report(rid) # last resort: originVersioned keys
python
# Invalidate a whole family by bumping the version constant in code —
# no scan-and-delete, old entries simply age out via TTL.
PRODUCT_CACHE_V = 3
key = f"product:v{PRODUCT_CACHE_V}:{pid}:{locale}"Purge-on-write
python
def update_product(pid: int, fields: dict):
db.update_product(pid, fields)
try:
redis.delete(f"product:v2:{pid}") # delete, don't rewrite: the next
except RedisError: # read repopulates from fresh origin
log.warning("purge failed for %s — TTL is the backstop", pid)Delete (not set) after write: writing the new value here races concurrent readers repopulating from a stale read replica.
Negative caching
python
NEG_TTL_S = 30 # short: absorbs miss storms without delaying creates for long
if value is None:
redis.setex(key, NEG_TTL_S, "__miss__")
# On create: redis.delete(key) so the sentinel dies immediately.Metrics
python
metrics.incr(f"cache.{family}.{'hit' if raw is not None else 'miss'}")Alert when hit rate for a family drops below its target (~80%) — usually a key-schema change or an invalidation bug, not traffic.
Gotchas
- Caching the serialized response of another cache-user stacks TTLs; staleness = sum of layers, not max.
KEYS pattern*for invalidation blocks Redis; that need is the signal to switch to versioned keys.- Thundering read-repair after purge-on-write: hot keys need single-flight even with purge-on-write.
- Objects that serialize differently across app versions poison shared caches during deploys — version the key (rule 5) on format changes.
- Read replicas + purge-on-write: purge, then repopulate-on-read may read a stale replica and resurrect old data; short TTL bounds the damage.
- In-process caches in autoscaled fleets are N independent staleness bubbles; keep them ≤5 s or accept per-instance divergence.