Прямой ответ
Cache stampede (или dog-piling) — ситуация, когда срок жизни популярного ключа истекает, и сотни одновременных запросов одновременно идут в источник за одними и теми же данными. Кэш не снижает нагрузку на базу, а создаёт её пиками.
Механика: ключ product:1234 читали 500 раз в секунду. Он истёк. До момента, пока первый запрос заполнит кэш, все 500 обращений идут в базу и выполняют один и тот же тяжёлый запрос. Если запрос занимает 200 миллисекунд, база получает 100 одинаковых запросов за это время.
Сценарий усиливается, если срок жизни многих ключей одинаков: они истекают одновременно, например после деплоя или прогрева кэша.
Четыре рабочих приёма защиты:
| Приём | Суть | Когда применять |
|---|---|---|
| Джиттер TTL | Срок жизни случайным образом варьируется | Всегда, как базовая мера |
| Single-flight | Только один запрос идёт в источник, остальные ждут | Тяжёлые запросы, средняя нагрузка |
| Вероятностное обновление | Ключ обновляется до истечения с растущей вероятностью | Популярные ключи с дорогим вычислением |
| Stale-while-revalidate | Отдаётся устаревшее значение, обновление в фоне | Допустима кратковременная устаревшие данные |
Джиттер TTL — самая простая мера:
import random
def ttl_with_jitter(base: int, spread: float = 0.2) -> int:
return int(base * (1 + random.uniform(-spread, spread)))
cache.set(key, value, ex=ttl_with_jitter(300))
Разброс в 20 % распределяет истечения по минуте вместо одной секунды.
Single-flight через блокировку в Redis:
async def get_or_compute(key: str, compute, ttl: int = 300):
if (cached := await redis.get(key)) is not None:
return json.loads(cached)
lock_key = f"lock:{key}"
if await redis.set(lock_key, "1", nx=True, ex=10): # захватили
try:
value = await compute()
await redis.set(key, json.dumps(value), ex=ttl_with_jitter(ttl))
return value
finally:
await redis.delete(lock_key)
for _ in range(50): # остальные ждут
await asyncio.sleep(0.05)
if (cached := await redis.get(key)) is not None:
return json.loads(cached)
return await compute() # запасной путь
Флаг nx=True гарантирует, что блокировку получит только один клиент. О механизме распределённых блокировок — в статье про distributed lock.
Практика: выбор приёма и его границы
1. Джиттер обязателен всегда. Он стоит одну строку кода и снимает самый частый сценарий — одновременное истечение.
2. Single-flight добавляет задержку ожидающим. Остальные запросы ждут заполнения кэша, поэтому при тяжёлом вычислении задержка вырастает у всех. Приём оправдан, когда вычисление дороже ожидания.
3. Ограничивайте время ожидания блокировки. Если держатель блокировки упал, ожидающие не должны ждать вечно. Срок жизни блокировки и ограниченное число попыток обязательны.
4. Вероятностное обновление убирает ожидание. Ключ обновляется до истечения тем чаще, чем ближе срок; алгоритм учитывает время вычисления и оставшийся срок. Ни один запрос не блокируется.
5. Stale-while-revalidate отдает устаревшее значение. Директива определена в RFC 5861: кэш отдаёт просроченное значение, пока обновление выполняется в фоне. Требуется хранить два срока — основной и льготный.
6. Не кэшируйте ошибки без срока. Кэширование пустого результата надолго скрывает появление данных; короткий срок допустим для защиты от запросов несуществующих ключей.
7. Прогревайте кэш после деплоя. Массовое истечение после перезапуска — типичная причина stampede. Прогрев популярных ключей снимает пик.
Рекомендации:
- Измеряйте промахи. Метрика cache miss ratio по ключам показывает, где возникает пик; о стратегиях кэширования — в статье про distributed caching.
- Защищайте источник, а не только кэш. Ограничение параллелизма запросов к базе работает даже при полном промахе; о сбросе нагрузки — в статье про load shedding.
- Не делайте TTL одинаковым для группы ключей. Массовое создание записей с одним сроком даёт синхронное истечение.
- Тестируйте истечение под нагрузкой. Сценарий «кэш пуст при высоком трафике» должен быть в нагрузочных тестах; о приёмах — в статье про структуры Redis.
- Учитывайте горячие ключи. Один популярный ключ нагружает один узел кластера Redis независимо от общего числа узлов.
Практический сценарий: раз в пять минут база уходила в отказ
Контекст: каждые пять минут задержка базы резко росла, и часть запросов завершалась таймаутом. Между пиками система работала нормально.
- Диагностика. В логах базы обнаружился всплеск одинаковых запросов к одной таблице — до 300 в секунду в течение 200 миллисекунд.
- Проверка кэша. Все ключи каталога создавались с фиксированным TTL 300 секунд во время прогрева после деплоя. Они истекали одновременно.
- Причина. Классический stampede: в момент истечения все запросы шли в базу, выполняя один и тот же тяжёлый запрос с несколькими соединениями.
- Действия. (а) Добавили джиттер TTL ±20 %; (б) для двадцати самых тяжёлых ключей ввели single-flight с блокировкой на 10 секунд; (в) ограничили параллелизм тяжёлых запросов к базе семафором; (г) для главной страницы включили отдачу устаревшего значения с фоновым обновлением; (д) добавили метрику числа промахов по ключу и алерт при аномальном росте.
- Результат. Пики исчезли: истечения распределились по минуте, а одновременные промахи по одному ключу обрабатываются одним запросом к базе.
Вывод: фиксированный TTL превращает кэш в генератор пиковых нагрузок. Джиттер и single-flight решают проблему за несколько строк кода, но требуют проверки под нагрузкой.
FAQ
Чем cache stampede отличается от thundering herd? Термины часто используют как синонимы. Строго, stampede относится к массовому промаху по одному ключу кэша, а thundering herd — к ситуации, когда многие процессы просыпаются по одному событию и только один получает работу.
Помогает ли увеличение TTL? Частично: пики становятся реже, но выше, поскольку нагрузка накапливается дольше. Без джиттера проблема сохраняется.
Что делать, если вычисление занимает минуту? Обновлять ключ фоновой задачей по расписанию, а не по запросу. Кэш в этом случае всегда содержит значение, а источник нагружается предсказуемо.
Нужна ли блокировка для редких ключей? Нет. Для ключа, который читают раз в минуту, блокировка добавляет сложность без пользы. Защита нужна там, где высокая частота чтения сочетается с дорогим вычислением.
Источники: RFC 5861 (stale-while-revalidate), Redis docs (locks), Wikipedia — ссылки в шапке.