Перейти к содержимому
Skein
← Все проекты

backend · intermediate · 5d

Лаборатория cache stampede

Воспроизведи thundering-herd промах кэша под нагрузкой, затем убей его через single-flight и пересчёт с ранним истечением.

Read-through кэш выглядит как выигрыш в латентности, пока горячий ключ не истекает и источник не получает тысячу одновременных промахов — кэш только что усилил одно событие истечения в thundering herd. Этот проект заставляет воспроизвести этот режим отказа под измеримой нагрузкой, увидеть скачок origin QPS на графике, а затем убить его двумя дополняющими оружиями: коалесцированием через single-flight (только один горутин/промис гонится к источнику, остальные ждут его результата) и вероятностным ранним истечением (XFetch пересчитывает горячий ключ до реального истечения, и обрыв жёсткого TTL исчезает). Сеньорный инсайт: TTL — не просто бюджет свежести, это синхронизированный таймер, разделённый всеми активными запросами, и правильное сочетание jitter, раннего пересчёта и схлопывания запросов превращает этот обрыв в плавный склон. Финальный этап превращает лабораторию в продукт: RED-метрики, трейс-span и отработанный инцидент, где ты обнаруживаешь стадо по дашборду, а не по логам.

Результат

Демо, где истечение горячего ключа под 200 конкурентными клиентами шлёт 1 запрос к источнику вместо 200, доказанное k6-тестом с графиками origin-QPS и staleness-vs-load.

Этапы

0/5 · 0%
  1. 01Спровоцируй стампид и измерь fan-out

    Собери read-through кэш (промах → пересчёт → заполнение) и сделай отказ видимым, а не теоретическим. Горячий ключ с фиксированным TTL — синхронизированный таймер: каждый конкурентный читатель, наблюдающий истечение в один миг, решает пересчитать, так что N читателей порождают N вызовов источника из одного события истечения. Твоя задача — это инструментировать: горячий ключ, настраиваемая стоимость пересчёта источника (например, 50–200 мс) и генератор нагрузки (k6 -c 50–200 или autocannon), бьющий в ключ через границу TTL. Зафиксируй fan-out ratio (параллельные промахи / вызовы источника) и скачок origin-QPS на временном графике — скачок и есть стампид, и без графика ты не докажешь, что следующие два этапа что-то починили. Держи кэш read-through, а не write-through: источник — авторитет, кэш лишь заполняется на промахе.

    Критерии готовности
    • Прогон k6/autocannon с 50–200 клиентaми, бьющими в один горячий ключ через истечение TTL, показывает скачок fan-out (например, 50–200 вызовов источника из одного истечения) на графике с размеченными origin QPS и числом промахов.
    • Кэш read-through: промах пересчитывает из источника (с симулированной задержкой), заполняет ключ с TTL, а попадание никогда не трогает источник.
    Самопроверка

    Покажи график origin-QPS через границу TTL и назови измеренный fan-out ratio. Senior-ревьюер проверяет, что скачок N≈конкуренция (а не плоская линия) и что стоимость пересчёта параметризована, а не захардкожена.

  2. 02Схлопни параллельные промахи в один вызов источника

    Добавь single-flight (коалесцирование запросов): когда N читателей одновременно промахиваются по одному ключу, только один полёт идёт к источнику, а остальные N-1 ждут его результата и делят его. Инвариант — ровно один вызов источника на коалесцированное окно промахов независимо от конкуренции. Два тонких момента на senior-глубине: (1) распространение ошибок — если единственный полёт падает, трансляция ошибки всем N ожидающим превращает один сбой источника в N сбоев запросов, поэтому не кешируй ошибки и позволь ожидающим ретраить независимо; (2) латентность очереди ожидания — ожидающие блокируются на время пересчёта (50–200 мс), измерь p50/p99 ожидания и убедись, что карта коалесцирования на ключ, сильно конкурирующие ключи не стопорят несвязанные ключи. Перезапусти тот же молот из этапа 1 и смотри, как fan-out схлопывается с N до 1.

    Критерии готовности
    • Повтор того же молота на 50–200 показывает fan-out = 1 (один вызов источника на окно истечения) — скачок origin-QPS из этапа 1 исчез, проверено на том же графике.
    • Сбой источника в коалесцированном окне не отравляет кэш и не превращает одну ошибку в N закешированных ошибок — ожидающие ретраят или промахиваются независимо, p99 очереди ожидания зафиксирован.
    Самопроверка

    Покажи графики origin-QPS до/после (fan-out N→1) и объясни, что происходит с N ожидающими, если единственный полёт бросает исключение. Senior-ревьюер проверяет, что ты не кешируешь ошибку и что латентность очереди ожидания измерена, а не угадана.

  3. 03Размажь пересчёт ранним истечением XFetch

    Single-flight чинит стадо после его формирования; XFetch не даёт стаду сформироваться. Вероятностное раннее истечение пересчитывает горячий ключ до жёсткого TTL с вероятностью, растущей по мере приближения к истечению: пересчитывай если `now - delta * beta * log(rand()) > expiry`, где `delta` — измеренное время пересчёта источника, а `beta ≈ 1` — ручка настройки. Эффект — пересчёты размазываются по окну TTL вместо концентрации на границе, обрыв на графике origin-QPS становится склоном. Настрой beta под измеренную delta: слишком мал — редко пересчитываешь заранее (обрыв остаётся); слишком велик — пересчитываешь почти на каждом запросе (нагрузка на источник приближается к частоте промахов). Держи single-flight снизу — XFetch без коалесцирования всё равно стадит, когда два ранних пересчёта перекрываются.

    Критерии готовности
    • XFetch реализован формулой `now - delta*beta*log(rand()) > expiry`, beta настроена под измеренную delta; график origin-QPS показывает замену обрыва истечения на размазанный склон, ранние пересчёты посчитаны отдельно от жёстких промахов.
    • Single-flight остаётся активным снизу, так что перекрывающиеся ранние пересчёты всё равно схлопываются в один вызов источника; отключение XFetch возвращает обрыв, доказывая, что именно XFetch, а не только single-flight, убрал его.
    Самопроверка

    Назови измеренную delta, выбранную beta и покажи графики обрыв-против-склона с XFetch вкл/выкл. Senior-ревьюер проверяет, что beta обоснована из delta (а не дефолтная 1) и что ранние пересчёты разнесены по времени, а не всплеском на истечении.

  4. 04Jitter, устаревание и кривая компромисса

    Jitter и XFetch решают разные задачи, и сеньорная ошибка — их путать. Jitter TTL (например, `TTL * (0.9 + 0.2*rand())`) разносит истечение множества ключей, чтобы они не истекали в один миг — снижает межключевые столкновения, но ничего не делает для одного очень горячего ключа, который всё равно стампидит на собственном истечении независимо от jitter. XFetch размазывает пересчёт одного горячего ключа. Этот этап делает компромисс количественным: прогони TTL и beta, измерь две кривые — origin QPS (нагрузка) против staleness (как долго читатель может видеть устаревшие данные) — и построй график компромисса. Выбери дефолты, которые можешь защитить: например, 'TTL 60с + beta 1.0 + 10% jitter держат p95 staleness < 70с, пока origin QPS остаётся < 2% от частоты запросов под молотом 200'. Задокументируй, почему один jitter не починил бы случай горячего ключа из этапа 1.

    Критерии готовности
    • Кривая staleness-против-нагрузки-на-источник построена свипом (минимум 4 комбинации TTL/beta), выбран защищаемый дефолт (TTL, beta, jitter %) с числами — например, зафиксированы p95 staleness и origin QPS %.
    • Контрольный прогон только с jitter (без XFetch и single-flight) всё равно стампидит на одном горячем ключе, доказывая, что один jitter не чинит случай горячего ключа — результат задокументирован рядом с кривой.
    Самопроверка

    Представь кривую staleness-против-нагрузки и защити выбранные дефолты числами. Senior-ревьюер проверяет, что ты можешь сформулировать, почему один jitter не спасает один горячий ключ (а не только 'много ключей') и что именно кривая, а не интуиция, определила выбор TTL/beta.

  5. 05Нагрузи, наблюдай и отработай инцидент

    Докажи end-to-end под прод-подобной нагрузкой и сделай наблюдаемым, когда оно ведёт себя плохо. Прогони полный стек (кэш + источник + single-flight + XFetch + jitter) под устойчивой k6-нагрузкой (например, 200 конкурентно, 30с, смесь горячих/холодных ключей) и найди QPS, где узким местом становится пересчёт источника, а не поиск в кэше. Снимай RED-метрики (rate запросов, hit/miss rate кэша, origin QPS, длительность пересчёта p50/p99) и трейс-span на вызов источника, чтобы собственная латентность кэша была видна в водопаде. Затем отработай инцидент: отключи XFetch или инжектируй stall источника 500 мс и смотри, как кэш усиливает его — origin QPS взлетает, hit rate падает, p99 раздувается. Обнаружь это по дашборду (не логам), смягчи (включи XFetch / добавь таймаут single-flight / сбрось нагрузку) и напиши пост-мортем на 5 строк, чья превенция — не 'добавь ещё кэша'.

    Критерии готовности
    • Устойчивый нагрузочный тест (≥200 конкурентно, ≥30с) сообщает пропускную способность, hit rate, origin QPS и p50/p99 пересчёта; дашборд показывает все четыре, трейс-span источника виден в водопаде.
    • Ты воспроизвёл инцидент (XFetch выключен или stall источника), обнаружил его по дашборду, смягчил и написал пост-мортем с корневой причиной и превенцией, которая не 'добавь ещё кэша' или 'увеличь TTL'.
    Самопроверка

    Вставь скриншот дашборда и пост-мортем. Senior-ревьюер проверяет, что узкое место отнесено к пересчёту источника (а не поиску в кэше), трейс-span его локализовал, а превенция адресует распределение TTL/XFetch/single-flight, а не просто 'масштабируй'.

Стартер

fallowlone/skein-projects

projects/cache-stampede-lab

Открыть на GitHub ↗
  • README.md
  • src/cache.ts
  • test/cache.test.ts
Забрать только этот проект npx degit fallowlone/skein-projects/projects/cache-stampede-lab cache-stampede-lab

Реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test

Форкни репозиторий и запушь свою работу — workflow grade прогонит тесты и статические проверки на твоих раннерах.

Рубрика

Джуниор Миддл Сеньор
Воспроизведение стампида Кэш промахивается при истечении, но нагрузочный тест не инструментирован; origin QPS во время стампида не измеряется. Нагрузочный тест истекает один горячий ключ под устойчивой конкурентной нагрузкой и показывает скачок origin QPS (например, 1 запрос → N параллельных промахов) на графике с зафиксированным числом промахов. Ты воспроизводишь стампид, измеряешь точный fan-out (соотношение параллельных промахов к вызовам источника) и объясняешь, почему jitter TTL сам по себе не чинит горячий ключ — он лишь снижает вероятность столкновения, когда несколько ключей истекают в одном окне.
Коалесцирование single-flight Все параллельные промахи независимо вызывают источник; дедупликации активных запросов нет. Single-flight (или мьютекс на ключ) гарантирует, что параллельные промахи по одному ключу схлопываются в один вызов источника; остальные ждут и переиспользуют результат. Ты знаешь патологический случай: если вызов источника падает, single-flight транслирует ошибку всем ожидающим — одна ошибка источника становится N ошибками запросов. Ты решаешь это независимыми повторами при неудаче, а не кешированием ошибки, и измеряешь стоимость латентности очереди ожидания при высокой конкуренции.
Раннее истечение и выбор TTL TTL — фиксированная константа, выбранная интуитивно; кэш всегда промахивается на жёстком истечении, никогда не пересчитывает заблаговременно. Stale-while-revalidate или вероятностное раннее истечение XFetch пересчитывает горячий ключ до срабатывания жёсткого TTL, и обрыв на границах истечения исчезает под нагрузкой. Ты настраиваешь параметр beta XFetch под измеренное время пересчёта источника: слишком малый — и пересчёт не начинается достаточно рано; слишком большой — и пересчёт происходит почти на каждом запросе. Ты представляешь кривую «устаревание против нагрузки на источник» и защищаешь выбранный дефолт числами из нагрузочного теста.
Эталонный разбор (спойлер)

Почему возникает thundering herd: популярный ключ истекает, и все активные запросы одновременно наблюдают промах. Сама атомарность кэша порождает проблему: без координации N читателей каждый решает пересчитать, N вызовов источника стреляют, и кэш только что умножил единственное истечение в fan-out, пропорциональный конкуренции запросов.

Single-flight как основной фикс: схлопни все параллельные промахи по одному ключу в один вызов источника и разошли результат. Компромисс — латентность: ожидающие блокируются на время пересчёта. Ловушка распространения ошибок (единственная ошибка источника расходится всем ожидающим) означает: не кешируй ошибки — позволь неудачам повторяться независимо.

Вероятностное раннее истечение XFetch: пересчитывай ключ с вероятностью, пропорциональной оставшемуся TTL и стоимости пересчёта, чтобы пересчёт распределялся по окну TTL, а не концентрировался на границе. Формула: пересчитывай, если `now - delta * beta * log(rand()) > expiry_time`. Beta ≈ 1 — хорошая отправная точка; увеличивай, если вызовы источника дороги.

Только jitter TTL недостаточно: jitter разносит истечения во времени, чтобы несколько ключей не истекали в один миг, снижая межключевые столкновения, но единственный очень горячий ключ всё равно устраивает стампид при собственном истечении независимо от jitter. Используй jitter для диверсификации ключей, single-flight или раннее пересчёт — для защиты горячих ключей.

Сделай по-сеньорски

  • Замени единственный TTL на двухуровневый stale-while-revalidate: отдавай устаревшее, пока фоновое обновление идёт, и измерь, как это меняет устаревание на ноль блокировок на горячем пути.
  • Добавь per-key circuit breaking: если источник падает N раз для одного ключа, перестань его долбить и отдавай устаревшее или фолбэк на окно cooldown.
  • Прогони хаос-тест, убивающий источник на середине пересчёта под нагрузкой, и докажи, что кэш никогда не отдаёт частичную запись, а ожидающие не висят вечно (таймаут single-flight).

Навыки

TTL designsingle-flight / request coalescingprobabilistic early expiration (XFetch)load testing & fan-out measurementstaleness vs origin-load tradeoff

Рекомендуемый стек

typescriptredisk6

Материалы