awesome-everything EN
↑ Обратно к восхождению

Кеширование

Dogpile-эффект: коллизия параллелизма в момент истечения горячего ключа

Суть Когда горячий ключ истекает, все запросы в полёте промахиваются в один миг и пересчитывают одно и то же значение параллельно — один дорогой запрос превращается в N. Single-flight-блокировки, раннее истечение XFetch и джиттер TTL ломают эту коллизию.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на junior-высоте — поверхность
◷ 16 min

Пейджер в 3 часа ночи. Запрос фида главной страницы — 200ms-агрегация, которая обычно выполняется пару раз в минуту — внезапно выполняется тысячи раз в секунду. База на 100% CPU, пул соединений исчерпан. Ничего не деплоили. Потом кто-то читает конфиг кэша: у ключа фида TTL в 5 минут, и в 02:55:00 он истёк. В этот миг в полёте было ~5000 запросов, каждый промахнулся по одному и тому же ключу, и все 5000 ушли в базу одновременно. Один истёкший ключ, 5000 одновременных одинаковых запросов.

Dogpile — это ракурс «параллелизм в момент истечения»

Есть более широкая тема — cache stampede / thundering herd — о том, как отсутствующий ключ заваливает origin. Dogpile-эффект — её самое острое и конкретное лицо: коллизия, которая происходит ровно в момент истечения на одном горячем ключе. Чем горячее ключ, тем хуже dogpile, потому что «горячий» значит, что много запросов одновременно в полёте, а истечение TTL — это событие, которое попадает по всем им в одну и ту же миллисекунду.

Пройди по таймлайну. Ключ закэширован и обслуживает тысячи чтений в секунду прямо из памяти — origin простаивает. Таймер TTL доходит до нуля. Запрос #1 читает кэш, получает промах и начинает пересчёт. Пока он ещё пересчитывает (это занимает 200ms), приходят запросы #2…#5000, каждый читает кэш, каждый тоже получает промах — потому что #1 ещё не записал новое значение — и каждый тоже начинает пересчёт. Промежуток между «первым промахом» и «значение перезаписано» — это и есть всё окно, в котором формируется dogpile. У наивного чтения cache-aside нет координации между этими параллельными промахами, поэтому origin за один тик переходит из простоя в N-параллельность.

Математика стоимости — это и есть вся проблема

Причина опасности dogpile мультипликативна, и цифры безжалостны. Возьми дорогую операцию — скажем, 200ms-агрегацию в базе. В установившемся режиме за тёплым кэшем она почти не выполняется. В момент истечения она выполняется один раз на каждый параллельный запрос. Один дорогой запрос × N параллельных запросов = расплавление origin.

Подставь реальный трафик. Формулировка из Wikipedia: страница, рендер которой занимает 3 секунды, при 10 запросах в секунду означает 30 процессов, пересчитывающих одновременно в момент истечения. Теперь масштабируй: 5000 запросов/сек по 200ms-запросу означает до 5000 одновременных соединений с базой, каждое держит запрос 200ms — далеко за пределами большинства лимитов пула (пул Postgres часто 20–100 соединений). Пул исчерпывается, здоровые несвязанные запросы встают в очередь за стадом, и промах кэша по одному ключу кладёт всю базу. Стоимость запроса в установившемся режиме никогда не была проблемой; проблема — множитель параллелизма в момент истечения.

ФиксКак ломает коллизиюЦена / режим отказа, который взвешивает сеньор
Single-flight-блокировка (mutex)Пересчитывает один запрос; остальные N−1 ждут или отдают staleСама блокировка сериализует; без таймаута → deadlock, если пересчётчик упал
XFetch (вероятностное раннее истечение)Каждый читатель может пересчитать чуть до истечения, в одиночку, так что ключ не истекает под N читателямиНадо хранить delta (стоимость пересчёта); настраивать β; немного лишней работы по пересчёту
Stale-while-revalidateОтдать stale-значение мгновенно; одна фоновая задача обновляетЧитатели кратко видят устаревшие данные; нужен разрыв soft-TTL / hard-TTL
Джиттер TTLРандомизировать TTL каждого ключа, чтобы пачка ключей не истекала вместеРазводит ключи во времени, но ничего не делает для стада одного горячего ключа

Single-flight: свернуть N промахов в один пересчёт

Первый рефлекс — блокировка. На промахе первый запрос захватывает блокировку для этого ключа и пересчитывает; все остальные, кто промахнулся в окне, блокируются на ней и, когда она освобождается, читают свежезаписанное значение вместо пересчёта. golang.org/x/sync/singleflight в Go — каноничная реализация: вызови Do(key, fn), и если вызов для этого ключа уже в полёте, дополнительные вызывающие ждут и получают результат исходного вызова, а не выполняют fn заново. Сообщаемый эффект драматичен — Reddit срезал пиковую нагрузку на базу ~в 3 раза с request coalescing на эндпоинтах фида, а заваливание сервера 100 параллельными запросами по одному ключу может дедуплицировать ~95% избыточных дорогих прогонов, превращая множество ожиданий 3–5 с в один прогон 3–5 с, общий для всех вызывающих.

Оговорка сеньора: локальный mutex коалесцирует только внутри одного процесса. С 20 инстансами приложения за балансировщиком каждый инстанс запускает свой single-flight, поэтому origin всё равно видит до 20 параллельных пересчётов — лучше, чем 5000, но не один. Чтобы получить один пересчёт на весь флот, нужна распределённая блокировка (например, Redis SET key val NX PX 30000), которая покупает межпроцессную координацию ценой сетевого round-trip и новой зависимости от доступности хранилища блокировок.

Почему это работает

Причина, по которой «отдавай stale, пока один пересчитывает» так привлекательна: dogpile существует только в окне между первым промахом и перезаписью значения. Если читателям не приходится ждать это окно — они мгновенно получают предыдущее значение, а один воркер обновляет в фоне — коллизии не во что сталкиваться. Ты меняешь несколько секунд устаревания на то, чтобы никогда не платить за стадо. Для фида главной страницы устаревание в пару секунд незаметно; для банковского баланса — нет. Это доменное суждение и есть настоящее решение.

XFetch: не давать горячему ключу истекать под нагрузкой

Блокировка чинит симптом после промаха; вероятностное раннее истечение (XFetch, из статьи VLDB 2015 «Optimal Probabilistic Cache Stampede Prevention» авторов Vattani, Chierichetti и Lowenstein) бьёт по причине — оно делает так, что ключ практически никогда не истекает, пока он горячий. Каждый читатель на попадании бросает кубик: он добровольно пересчитывает раньше с вероятностью, которая растёт по мере приближения истечения. Правило решения:

time() − delta * beta * log(rand(0,1)) ≥ expiry

где delta — сколько занял последний пересчёт, beta — ручка настройки (статья доказывает, что beta = 1 фактически оптимален), а log(rand(0,1)) берёт выборку из экспоненциального распределения. Изящество: более дорогое значение (большая delta) обновляется раньше и охотнее, и поскольку каждый читатель решает независимо, первый удачливый читатель пересчитывает в одиночку — задолго до того, как TTL дойдёт до нуля — и перезаписывает ключ, так что когда приходит стадо, значение свежее, и больше никто не промахивается. Коллизия растворена, потому что событие истечения больше не совпадает с пиком параллелизма.

Режимы отказа сеньора: лекарство, которое становится болезнью

Каждый фикс вносит собственную продакшн-опасность, и постмортемы по dogpile обычно про отказ фикса, а не исходного промаха.

Блокировка пересчёта может стать узким местом сериализации. Если пересчёт медленный, а ключ крайне горячий, теперь каждый читатель блокируется на одном держателе блокировки на всю длительность пересчёта — ты превратил шторм параллелизма в обрыв латентности, где p99 этого эндпоинта становится временем пересчёта для всех. Хуже — блокировка без таймаута: если единственный пересчётчик падает или зависает на середине, держа блокировку, каждый читатель блокируется навсегда — самонанесённый полный отказ, где база здорова и простаивает, но каждый запрос в deadlock-е, ожидая блокировку, которая никогда не освободится. Всегда ставь TTL блокировки (и делай его длиннее, чем худший случай пересчёта, иначе блокировка истечёт на середине пересчёта и ты снова откроешь dogpile). Джиттер TTL реален, но узок: он останавливает истечение пачки ключей, закэшированных вместе, в одну и ту же секунду, но ничего не делает для одного отдельного горячего ключа — у него по-прежнему ровно один момент истечения и собственное стадо, ждущее его. Тянуться к джиттеру, чтобы починить dogpile одного горячего ключа, — частая ошибка диагноза.

Выбери лучший вариант

Один горячий ключ (фид главной страницы, 200ms на пересчёт) кладёт БД каждый раз, когда его TTL истекает под ~5000 параллельных req/s. Выбери основной фикс.

Викторина

Почему dogpile формируется, хотя истёк только один ключ?

Викторина

Ты добавляешь блокировку пересчёта, но пропускаешь таймаут блокировки. В чём риск уровня сеньора?

Расставь шаги по порядку

Расставь таймлайн dogpile на одном горячем ключе:

  1. 1 Горячий ключ отдаётся из кэша на тысячи req/s; origin простаивает
  2. 2 Таймер TTL доходит до нуля, и ключ истекает
  3. 3 Запрос #1 читает промах и начинает 200ms-пересчёт
  4. 4 Приходят запросы #2..N, все читают промах (значение ещё не записано) и каждый начинает свой пересчёт
  5. 5 N параллельных одинаковых запросов бьют по origin разом; пул исчерпывается, p99 взрывается
Вспомните перед уходом
  1. 01
    Объясни коллеге, почему dogpile — это именно проблема параллелизма в момент истечения, а не просто «кэш промахнулся», и почему один горячий ключ — худший случай.
  2. 02
    Джуниор добавляет блокировку пересчёта, и следующий dogpile хуже, а не лучше. Какими двумя способами сама блокировка стала отказом?
Итог

Dogpile-эффект — это коллизия параллелизма в точный момент истечения одного горячего ключа: каждый запрос в полёте читает промах в одном и том же окне — до того как первый пересчёт записал значение обратно — и все они штурмуют origin параллельно. Опасность мультипликативна, а не в стоимости самого запроса: одна дорогая операция умножить на N параллельных запросов — это расплавление, и 200ms-запрос при 5000 req/s становится 5000 одновременными соединениями, которые исчерпывают пул в 20–100 соединений и кладут вместе с собой здоровый несвязанный трафик. Три фикса бьют по нему с разных сторон. Single-flight-блокировка сворачивает N параллельных промахов в один пересчёт, пока остальные ждут или отдают stale — но локальный mutex коалесцирует только в одном процессе (используй распределённую блокировку между инстансами), а блокировка без таймаута блокирует каждого читателя, если пересчётчик упал. XFetch (вероятностное раннее истечение, VLDB 2015) делает так, что горячий ключ фактически никогда не истекает под нагрузкой: каждый читатель независимо бросает кубик на ранний пересчёт, так что первый удачливый читатель обновляет в одиночку до прихода стада. Джиттер TTL разводит пачку ключей, закэшированных вместе, но ничего не делает для собственного стада одного отдельного горячего ключа. Суждение сеньора — подобрать фикс под форму проблемы и помнить, что большинство постмортемов по dogpile про deadlock или сериализацию фикса, а не про исходный промах.

Продолжить восхождение ↑Dogpile: тест с множественным выбором
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.