Prevent Cache Stampedes in Django
Stop cache stampedes (dogpile effects) in Django when a popular cache key expires and hundreds of requests recompute it at once: use locks, stale-while-revalidate, jittered timeouts, background refresh, and probabilistic early expiration.
Problem statement
Your home page caches an expensive query for five minutes. Every five minutes, at peak traffic, response times spike and database CPU jumps to 100 percent. When the key expires, every request in flight sees a miss at the same moment and runs the same expensive query, so the database receives hundreds of identical heavy queries at once. This is a cache stampede, also called the dogpile or thundering herd problem.
- Only let one process rebuild a key: take a short Redis lock (
cache.addor django-redislock) and let others wait or serve stale data. - Serve stale while revalidating: store the value with a soft expiry and refresh it in the background.
- Add random jitter to timeouts so keys created together do not expire together.
- Refresh critical keys proactively with Celery beat before they expire.
- Use probabilistic early expiration for keys you cannot schedule.
Step-by-step solution
1. Confirm it is a stampede
Signs: latency and database CPU spikes at regular intervals matching a cache timeout, many identical slow queries in pg_stat_statements or the slow log at the same timestamps, and Redis showing a burst of misses for one key.
2. Rebuild under a lock
import time
from django.core.cache import cache
def get_with_lock(key, compute, timeout=300, lock_timeout=30, wait=0.1, max_wait=5):
value = cache.get(key)
if value is not None:
return value
lock_key = f"lock:{key}"
if cache.add(lock_key, "1", lock_timeout): # only one caller wins
try:
value = compute()
cache.set(key, value, timeout)
return value
finally:
cache.delete(lock_key)
deadline = time.monotonic() + max_wait
while time.monotonic() < deadline: # others wait briefly
time.sleep(wait)
value = cache.get(key)
if value is not None:
return value
return compute() # fallback if the winner is slow
cache.add sets the key only if it does not exist, which works as a simple mutex across processes with Redis or Memcached. The lock timeout must exceed the computation time.
3. Serve stale while revalidating
Store the value with its own soft expiry and keep the cache entry longer:
import time
def get_swr(key, compute, fresh_for=300, stale_for=3600):
entry = cache.get(key)
now = time.time()
if entry and entry["expires"] > now:
return entry["value"]
if entry and cache.add(f"lock:{key}", "1", 60):
refresh_cache_key.delay(key) # Celery task recomputes and sets
return entry["value"] # serve stale meanwhile
if entry:
return entry["value"]
value = compute()
cache.set(key, {"value": value, "expires": now + fresh_for}, fresh_for + stale_for)
return value
Users never wait for the rebuild once the key exists; only the very first request computes synchronously.
4. Add jitter and refresh proactively
import random
cache.set(key, value, timeout=300 + random.randint(0, 60))
For a handful of critical keys (home page data, navigation, pricing tables), schedule a Celery beat task that recomputes them every few minutes, with a timeout longer than the schedule. They then never expire under traffic.
5. Probabilistic early expiration
Each reader recomputes slightly early with a probability that grows as expiry approaches (the "XFetch" approach). With delta the time the computation takes and beta around 1, recompute when now - delta * beta * log(random()) >= expiry. This spreads rebuilds without locks and works well for keys with steady traffic.
Explanation
A cache turns many reads into one computation only while the value exists. At the instant it expires, the cache provides no coordination: each process independently sees a miss and starts the same work. For cheap values that is harmless; for queries taking hundreds of milliseconds under high concurrency, it multiplies load exactly when traffic is highest. Locks coordinate rebuilds, stale serving removes waiting, and jitter or early refresh avoids synchronized expiry.
Edge cases / notes
- Lock failures. If the lock holder crashes, the lock expires after
lock_timeout; keep it short but longer than the computation. - django-redis locks.
cache.lock("name", timeout=30)provides a Redis lock object with blocking options. - Per-user keys. Stampedes mainly affect shared keys; per-user caches rarely see concurrent misses.
Next steps
- Design caching layers with Django Caching Strategy for Production.
- Cache rendered pieces with Django Template Fragment Caching.
- Prepare for peaks with the Django Traffic Spike Readiness Checklist.
FAQ
What is a cache stampede?
Many concurrent requests missing the same expired key and recomputing it at once, overloading the database.
Does Django's cache framework prevent stampedes?
No. You add coordination with locks, stale serving, or proactive refresh.
Is cache.add a reliable lock?
It is atomic in Redis and Memcached, which makes it a simple lock. For stricter needs, use Redis locks with tokens.
Should every cache key use a lock?
No. Use locks for expensive, popular keys; jitter covers many similar keys cheaply.