Caching is the number one use of Redis, and naive caching causes stale data and outage-inducing stampedes.
The common pattern is cache-aside (lazy loading): the app reads from Redis; on a miss it loads from the database, writes the result to Redis with a TTL, and returns it. Writes go to the database and then delete (rather than update) the cache key, so the next read reloads fresh data. Deleting is safer than setting, because two concurrent writers updating the cache can leave it in the wrong order.
Design decisions:
- Keys encode everything the value depends on:
cache:product:42:v3:hy. - TTL bounds staleness even if an invalidation is missed. Add random jitter so thousands of keys don't expire in the same second.
- Serialization: JSON for interoperability, or hashes for partial reads.
- Negative caching: cache "not found" briefly to stop repeated misses hammering the database.
A cache stampede happens when a hot key expires and hundreds of requests miss at once, all querying the database. Defences: a short lock with SET lock:key token NX EX 5 so only one request recomputes while others wait or serve stale data; early probabilistic refresh before expiry; or keeping a stale copy with a longer TTL.
Configure maxmemory and an eviction policy (such as allkeys-lru) for pure caches, so Redis evicts old entries instead of refusing writes.
Other patterns: write-through (write cache and database together) and write-behind (write cache, flush later), which trade simplicity for freshness or durability.
Example
import json, random
import redis
r = redis.Redis(decode_responses=True)
def get_product(pid, db):
key = f"cache:product:{pid}"
if (hit := r.get(key)) is not None:
return json.loads(hit)
if not r.set(f"lock:{key}", "1", nx=True, ex=5): # someone else is loading
return db.load_product(pid) # or wait / serve stale
try:
product = db.load_product(pid)
ttl = 300 + random.randint(0, 60) # jitter
r.set(key, json.dumps(product), ex=ttl)
return product
finally:
r.delete(f"lock:{key}")
def update_product(pid, data, db):
db.save_product(pid, data)
r.delete(f"cache:product:{pid}") # invalidate, don't setRemember
- Cache-aside: read cache, on miss load the DB and SET with TTL
- On writes, update the DB then delete the cache key
- Add TTL jitter and a recompute lock to avoid stampedes
- For pure caches, set maxmemory with an allkeys eviction policy
Watch out for
- Caching without a TTL and relying only on invalidation: one missed delete serves stale data forever.