Программа курса

Модуль 02 / Урок 8 из 27

Анти-паттерны: stampede, проникновение, горячие ключи

Теория и практика

Три аварии, которые происходят с каждым кешем хотя бы раз: лавина промахов, запросы несуществующих данных и один ключ, на котором держится вся нагрузка. Разбираем каждую с готовым кодом защиты.

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.

Практика

  1. Воспроизведите stampede: 20 процессов читают ключ с TTL 5с, compute спит 2с; замерьте число вызовов compute за минуту. Включите мьютекс — стало 1 вызов на истечение.
  2. Добавьте джиттер TTL и убедитесь, что истечения «размазались» (логируйте момент промаха).
  3. Реализуйте null-кеширование и погоните benchmark по несуществующим ключам: сравните keyspace_misses/нагрузку на fake-БД.
  4. Нагрузите один ключ 50k RPS (redis-benchmark -t get -n 500000 -c 50) и убедитесь, что QPS упирается в одно ядро; попробуйте расщепление на 10 копий.
Мини-квиз

1. Чем stampede отличается от простого промаха? — Массовостью: тысячи одновременных пересчётов одного значения, отчего страдает БД.

2. Может ли фильтр Блума сказать «нет» для существующего id? — Нет, только редкое ложное «да» для отсутствующего.

3. Почему расщепление ключа помогает от hot key? — Нагрузка распределяется по N ключам и, в кластере, по N слотам/ядрам.