Cache stampede возникает, когда популярный ключ истёк, и сотни запросов одновременно пересчитывают одно значение: SQL-агрегацию, внешний API или ML inference. Частая ошибка - считать, что обычный TTL сам по себе защищает production.
Singleflight: один refresh на ключ
Для одного
cache_key в момент времени должен работать один пересчёт, остальные ждут или получают stale.Внутри процесса подойдёт
asyncio.Lock на ключ, но это не защита для нескольких uvicorn/gunicorn workers или pod’ов. Там нужен Redis SET NX PX, lease-lock, PostgreSQL advisory lock или singleflight поверх общего хранилища.lock = locks.setdefault(key, asyncio.Lock())
async with lock:
item = await cache.get(key)
if item and item["expires_at"] > time.time():
return item["value"]
value = await fetch()
await store(cache, key, value)
Jitter: не синхронизируйте истечение
Если после деплоя прогреть 50k ключей с
ttl=60, через минуту они начнут истекать пачкой.Практичнее так:
ttl = 60
ttl = ttl + random.uniform(0, ttl * 0.15)
Особенно важно для агрегатов, feature flags, кэша внешних API и scheduled prewarm jobs.
Stale-while-revalidate: старое лучше лавины
Формат записи:
{
"value": value,
"expires_at": expires_at,
"stale_until": expires_at + 300,
}Логика простая:
- свежий TTL жив - отдаём кэш;
- TTL истёк, но
stale_until жив - отдаём stale и обновляем в фоне;- stale-окно истекло - ждём refresh или возвращаем controlled error.
Production-нюансы
- lock обязан иметь TTL, иначе упавший воркер заблокирует refresh;
- refresh должен иметь timeout, retry budget и circuit breaker;
- stale нельзя бездумно включать для балансов, прав доступа и лимитов;
- метрики обязательны:
cache_hit, stale_hit, lock_wait_seconds, refresh_errors.Вывод:
Защита от cache stampede - это не один lock, а согласованный дизайн TTL, singleflight, jitter, stale-окон и отказоустойчивого refresh.