Модуль 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 и получают тот же результат:
goif 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. Диагностический сигнал — отношение:
promqlsum(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/. Время — ориентир для одного человека.
- Базовая линия (5 мин).
make up && make seed, откройте Grafana (http://localhost:3001/d/polygon-overview), запуститеmake load-hot. - Сломать (5 мин).
make fail-5— ожидаемоAPI перезапущен с CACHE_MODE=naive,вычислений на промах: ≈1.0. На панели «Кэш: промахи и вычисления»misses/sиcomputes/sидут вместе, hit ratio падает. - Redis-диагностика (5 мин). Пока идёт нагрузка или сразу после
make load-hot:
bashdocker 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.
- SQL-упражнение (5 мин). Посмотрите, что считается за эти 200 мс:
bashdocker 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 — намеренная задержка «сборки отчёта», чтобы окно шторма было наблюдаемым.
- Красный чек (3 мин).
make check-5— ожидаемоCHECK FAIL: кэш-шторм — промахи не схлопываются (CACHE_MODE=naive?); перед падением чек печатаетвычислений на промах: X, p99 /hot: Yms. - Починить (2 мин).
make fix-5— режимsingleflight, повторный шторм, отношение ~0.1–0.3. Затемmake check-5—CHECK OK: сценарий 5: шторм погашен, /hot в SLO. - Сравнить режимы вручную (10 мин).
bashCACHE_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 и сравните отношение.
- Защита (10 мин). Предложите для
/hot: jitter TTL, прогрев при деплое, singleflight на всех инстансах или общий Redis-лок; объясните, что закрывает шторм, а что — только холодный старт.
🧩 Quiz "Проверь себя"
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 → алерт → причина» целиком. Заодно проверим, что сигнал приходит
за минуту, а не после того, как клиент напишет в поддержку.