Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Cache stampede и thundering herd: защита кэша

Cache stampede: почему кэш не спасает и как чинить

Cache stampede: массовый промах по одному ключу, джиттер TTL, single-flight, вероятностное обновление и stale-while-revalidate.

Прямой ответ

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. Прогрев популярных ключей снимает пик.

Рекомендации:

  1. Измеряйте промахи. Метрика cache miss ratio по ключам показывает, где возникает пик; о стратегиях кэширования — в статье про distributed caching.
  2. Защищайте источник, а не только кэш. Ограничение параллелизма запросов к базе работает даже при полном промахе; о сбросе нагрузки — в статье про load shedding.
  3. Не делайте TTL одинаковым для группы ключей. Массовое создание записей с одним сроком даёт синхронное истечение.
  4. Тестируйте истечение под нагрузкой. Сценарий «кэш пуст при высоком трафике» должен быть в нагрузочных тестах; о приёмах — в статье про структуры Redis.
  5. Учитывайте горячие ключи. Один популярный ключ нагружает один узел кластера Redis независимо от общего числа узлов.

Практический сценарий: раз в пять минут база уходила в отказ

Контекст: каждые пять минут задержка базы резко росла, и часть запросов завершалась таймаутом. Между пиками система работала нормально.

  1. Диагностика. В логах базы обнаружился всплеск одинаковых запросов к одной таблице — до 300 в секунду в течение 200 миллисекунд.
  2. Проверка кэша. Все ключи каталога создавались с фиксированным TTL 300 секунд во время прогрева после деплоя. Они истекали одновременно.
  3. Причина. Классический stampede: в момент истечения все запросы шли в базу, выполняя один и тот же тяжёлый запрос с несколькими соединениями.
  4. Действия. (а) Добавили джиттер TTL ±20 %; (б) для двадцати самых тяжёлых ключей ввели single-flight с блокировкой на 10 секунд; (в) ограничили параллелизм тяжёлых запросов к базе семафором; (г) для главной страницы включили отдачу устаревшего значения с фоновым обновлением; (д) добавили метрику числа промахов по ключу и алерт при аномальном росте.
  5. Результат. Пики исчезли: истечения распределились по минуте, а одновременные промахи по одному ключу обрабатываются одним запросом к базе.

Вывод: фиксированный TTL превращает кэш в генератор пиковых нагрузок. Джиттер и single-flight решают проблему за несколько строк кода, но требуют проверки под нагрузкой.

FAQ

Чем cache stampede отличается от thundering herd? Термины часто используют как синонимы. Строго, stampede относится к массовому промаху по одному ключу кэша, а thundering herd — к ситуации, когда многие процессы просыпаются по одному событию и только один получает работу.

Помогает ли увеличение TTL? Частично: пики становятся реже, но выше, поскольку нагрузка накапливается дольше. Без джиттера проблема сохраняется.

Что делать, если вычисление занимает минуту? Обновлять ключ фоновой задачей по расписанию, а не по запросу. Кэш в этом случае всегда содержит значение, а источник нагружается предсказуемо.

Нужна ли блокировка для редких ключей? Нет. Для ключа, который читают раз в минуту, блокировка добавляет сложность без пользы. Защита нужна там, где высокая частота чтения сочетается с дорогим вычислением.

Источники: RFC 5861 (stale-while-revalidate), Redis docs (locks), Wikipedia — ссылки в шапке.