Cache-aside is the pattern almost everyone ends up using, usually without naming it. The application asks the cache; on a miss it asks the database, writes the result back, and returns it.
def get_dashboard(user_id):
key = f"dash:v3:{user_id}"
data = cache.get(key)
if data is None: # miss
data = expensive_query(user_id)
cache.set(key, data, timeout=300)
return data
Four lines, and three separate ways to get it wrong.
Mistake one: falsy values are not misses
The check above is correct. This one, which is what people usually write, is not:
data = cache.get(key)
if not data: # WRONG
...
A cached 0, empty list or empty string is falsy. If your dashboard legitimately
returns "no pending items", every single request is a cache miss — and the cache
is now pure overhead on your hottest path. Always compare against None, or use
a sentinel.
Mistake two: keys without a version
The moment you change the shape of what you cache, every cached entry becomes a
landmine. Old rows have the old structure; new code expects the new one; you get
KeyError in production for exactly as long as the TTL.
Put a version in the key and bump it whenever the structure changes:
DASH_VERSION = 3 # bump on any shape change
key = f"dash:v{DASH_VERSION}:{user_id}"
Old entries are now unreachable and expire on their own. No flush, no downtime, no coordinating a deploy with a cache clear. This costs nothing and saves an incident.
Mistake three: the stampede
A popular key expires. Two hundred concurrent requests all miss, all run the expensive query, and all write the same value back. Your database gets two hundred copies of a query it was being protected from — precisely when traffic is highest.
The fix is a short lock so only one caller recomputes:
def get_with_lock(key, producer, timeout=300, lock_ttl=30):
data = cache.get(key)
if data is not None:
return data
# add() is SET NX: atomic, only one caller wins
if cache.add(f"lock:{key}", "1", timeout=lock_ttl):
try:
data = producer()
cache.set(key, data, timeout=timeout)
return data
finally:
cache.delete(f"lock:{key}")
# someone else is computing it. Wait briefly and re-read
# rather than piling onto the database.
for _ in range(10):
time.sleep(0.1)
data = cache.get(key)
if data is not None:
return data
# the holder died or is slow: compute rather than fail
return producer()
The final fallback matters. A lock holder that crashes must not turn a cache miss into an outage — degrading to "everyone computes" is bad, and failing the request is worse.
The best invalidation is no invalidation
Explicit invalidation — deleting keys when data changes — sounds clean and rots quickly. Every new write path is a new place someone must remember to invalidate, and the bug from forgetting is stale data that nobody notices for weeks.
Prefer keys that make themselves stale. Include the thing that changes in the key itself:
# The key changes when the row changes, so the old entry is
# simply never asked for again. Nothing to invalidate.
key = f"invoice:v2:{invoice.id}:{invoice.updated_at.timestamp()}"
For a collection, use an aggregate — a max updated_at or a count — as part of
the key. You trade a cheap indexed query for never having to reason about
invalidation again, which is a trade worth making almost every time.
When you genuinely must invalidate, do it in post_save, and only for keys you
can name exactly. Wildcard deletion by pattern means KEYS or SCAN across the
keyspace, which is slow and, in the case of KEYS, blocks the entire Redis
instance.
Choose TTLs by how wrong you can afford to be
Not by how often the data changes — by how much staleness costs.
| Data | TTL | Why |
|---|---|---|
| Nav menus, site settings | hours | Changes rarely, staleness harmless |
| Dashboard aggregates | 1–5 min | Expensive, approximate is fine |
| Search result pages | 30–60 s | Absorbs bursts on popular terms |
| Stock levels, balances | don't cache | Being wrong here costs real money |
Add jitter to TTLs on keys written together. A thousand entries created in the same request all expiring in the same second recreates the stampede on a schedule:
cache.set(key, data, timeout=300 + random.randint(0, 60))
Configure the Redis side too
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
"OPTIONS": {"socket_timeout": 2, "socket_connect_timeout": 2},
}
}
Those timeouts are not optional. Without them, a wedged Redis makes every request hang on a socket read instead of falling through to the database — the cache becomes a hard dependency of a system that was supposed to work without it.
Use a separate Redis database number (or instance) from your Celery broker, and set an eviction policy so a full cache evicts rather than erroring:
maxmemory 512mb
maxmemory-policy allkeys-lru
Never point Celery at a database with allkeys-lru. Evicting a queued task under
memory pressure is exactly the silent job loss you set up a queue to avoid.
Measure the hit rate or do not bother
A cache you cannot measure is a cache you cannot reason about. redis-cli INFO
stats gives you keyspace_hits and keyspace_misses; the ratio is the number
that tells you whether any of this is working.
A hit rate below roughly 80% usually means the TTL is too short, the key is too specific, or — most often — you are caching something that is not actually read repeatedly. That last one is common, and the right response is to delete the caching code rather than tune it.