Caching patterns

Redis · Practical · 12 min

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 set

Remember

  • 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.

Check yourself

  1. Why do most cache-aside implementations delete the cache key after a write instead of setting the new value?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: redis.io/docs/latest/develop/