Кеширование
Stale-while-revalidate: отдать устаревшее сразу, обновить в фоне
У API цен стоит max-age=60. p50 latency — 8мс, красота на дашборде. Но каждые 60 секунд запись протухает, и следующий запрос блокируется на 400мс round-trip к origin, чтобы её обновить. На тысячах req/s этот «следующий запрос» — сотни пользователей, каждую минуту, попадающие прямо в твой p99. График — пила: ровно 8мс, потом всплеск 400мс точно на каждой границе TTL. Команда потратила спринт на погоню за «медленным origin», который никогда не был медленным — он просто синхронно ревалидировался в горячем пути живого пользователя.
Пила — это цена синхронной ревалидации
У обычного TTL-кэширования есть одно жестокое свойство: в момент протухания записи кто-то платит полную цену origin за её перезаполнение. С одним max-age этот кто-то — живой пользователь: невезучий запрос, пришедший первым после протухания, блокируется, пока origin не ответит. Твоя установившаяся latency — это время попадания в кэш; твоя tail latency — время промаха; а TTL гарантирует, что ты периодически сэмплируешь промах в реальный трафик. Чем выше частота запросов, тем больше пользователей попадает на каждую границу, тем толще твой p99.
Stale-while-revalidate разрывает связь. Когда запись пересекает окно свежести, но всё ещё внутри окна SWR, кэш делает две вещи разом: возвращает устаревшее значение мгновенно (пользователь получает latency попадания в кэш, ~8мс) и запускает асинхронное обновление к origin. Пользователь, который спровоцировал обновление, никогда его не ждёт. Свежее значение прилетает на несколько сотен миллисекунд позже и обслуживает следующие запросы. Ревалидация всё так же происходит — она просто перестала происходить на переднем плане.
Сделка, которую ты заключаешь, явная и ограниченная: ты соглашаешься, что часть ответов слегка устаревшая, в обмен на удаление пика p99. Устарелость ограничена окном SWR; latency ограничена временем попадания в кэш. Сеньор читает stale-while-revalidate=N как «меня устраивают данные до N секунд за дедлайном свежести, пока ни один человек не ждёт обновление».
RFC 5861: два окна, затем stale-if-error как пол
Это настоящая HTTP-директива, стандартизированная в RFC 5861, и она работает в CDN и в браузерах. Важны два расширения Cache-Control:
stale-while-revalidate=N— в течение N секунд после протухания записи отдавай её мгновенно и ревалидируй в фоне.stale-if-error=N— в течение N секунд после протухания, если origin вернул ошибку (500/502/503/504) или недоступен, продолжай отдавать устаревшую копию вместо проброса ошибки.
Боевой заголовок читается как эшелонированная защита:
Cache-Control: max-age=60, stale-while-revalidate=300, stale-if-error=86400Читай его как три вложенных окна. 0–60с: свежо, отдаётся напрямую. 60–360с: устарело, отдаётся мгновенно, пока в фоне идёт обновление (окно SWR). при сбое origin, до 24ч: всё ещё отдаётся из последней хорошей копии, а не бросает 503 в пользователя (пол SIE). Первое окно оптимизирует свежесть, второе убивает tail latency, третье превращает аварию в «деградировало, но живо».
| Стратегия | Кто платит round-trip к origin | Макс. устарелость | p99 на границе TTL |
|---|---|---|---|
только max-age=60 | Живой пользователь, каждые 60с | 0 (всегда свежо) | Всплеск до latency origin |
max-age=60, swr=300 | Фоновый фетч — без пользователя | До 60+300с | Ровно (нет промаха на переднем плане) |
SWR + stale-if-error | Фон; никто во время аварии | До окна SIE | Ровно, переживает падение origin |
Почему это работает
Имена — зеркальные. stale-while-revalidate — инструмент счастливого пути: origin в порядке, ты просто не хочешь, чтобы пользователи ждали обновление. stale-if-error — инструмент пути сбоя: origin болен, и устаревшее лучше, чем 503. Они независимы: можно выкатить один без другого, но их парность — дефолт сеньора, потому что один SWR всё равно отдаст пользователю ошибку, как только origin сломается и окно SWR истечёт.
Та же идея на слой выше: SWR/React Query на клиенте
HTTP-директива живёт в сети. Ровно тот же паттерн живёт в клиентских библиотеках загрузки данных — библиотека SWR буквально названа в его честь, а React Query (TanStack Query) реализует ту же модель. Когда компонент просит ключ, библиотека возвращает закэшированное значение из последнего фетча мгновенно (без спиннера, если ключ уже видела), затем запускает фоновый запрос и подменяет на свежие данные, когда они придут. Пользователь видит мгновенный контент, который тихо сам себя исправляет.
Клиентская модель добавляет два боевых поведения, которых у голого заголовка нет:
- Коалесинг запросов / single-flight. Если два компонента вызывают
useSWR("/api/user")в один тик, SWR делает один запрос, и оба получают один результат. ДефолтныйdedupingInterval— 2000мс: повторные вызовы того же ключа в пределах 2с схлопываются на in-flight промис. React Query делает то же; его аналогичныйstaleTimeпо умолчанию 0 (рефетч жадно) против 2с у SWR. - Триггеры ревалидации. Обе библиотеки по умолчанию ревалидируют при фокусе окна и при восстановлении сети, так что вкладка, оставленная открытой на час, показывает свежие данные в момент, когда ты в неё кликаешь — снова отдано stale-first, обновлено в фоне.
Будь то edge-нода CDN или React-хук, контракт идентичен: верни то, что есть сейчас, забери то, что верно, следующим, никогда не блокируй человека на обновлении.
API листинга товаров за CDN видит классическую поминутную пилу p99 от синхронной ревалидации. Данные каталога могут быть устаревшими на несколько минут без вреда. Выбери заголовок.
Где это кусается: стампиды, неограниченная устарелость и auth
SWR не свободен от режимов отказа — он их перемещает. Три кусаются в проде.
Стампид фоновых обновлений. Наивный edge запускает одну фоновую ревалидацию на каждый запрос, который нашёл запись устаревшей. Если популярный ключ протухает, пока он принимает 10k req/s, ты можешь запустить тысячи одновременных обновлений к origin, который ожидал одно, — самонанесённый thundering herd, только теперь это фоновые фетчи плавят origin, а не пользователи видят latency. Фикс — single-flight: первый устаревший запрос запускает ровно одно обновление (Cloudflare помечает его UPDATING), а всех остальных обслуживают устаревшим, пока оно не прилетит. Добавь jitter к таймингу обновления, чтобы много ключей не протухали и не ревалидировались синхронно.
Неограниченная устарелость, когда origin лежит. Это классическая ошибка stale-if-error наоборот. Если origin недоступен, фоновая ревалидация падает — и плохо построенный кэш может тогда продлевать устаревшую копию вечно, «кэш до тепловой смерти». Нужен жёсткий потолок: конечное окно SWR, конечное окно stale-if-error. За их пределами кэш обязан упасть громко, а не отдавать контент неизвестного возраста. Устарелость всегда должна быть ограниченной.
Отдача устаревших auth и прав. Вот этот — опасный. SWR корректен для контента, которому нормально быть на несколько минут старее — список товаров, пост в блоге, плитка аналитики. Он неверен для решений авторизации, фича-флагов, гейтящих доступ, или всего, где устаревшее = регрессия безопасности. Отдай отозванному пользователю устаревшее на 5 минут «у тебя всё ещё есть доступ» — и ты выкатил уязвимость. Правило сеньора: никогда не SWR-ить проверку прав. Персональные, чувствительные к безопасности ответы получают короткую, синхронную, валидированную свежесть — или не кэшируются вовсе.
С `Cache-Control: max-age=60, stale-while-revalidate=300` запрос приходит через 90 секунд после кэширования записи. Что получит пользователь?
Популярный ключ протухает под тяжёлым трафиком, и твой edge запускает одну фоновую ревалидацию на каждое устаревшее попадание. В чём боевой риск и фикс?
Расставь, что происходит с запросом, пришедшим во время окна stale-while-revalidate:
- 1 Кэш обнаруживает, что запись за пределами max-age, но всё ещё внутри окна SWR
- 2 Он возвращает устаревшее закэшированное значение пользователю мгновенно — без ожидания origin
- 3 Он запускает ровно одну фоновую ревалидацию к origin (single-flight)
- 4 Свежий ответ приходит и заменяет закэшированную запись вне полосы
- 5 Последующие запросы получают свежее значение (или снова устаревшее, если окно истекло)
- 01Объясни, почему один max-age порождает пилу p99 и как добавление stale-while-revalidate её выравнивает.
- 02Когда stale-while-revalidate — неверный инструмент, и от каких режимов отказа сеньор обязан защититься?
Stale-while-revalidate разделяет свежесть и задержку: вместо того чтобы давать живому пользователю платить round-trip к origin каждый раз, когда истекает TTL, кэш возвращает устаревшее значение мгновенно и ревалидирует в фоне, так что пила p99 синхронной ревалидации выравнивается. Это живёт на двух слоях — директива HTTP Cache-Control: stale-while-revalidate=N (RFC 5861, уважаемая CDN и браузерами) и клиентские библиотеки вроде SWR и React Query, которые возвращают последние известные данные мгновенно, коалесят дублирующие запросы через dedupingInterval и ревалидируют при фокусе и реконнекте. Сделка явная: ограниченная устарелость за стёртую tail latency. Сопрягай её с stale-if-error, чтобы авария origin деградировала мягко, а не бросала 503. Затем держи в уме три режима отказа: делай фоновое обновление single-flight, иначе горячий ключ запустит thundering herd; ограничивай устарелость конечными окнами, иначе лежащий origin будет вечно отдавать контент неизвестного возраста; и никогда не применяй SWR к auth, правам или всему, где устаревшие данные — это баг безопасности.