proactive cache refresh, single-flight mutex, probabilistic XFetch
Refresh-ahead нужен для горячих cache keys, у которых обычный cache-aside создает периодические провалы. Cache-aside прост: если ключ есть в cache, вернуть его; если нет, сходить в DB и положить в cache. Но у популярного ключа TTL истекает для всех одновременно. Тысяча клиентов приходит на один и тот же miss, все пытаются восстановить значение, база получает thundering herd, latency растет, клиенты начинают retry. Это называется cache stampede.
Refresh-ahead меняет момент обновления: система не ждет полного expiration, а заранее запускает background refresh, пока старое значение еще можно отдавать клиентам. Пользователь видит cache hit, база получает один контролируемый запрос, TTL сбрасывается до того, как произойдет массовый miss. Связанные темы: cache-aside, write-through-write-behind, load-shedding, rate-limiting, single-flight, cdn-edge-network.
Представьте витрину с самым популярным товаром. Плохой процесс ждет, пока полка полностью опустеет, а затем все покупатели одновременно требуют пополнения со склада. Refresh-ahead похож на сотрудника, который видит, что товара осталось на 20% времени/запаса, и заранее приносит новую партию. Покупатели продолжают брать товар с полки, склад не получает лавину срочных заявок.
Важно: refresh-ahead не значит «обновлять все подряд». Это оптимизация для hot keys с предсказуемым чтением. Для cold keys она только добавит лишнюю нагрузку на базу. Для слишком часто меняющихся данных она может возвращать устаревшее значение чаще, чем бизнес готов принять.
Диаграмма показывает 1000 клиентов, Redis с TTL 60s и Postgres. В стандартном cache-aside сценарии все хорошо, пока ключ находится в Redis. Затем TTL истекает, все клиенты получают miss, и Redis/приложение отправляет множество запросов в Postgres. База перегружается, появляются timeouts и cascading retry.
Следующий сценарий показывает single-flight mutex: первый miss захватывает lock и один раз идет в DB, остальные ждут. Это уже лучше, потому что база получает один запрос вместо тысячи, но пользователи все равно ждут завершения fetch. Refresh-ahead показывает другой режим: при чтении стареющего значения cache сразу возвращает текущие данные, а refresh запускает в фоне. Probabilistic XFetch добавляет случайность, чтобы не каждый клиент запускал refresh при достижении порога.
stampede-cache-aside учит, что TTL является скрытым scheduler'ом нагрузки. Если популярный ключ истекает ровно через 60 секунд, все читатели синхронизируются на один и тот же miss. ADR-вывод: для hot keys недостаточно поставить TTL; нужна защита от stampede через jitter, single-flight, refresh-ahead или stale-while-revalidate.
mutex-fix показывает single-flight: lock на key, первый fetch заполняет cache, остальные ожидают. ADR-вывод: single-flight обязателен как baseline для дорогих miss, но он переносит цену в latency ожидающих клиентов. Если p99 критичен, стоит рассмотреть refresh-ahead или stale serving.
refresh-ahead-async показывает proactive update при age больше 0.8 TTL. Клиент получает hit мгновенно, а cache в фоне идет в DB и обновляет значение. ADR-вывод: refresh-ahead подходит, когда слегка устаревшее значение лучше, чем miss, а источник данных способен выдержать сглаженные фоновые чтения.
probabilistic-xfetch показывает рандомизированный trigger. Вместо того чтобы все клиенты при age 0.85 TTL начали refresh, каждый делает это с вероятностью, зависящей от возраста, TTL и beta. ADR-вывод: probabilistic refresh снижает синхронизацию и распределяет нагрузку без централизованного scheduler'а.
Первый trade-off: свежесть против latency. Refresh-ahead иногда отдает значение, которое уже немного старое, зато сохраняет низкую latency и защищает базу. Для feed, recommendations, counters и catalog snippets это часто нормально. Для баланса счета, статуса платежа или access decision это может быть недопустимо.
Второй trade-off: меньший stampede против лишней фоновой нагрузки. Если включить refresh-ahead для всех keys, система начнет читать из DB даже то, что больше никто не спросит. ADR-подход: применять только для hot keys по метрикам частоты чтения, стоимости miss и допустимой stale window.
Третий trade-off: deterministic threshold против probabilistic. Простое правило age > 0.8 * TTL легко объяснить, но при большом трафике многие клиенты одновременно пересекут порог. Probabilistic XFetch сложнее, требует настройки beta и наблюдения, но лучше распределяет refresh во времени.
Четвертый trade-off: background refresh против consistency. Если refresh идет в фоне и падает, cache может продолжить отдавать старое значение. Нужна политика: stale-if-error, hard expiry, max stale age, alerts. Без hard cap stale данные могут жить слишком долго.
Caffeine в JVM поддерживает refreshAfterWrite: чтение старого значения может вернуть текущий cache value и асинхронно обновить его. Hazelcast имеет refresh-ahead loader для распределенного cache. Apache Ignite поддерживает preload/refresh-подходы в data grid сценариях. CDN и HTTP-кэши часто используют похожие идеи через stale-while-revalidate и stale-if-error. В приложениях похожий паттерн реализуют через Redis + distributed lock + background worker или через локальный in-memory cache с async reload.
В больших системах refresh-ahead часто комбинируется с jittered TTL, request coalescing, hot-key detection и rate limit на refresh. Например, homepage feed или top-N leaderboard можно обновлять заранее, а редкий profile key оставить обычному cache-aside.
Главная ошибка: refresh-ahead на все ключи. Это превращает cache из защиты базы в генератор лишних запросов. Вторая ошибка: отсутствие single-flight на refresh. Если 1000 клиентов одновременно решили обновлять, вы получили stampede, только до TTL. Третья ошибка: нет max stale age. Если DB деградирует, cache может бесконечно отдавать старую версию, скрывая incident.
Опасно также не добавлять TTL jitter. Даже с refresh-ahead часть ключей может синхронизироваться по времени, особенно после deploy или массового warmup. Еще одна ошибка: не разделять foreground miss и background refresh в метриках. Без отдельных dashboards непонятно, снижаете ли вы p99 или просто перенесли нагрузку.
Не используйте refresh-ahead для cold keys и long tail данных. Для них вероятность повторного чтения мала, поэтому background refresh почти всегда waste. Не используйте его для strongly consistent операций: платежный баланс, права доступа, inventory decrement, одноразовые токены. Не используйте refresh-ahead, если источник данных уже перегружен постоянными чтениями; сначала ограничьте refresh rate и проверьте capacity.
Если данные меняются чаще, чем TTL, refresh-ahead может постоянно приносить устаревшие snapshots. В таких случаях лучше event-driven invalidation, write-through/write-behind, CDC или короткий TTL с single-flight. Если miss дешевый и редкий, обычный cache-aside проще и надежнее.
В CloudArch дальше полезны cache-aside, write-through-write-behind, cdn-edge-network, load-shedding, rate-limiting, priority-queue, database-replication. Из внешних источников: Caffeine refreshAfterWrite, Hazelcast refresh-ahead, статья Vattani et al. про Optimal Probabilistic Cache Stampede Prevention, HTTP stale-while-revalidate, материалы по request coalescing и cache stampede prevention.