Три аварии, которые происходят с каждым кешем хотя бы раз: лавина промахов, запросы несуществующих данных и один ключ, на котором держится вся нагрузка. Разбираем каждую с готовым кодом защиты.
Cache stampede (собачья куча)
Сценарий: горячий ключ с TTL истёк → 500 подов одновременно видят промах → все 500 идут в БД с одним и тем же тяжёлым запросом → БД ложится → таймауты → ретраи → ещё хуже. Классика «упало всё в 03:00, хотя никто ничего не деплоил».
Защита 1: мьютекс на пересчёт
Право пересчитать значение получает один процесс, остальные ждут и потом читают готовое:
def get_with_lock(key, ttl, compute, wait=10):
raw = redis.get(key)
if raw is not None:
return json.loads(raw)
lock_key = key + ":compute"
deadline = time.time() + wait
while time.time() < deadline:
# NX: установится только у одного; EX: не вечная
if redis.set(lock_key, "1", nx=True, ex=30):
try:
raw = compute() # тяжёлый запрос в БД
redis.set(key, json.dumps(raw), ex=ttl)
return raw
finally:
redis.delete(lock_key)
time.sleep(0.05 + random.random() * 0.05) # джиттер против синхронного стада
raw = redis.get(key)
if raw is not None:
return json.loads(raw)
raise TimeoutError("cache recompute timeout")
Защита 2: джиттер TTL и раннее обновление
Чтобы ключи не истекали синхронной волной, добавляйте разброс: ttl = base + random(0..base/10).
Для топовых горячих ключей — фоновой «прогреватель»: пока TTL > 20% остатка, обновлять
значение до истечения, чтобы промах не случался никогда.
Защита 3: stale-while-revalidate
При промахе отдать протухшую копию (хранится рядом как key:stale) и обновить
кеш асинхронно. Пользователь получает мгновенный ответ «слегка устаревших» данных вместо паузы.
Применимо, если несвежесть в пределах минуты терпима.
Cache penetration (проникновение)
Запросы к несуществующим данным всегда пробивают до БД — кешу нечего отдавать. Классика: сканеры перебирают id, бот-шторм, баг в клиенте, генерирующий null-ключи.
- Null-кеширование: кешировать пустой ответ с коротким TTL (30–60 с):
SET user:{id} "" EX 60. Простое и обычно достаточное; - Фильтр Блума: битовый вероятностный фильтр «id вообще существует?» — исключает
заведомо отсутствующие до похода в БД. В Redis — модуль RedisBloom:
Фильтр не даёт ложных отрицательных, но может дать редкие ложные положительные — это ок для кеша;BF.ADD known-ids 1001 ; BF.EXISTS known-ids 99999 (integer) 1 / (integer) 0 # 0 = точно нет: в БД не ходим - Валидация на входе: не ходить в кеш с заведомо мусорными id.
Горячие ключи (hot keys)
Один ключ принимает львиную долю QPS. Redis упирается в одно ядро шарда, на котором
живёт ключ, — остальные ядра простаивают. Диагностика: redis-cli --hotkeys
(требует LFU) или клиентская метрика top-K ключей.
Решения:
- Локальный микро-кеш в процессе: держать значение в памяти пода на 1–5 секунд — убивает 90% обращений к Redis. Компромисс — несвежесть на длительность микро-TTL;
- Расщепление ключа: N копий
hot:{n mod 10}— писать во все, читать случайную. Подходит для значений «одинаковых по смыслу» (топ-новости, конфиг); - Хеш-теги и аудит размещения: если hot key один из многих в системе кластера — возможно, стоит вынести его в отдельный инстанс.
Ещё анти-паттерны, которые дорого стоят
- Кеш без TTL «потом почистим» → OOM и eviction живых данных;
- Гигантские значения в одном ключе (сотни КБ – МБ) → долгая сериализация, сети, eviction. Разбивайте или ужимайте (сжатие gzip/msgpack — часто ×5–10);
- Инвалидация через KEYS pattern → O(N) стопор; тегируйте зависимые ключи:
SADD tag:catalog "cache:products:page1" "cache:products:page2"и чистите по сету; - Одна команда
MGETна 50 000 ключей → огромный ответ одним пакетом; бэтчируйте по 500–1000.
Практика
- Воспроизведите stampede: 20 процессов читают ключ с TTL 5с, compute спит 2с; замерьте число вызовов compute за минуту. Включите мьютекс — стало 1 вызов на истечение.
- Добавьте джиттер TTL и убедитесь, что истечения «размазались» (логируйте момент промаха).
- Реализуйте null-кеширование и погоните benchmark по несуществующим ключам: сравните keyspace_misses/нагрузку на fake-БД.
- Нагрузите один ключ 50k RPS (
redis-benchmark -t get -n 500000 -c 50) и убедитесь, что QPS упирается в одно ядро; попробуйте расщепление на 10 копий.
Мини-квиз
1. Чем stampede отличается от простого промаха? — Массовостью: тысячи одновременных пересчётов одного значения, отчего страдает БД.
2. Может ли фильтр Блума сказать «нет» для существующего id? — Нет, только редкое ложное «да» для отсутствующего.
3. Почему расщепление ключа помогает от hot key? — Нагрузка распределяется по N ключам и, в кластере, по N слотам/ядрам.