Модуль 5. Кэш-шторм: cache-aside, singleflight и TTL

Цель модуля: воспроизвести шторм на горячем ключе, отличить «много промахов» от «каждый промах считает сам» и погасить его singleflight — с машинной проверкой по отношению вычислений к промахам и p99 /hot.

Перед началом: Redis и cache-aside на уровне arch v2 m5, горутины и sync.WaitGroup — go-concurrency m15. Инструменты: Docker Compose v2, Grafana, redis-cli, psql, curl, make. Трудоёмкость: ~1.5 ч (разбор 40 мин · практика 50 мин).

🔥 Инцидент: «горячий ключ истёк — и база получила сотни запросов»

15:05. /hot обычно отвечает за 5–10 мс: результат лежит в Redis. Внезапно p99 /hot уходит к секундам, а на панели «Кэш: промахи и вычисления» промахи и вычисления идут вплотную; «Cache hit ratio» проваливается, хотя ошибок нет и RPS не менялся.

Причина — TTL: ключи hot:1..hot:10 живут 30 секунд и истекают почти одновременно. 40 воркеров loadgen продолжают слать запросы, и каждый промах в CACHE_MODE=naive сам запускает вычисление на 200 мс. Пока лидер считает, остальные видят пустой ключ и считают то же самое: один ключ даёт залп одинаковых запросов к реплике — в разборе инцидента их было около 500.

📖 5.1. Cache-aside: как кэш связан с БД

Стенд использует cache-aside: приложение само читает кэш, при промахе идёт в БД, кладёт результат в Redis и отдаёт клиенту. В api/internal/cache/cache.go это GetOrCompute: Get → hit (cache_hits_total) → возврат; miss (cache_misses_total) → compute → Set с TTL.

Маршрут /hot строит ключ hot:{user_id} с TTL 30 секунд. Внутри — HotAggregate: count(*) по orders за 30 дней на реплике плюс намеренная пауза 200 мс («сборка отчёта»). Заказ /orders/{id} кэшируется отдельно, ключ order:{id}, TTL 60 секунд. Redis — не источник правды, а копия: потерянный ключ пересчитывается из PostgreSQL, и каждый промах стоит полного вычисления.

cache_compute_total — третье событие: сколько раз вычисление реально выполнилось. Проблема начинается на границе TTL. Глубина по cache-aside и инвалидации — arch v2 m5.

📖 5.2. Почему TTL-истечение = залповый удар (stampede)

TTL не размазывает истечение: ключ исчезает в один момент, и все запросы после него одновременно видят miss. Чем горячее ключ, тем больше запросов попадает в окно пересчёта. В стенде окно — 200 мс, поток — 40 воркеров: на один ключ приходятся десятки одинаковых вычислений, а десять рядом истёкших ключей складывают залпы.

scripts/fail-5.sh усиливает эффект: пересоздаёт api в naive, запускает hot-нагрузку на 15 секунд и параллельно flush_hot_loop — каждые 0.3 секунды удаляет все hot:1..hot:10 через POST /admin/flush. Ключи не успевают прогреться, шторм не заканчивается. В конце скрипт печатает «вычислений на промах»: в naive отношение ≈ 1.0 — каждый промах стал вычислением.

Высокий RPS сам по себе не ломает БД. Ломает умножение одного и того же вычисления на число одновременных промахов: кэш из защиты становится усилителем. Добавляет промахов и Redis: maxmemory 128mb с политикой allkeys-lru вытесняет ключи под давлением памяти.

📖 5.3. Singleflight: схлопываем промахи

CACHE_MODE=singleflight (значение по умолчанию) добавляет к cache-aside карту полётов. Первый промах создаёт flight, считает значение и пишет его в Redis; остальные промахи того же ключа ждут WaitGroup и получают тот же результат:

go
if f, ok := c.flights[key]; ok { f.wg.Wait(); return f.val, f.err }
// иначе: создать flight, вычислить, Set, Done, удалить из карты

cache_misses_total по-прежнему растёт на каждом запросе — каждый действительно промахнулся. А cache_compute_total растёт один раз на полёт, поэтому отношение падает далеко ниже единицы: в десятисекундном шторме типично 0.1–0.3.

Singleflight схлопывает только одновременные промахи одного процесса и только на время вычисления; две реплики api имеют свои карты, а холодный старт после деплоя он не лечит. Теория — go-concurrency m15.

📖 5.4. Смежные приёмы: jitter TTL, прогрев, negative caching, инвалидация

  • Jitter TTL. ttl = 30s + rand(0..6s): истечения разъезжаются; цена — более широкое окно устаревания.
  • Прогрев. Refresh-ahead или прогрев hot:1..hot:10 после деплоя убирает холодный старт.
  • Negative caching. Короткий TTL для «не найдено» защищает БД от запросов по несуществующим id.
  • Инвалидация по записи. Для order:{id} удалять ключ после UPDATE; TTL — страховка, а не механизм свежести.
  • Stale-while-revalidate. Отдавать старый ответ, пока один процесс обновляет значение.
  • Распределённая блокировка. SET NX PX схлопывает промахи между инстансами ценой сетевого хопа и таймаутов.

Тяжёлые агрегаты лучше считать не на OLTP-реплике, а в аналитическом контуре: как строится пайплайн — pg-clickhouse m6.

📖 5.5. Диагностика: отношение вычислений к промахам

Абсолютные значения счётчиков ничего не говорят: 1000 промахов — норма при 10 000 RPS и катастрофа при 100 RPS. Диагностический сигнал — отношение:

promql
sum(rate(cache_compute_total[1m]))
  / clamp_min(sum(rate(cache_misses_total[1m])), 0.001)
  • ≈ 1.0 — каждый промах считает сам: naive или потерянный singleflight, это шторм.
  • 0.1–0.3 — промахи схлопываются, вычислений на порядок меньше; норма singleflight.
  • Резкий рост при неизменном RPS — массовое удаление ключей: деплой, flush, инвалидация.

В lib.sh та же формула живёт в cache_ratio: make fail-5 печатает отношение за 5 минут, fix-5 — за 1 минуту, check-5 — за 20 секунд вокруг собственного шторма. check-5 требует отношение ≤ 0.5 и p99 /hot ≤ 1000 мс; p99 он берёт из вывода loadgen (p99=...ms), а не из Prometheus.

В Grafana смотрите две панели. «Кэш: промахи и вычисления»: в шторме computes/s идёт вровень с misses/s, после singleflight появляется разрыв. «Cache hit ratio» при непрерывном flush может оставаться низким — ключи удаляют каждые 0.3 секунды; диагноз ставится по отношению.

📖 5.6. Компромиссы

  • naive vs singleflight. naive не заставляет ждать, но платит БД за каждый промах; singleflight экономит вычисления, но задерживает ведомых и наследует ошибку лидера.
  • TTL короткий vs длинный. Короткий свежее, но чаще промахи; длинный снижает нагрузку, но дольше живёт устаревшее значение.
  • Jitter vs предсказуемость. Размазанные истечения снижают залп, но окно свежести неравномерно.
  • In-process vs distributed. Карта в памяти дешева, но не работает между инстансами; Redis-лок глобален, но добавляет хоп и отказную точку.
  • Инвалидация vs TTL. Инвалидация свежее, но требует дисциплины во всех путях записи; TTL — простая страховка.

💻 Практика

Все команды — из каталога polygon/. Время — ориентир для одного человека.

  1. Базовая линия (5 мин). make up && make seed, откройте Grafana (http://localhost:3001/d/polygon-overview), запустите make load-hot.
  2. Сломать (5 мин). make fail-5 — ожидаемо API перезапущен с CACHE_MODE=naive, вычислений на промах: ≈1.0. На панели «Кэш: промахи и вычисления» misses/s и computes/s идут вместе, hit ratio падает.
  3. Redis-диагностика (5 мин). Пока идёт нагрузка или сразу после make load-hot:
bash
docker compose exec redis redis-cli TTL hot:1
docker compose exec redis redis-cli GET hot:1
docker compose exec redis redis-cli DEL hot:1
curl -s "http://localhost:8088/hot?user_id=1"
docker compose exec redis redis-cli TTL hot:1

Ожидаемо: первый TTL около 30 (или −2, если ключ уже удалён), DEL возвращает 1, следующий /hot считает 200 мс и восстанавливает ключ с TTL ~30.

  1. SQL-упражнение (5 мин). Посмотрите, что считается за эти 200 мс:
bash
docker compose exec -T postgres-replica psql -U app -d orderflow -c \
  "EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM orders
    WHERE user_id = 1 AND created_at > now() - interval '30 days';"

Сам count(*) быстрый; в шторме он выполняется десятки раз параллельно, а 200 мс в /hot — намеренная задержка «сборки отчёта», чтобы окно шторма было наблюдаемым.

  1. Красный чек (3 мин). make check-5 — ожидаемо CHECK FAIL: кэш-шторм — промахи не схлопываются (CACHE_MODE=naive?); перед падением чек печатает вычислений на промах: X, p99 /hot: Yms.
  2. Починить (2 мин). make fix-5 — режим singleflight, повторный шторм, отношение ~0.1–0.3. Затем make check-5 — CHECK OK: сценарий 5: шторм погашен, /hot в SLO.
  3. Сравнить режимы вручную (10 мин).
bash
CACHE_MODE=naive docker compose up -d --force-recreate api
CACHE_MODE=singleflight docker compose up -d --force-recreate api

Или функциями из lib.sh: source scripts/lib.sh && recreate_api naive true true, затем recreate_api singleflight true true. После каждого переключения повторите make check-5 и сравните отношение.

  1. Защита (10 мин). Предложите для /hot: jitter TTL, прогрев при деплое, singleflight на всех инстансах или общий Redis-лок; объясните, что закрывает шторм, а что — только холодный старт.

🧩 Quiz "Проверь себя"

1. Какая защита лучше всего от одновременного истечения hot-ключей?
2. Что делает singleflight при промахах одного ключа?
3. Что означает отношение вычислений к промахам ≈ 1.0?
4. Почему истечение TTL горячего ключа вызывает stampede?
5. Что делает cache-aside при промахе в режиме CACHE_MODE=naive?

✅ Итоги модуля

  • Объяснить путь cache-aside и разницу трёх счётчиков кэша.
  • Воспроизвести шторм make fail-5 и увидеть computes ≈ misses.
  • Показать в redis-cli TTL, DEL и восстановление ключа после /hot.
  • Пройти цикл fail-5 → check-5 (red) → fix-5 → check-5 (green).
  • Назвать защиту: singleflight, jitter TTL, прогрев, инвалидация.
  • Объяснить, почему hit ratio сам по себе не диагностирует шторм.

🔗 Мостик к Модулю 6

Шторм мы погасили, но узнали о нём из панели и собственного разбора, а не от алерта: в базовом наборе правил сигнала на stampede нет. В модуле 6 добавим CacheStampede — алерт на отношение вычислений к промахам — и пройдём путь «симптом → SLO → алерт → причина» целиком. Заодно проверим, что сигнал приходит за минуту, а не после того, как клиент напишет в поддержку.