Защита кэша: стампед, лавина, проникновение
Кэш ускоряет систему — пока всё хорошо. Но есть сценарии, когда кэш сам становится источником обвала. Их нужно знать заранее.
Самый коварный сбой кэша — это когда популярный ключ истекает, и тысячи запросов разом бьют в БД. Кэш, призванный защищать БД, на мгновение убирает защиту.
Три классические проблемы кэширования под нагрузкой: 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 при проектировании, чем разбираться, почему БД легла ровно в момент массового истечения кэша.