Защита кэша: стампед, лавина, проникновение

Кэш ускоряет систему — пока всё хорошо. Но есть сценарии, когда кэш сам становится источником обвала. Их нужно знать заранее.

Самый коварный сбой кэша — это когда популярный ключ истекает, и тысячи запросов разом бьют в БД. Кэш, призванный защищать БД, на мгновение убирает защиту.

Три классические проблемы кэширования под нагрузкой: cache stampede (давка), cache penetration (проникновение) и cache avalanche (лавина). Каждая способна положить БД. Разберём их и защиту.

Cache stampede (thundering herd)

Популярный ключ истекает по TTL. В этот момент сотни одновременных запросов получают промах — и все разом идут в БД за одним и тем же. БД захлёбывается.

   Cache stampede

   TTL истёк -> ключ исчез
        |
   запрос1 \
   запрос2  >-- все промахнулись --> [БД] перегрузка!
   запрос3 /     одновременно
   ...

Защита: (1) блокировка пересчёта — только один запрос идёт в БД, остальные ждут (mutex/lock на ключ); (2) ранний асинхронный пересчёт до истечения (refresh-ahead); (3) небольшой случайный разброс TTL, чтобы ключи не истекали синхронно.

Cache penetration (проникновение)

Запросы идут по ключу, которого нет ни в кэше, ни в БД (например, перебор несуществующих ID). Каждый такой запрос промахивается мимо кэша и зря нагружает БД.

Защита: кэшировать сам факт «такого нет» (положить пустышку с коротким TTL); фильтр Bloom для быстрой проверки «точно нет».

Cache avalanche (лавина)

Множество ключей истекает одновременно (например, все закэшированы в один момент с одинаковым TTL). Массовый промах = массовый удар по БД.

Защита: добавлять случайный джиттер к TTL (например, базовый TTL ± 10%), чтобы истечения размазались во времени.

Демонстрация: защита от стампеда блокировкой на Python

import threading, time

cache = {}
db_calls = {"n": 0}
locks = {}
lock_guard = threading.Lock()

def rebuild_from_db(key):
    time.sleep(0.05)              # имитация запроса в БД
    db_calls["n"] += 1
    return f"value_for_{key}"

def get_with_lock(key):
    if key in cache:
        return cache[key]
    # берём блокировку на конкретный ключ
    with lock_guard:
        lk = locks.setdefault(key, threading.Lock())
    with lk:                      # только ОДИН поток пересчитает
        if key in cache:          # двойная проверка
            return cache[key]
        cache[key] = rebuild_from_db(key)
        return cache[key]

# 20 потоков одновременно просят отсутствующий ключ
threads = [threading.Thread(target=get_with_lock, args=("hot",)) for _ in range(20)]
for t in threads: t.start()
for t in threads: t.join()

print("Потоков запросило ключ: 20")
print("Реальных обращений к БД:", db_calls["n"])
print("Без блокировки было бы ~20 ударов по БД, с блокировкой — 1.")

Блокировка на ключ гарантирует: пока один запрос пересчитывает значение, остальные ждут его результата вместо штурма БД.

Как работает под капотом

Все три проблемы — про момент промаха. Stampede — много промахов по одному ключу одновременно. Avalanche — много промахов по разным ключам в один момент. Penetration — промахи по ключам, которых нет вообще. Общая идея защиты: не дать всем промахам дойти до БД одновременно — через блокировки, кэширование отрицательных ответов и разброс TTL.

Частые ошибки

  • Одинаковый TTL для всех ключей. Готовая лавина при массовом прогреве кэша.
  • Не кэшировать «пустые» ответы. Открывает дорогу проникновению по несуществующим ключам.
  • Игнорировать горячие ключи. Один очень популярный ключ при истечении устроит давку.

Best practices

  • Добавляйте джиттер к TTL: base ± random, чтобы размазать истечения.
  • Для горячих ключей используйте блокировку пересчёта или refresh-ahead.
  • Кэшируйте отрицательные ответы с коротким TTL; для огромных множеств — Bloom-фильтр.

Итог: Stampede, penetration и avalanche — три способа, которыми кэш под нагрузкой может ударить по БД. Защита: блокировка пересчёта, кэширование отрицательных ответов и случайный джиттер TTL. Все они про управление моментом промаха.

Сводная таблица защит

Соберём три проблемы и их лекарства в одну памятку, чтобы при проектировании кэша держать их перед глазами:

ПроблемаСутьЗащита
StampedeМного промахов по одному ключу разомБлокировка пересчёта, refresh-ahead, джиттер TTL
PenetrationЗапросы по несуществующим даннымКэшировать «пусто» с коротким TTL, Bloom-фильтр
AvalancheМного ключей истекает одновременноДжиттер TTL, прогрев кэша с разбросом сроков

Объединяющая мысль: кэш защищает БД ровно до тех пор, пока промахи редки и распределены во времени. Все три проблемы — это всплески промахов, синхронные по времени или сконцентрированные на одном ключе. Поэтому почти все защиты сводятся к двум идеям: размазать промахи во времени (джиттер TTL, refresh-ahead) и не пустить лишние промахи к БД (блокировка пересчёта, кэширование отрицательных ответов, Bloom-фильтр).

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

Проверьте себя
1. Что такое cache stampede (thundering herd)?
AКэш переполняется и удаляет все ключи
BПопулярный ключ истекает, и множество одновременных запросов разом идут в БД за одними данными
CАтака, при которой кэш перезаписывают вредоносными данными
DКэш медленнее, чем прямой запрос в БД
2. Как джиттер (случайный разброс) TTL помогает против cache avalanche?
AОн ускоряет чтение из кэша
BОн размазывает моменты истечения ключей во времени, чтобы они не промахивались все разом
CОн шифрует ключи кэша
DОн увеличивает общий объём памяти