ISR и слои CDN: stale-while-revalidate на origin, кеши по подам и порядок purge
revalidate отдаёт устаревшее, регенерирует в фоне и атомарно подменяет — на каждом поде отдельно. При нескольких подах дисковый кеш расходится: нужен общий cacheHandler и pub/sub-разлёт для revalidatePath. Поверх — CDN: порядок строгий, сначала регенерация, потом purge.
Пятница, 16:40: CMS публикует новую цену, вебхук вызывает revalidatePath('/pricing'), админ видит свежее число, все расходятся по домам. К 17:10 у поддержки девять тикетов с одинаковой парой скриншотов: страница цен показывает старую цену — refresh — новую — refresh — снова старую. Сайт self-hosted на трёх подах за round-robin ALB. revalidatePath выполнился на том единственном поде, которому достался вебхук; два других продолжают раздавать собственные дисковые кеши, а балансировщик сдаёт пользователям новый под на каждый запрос. Хуже того: страница только on-demand — без временно́го revalidate, — так что поды A и C не исцелятся никогда: они будут отдавать устаревшую цену, пока следующий деплой не заменит их файловые системы. Понедельник начинается с эскалации в юротдел: рекламируемая цена и списанная цена провели выходные в разногласиях — с частотой ровно два запроса из трёх.
ISR — это stale-while-revalidate на origin
revalidate: 60 не значит «регенерировать каждые 60 секунд» и не значит «пользователи ждут свежий HTML». Это stale-while-revalidate (SWR — «отдай устаревшее, ревалидируй в фоне»), реализованный на origin: каждая закешированная запись несёт таймстемп, и первый запрос после истечения окна немедленно получает устаревшую страницу — единицы миллисекунд из кеша, — пока в фоне стартует ровно одна регенерация. Когда рендер завершается, новая запись подменяется атомарно: ни один запрос не видит полузаписанную страницу. Если регенерация падает — подмена просто не происходит: последняя удачная страница продолжает раздаваться, а следующий запрос после окна повторит попытку. Арифметика раздачи и делает ISR привлекательным: попадание в кеш — 1–5 мс IO, полная регенерация — сколько стоит рендер страницы, обычно 100–800 мс выборки данных и работы RSC, — и при ISR её никто не ждёт, кроме самой регенерации.
// app/pricing/page.tsx — по времени: максимум одна регенерация на окно в 60 с
export const revalidate = 60;
// или на каждый fetch, с тегами для on-demand-инвалидации:
const res = await fetch('https://cms.example.com/prices', {
next: { revalidate: 60, tags: ['pricing'] },
});
// app/api/cms-webhook/route.ts — on-demand: инвалидировать сейчас, а не по таймеру
import { revalidateTag } from 'next/cache';
export async function POST() {
revalidateTag('pricing'); // помечает записи устаревшими — ТОЛЬКО В ЭТОМ ПРОЦЕССЕ
return Response.json({ ok: true });
}Больше одного пода: кеш обязан быть общим
Всё сказанное выше верно на процесс. Дефолтное хранилище ISR — файловый кеш в .next/cache плюс in-memory LRU — локально для пода по обоим пунктам. Один под — одна истина. Три пода — три истины: каждый регенерирует по собственному расписанию, и страницы с временны́м revalidate расходятся на ширину окна — обычно терпимо. Нетерпимо другое — on-demand-ревалидация: revalidatePath и revalidateTag мутируют локальное хранилище того пода, на котором выполнились. Вебхук приземляется на под B, под B регенерирует, поды A и C об этом не услышат никогда — и round-robin-балансировщик превращает это во флип-флоп из вступления: свежее и устаревшее чередуются на каждый запрос. Без временно́го фолбэка устаревшие поды не сойдутся вовсе.
Два продакшен-уровневых лечения. Структурное: заменить хранилище на общий cacheHandler — next.config.js принимает cacheHandler: require.resolve('./cache-handler.mjs') плюс cacheMaxMemorySize: 0, отключающий per-pod LRU, а ваш обработчик реализует get/set/revalidateTag поверх Redis или другого общего стора. Истина снова одна; цена — ~1–3 мс RTT до Redis на каждом попадании ISR вместо субмиллисекундного локального диска — стоимость консистентности. Альтернатива оставляет локальные кеши, но разносит инвалидацию веером: вебхук публикует в Redis pub/sub (publish/subscribe — модель «публикатор/подписчик»), каждый под подписан и вызывает revalidateTag локально. Веер сохраняет скорость локального диска, но возвращает задачу распределённых систем: под, который перезапускался в момент публикации, её пропустил — и ничто его не примирит. Проще говоря: общий стор гарантирует корректность по конструкции, веер — даёт скорость ценой пробела, который нужно мониторить. Общий стор — скучный правильный дефолт; веер — оптимизация, которую берут вместе с мониторингом.
Три пода, дефолтный кеш ISR, вебхук CMS вызывает revalidatePath на том поде, куда попал. Временно́го revalidate у страницы нет. Что реально видят пользователи?
Слой CDN сверху — и порядок purge
Перед подами стоит CDN, и ISR-страницы сами ему представляются: роут с revalidate: 300 уезжает с Cache-Control: s-maxage=300, stale-while-revalidate — CDN может кешировать его пять минут и отдавать устаревшее, пока перезапрашивает. Здоровый контентный сайт держит 80–98% попаданий в CDN, то есть поды видят только ручеёк промахов; CDN танцует тот же stale-while-revalidate этажом выше — против вашего origin вместо вашей функции рендера. Два кеша, один протокол, стопкой.
У стопки кешей есть теорема порядка, и её нарушение — второй классический инцидент: сначала регенерировать origin, потом чистить CDN. Запустите наоборот — и пройдитесь по таймлайну: purge завершился, CDN пуст, каждый запрос страницы — промах в ваш origin — лавина промахов, — а origin, который ещё не регенерировал, послушно отдаёт старую страницу по семантике SWR. CDN снова кеширует эту старую страницу, проштампованную свежим s-maxage=300, и ваш «срочный фикс» закреплён ещё на целое окно — вы почистили кеш, и устаревший контент вернулся как ни в чём не бывало. Правильная последовательность: дёрнуть revalidateTag/revalidatePath на origin (дойдя до каждого пода, см. предыдущий раздел), убедиться, что origin отдаёт свежее, и только потом чистить CDN — чтобы промахи вытянули новую страницу. Лавина всё равно случится — столько стоит purge, — но вытянет правильные байты.
▸Почему это работает
Почему оба слоя сходятся на stale-while-revalidate, а не на «отдай свежее или жди»? Потому что альтернатива связывает latency пользователя со стоимостью регенерации. Если истечение значит, что следующий пользователь ждёт рендер в 600 мс — или поход в origin, — каждое истечение кеша становится видимым всплеском latency, а истёкшая популярная страница — давкой конкурентных регенераций. SWR расцепляет: читатели всегда получают ответ со скоростью кеша, писатель регенерирует ровно один раз в фоне, и система меняет ограниченное окно устаревания на плоскую latency. Вся стопка — браузер, CDN, ISR — одна и та же ставка, сделанная трижды.
В проде неправильная цена. Ops сначала чистит CDN, затем дёргает on-demand-ревалидацию на origin. Что происходит в зазоре между ними?
- 01Опишите точную последовательность раздачи ISR при истечении и что происходит при падении регенерации. Почему этот дизайн называют SWR на origin?
- 02Почему revalidatePath ломается на трёх self-hosted подах, какие два лечения и сколько стоит каждое?
ISR в проде — это stale-while-revalidate, реализованный на origin: записи несут таймстемпы, первый запрос после истечения получает устаревшую страницу за 1-5 мс, пока в фоне идёт ровно одна регенерация, готовый рендер подменяется атомарно, а упавший не меняет ничего — последнее удачное продолжает раздаваться, следующее окно повторяет попытку. Читатели никогда не платят 100-800 мс регенерации; в этом весь смысл. Всё это — на процесс, и здесь self-hosting кусается: дефолтное хранилище — .next/cache на локальном диске пода плюс его LRU, так что три пода — три расходящиеся истины. Страницы с временны́м revalidate дрейфуют на ширину окна и самоисцеляются; on-demand revalidatePath и revalidateTag мутируют только под, на котором выполнились, поэтому вебхук CMS на поде B оставляет A и C перманентно устаревшими на on-demand-страницах — а round-robin-балансировщик превращает это в ценовой флип-флоп на каждый запрос. Структурное лечение — общий cacheHandler, подключённый в next.config с нулевым cacheMaxMemorySize, поверх Redis, платящий 1-3 мс за хит ради единственной истины; оптимизация — pub/sub-веер инвалидаций, сохраняющий скорость диска, но способный молча пропустить перезапускающийся под. Над подами CDN гоняет тот же SWR-протокол через заголовки s-maxage и stale-while-revalidate при 80-98% попаданий — и у стопки кешей одна теорема порядка: сначала регенерировать origin, убедиться в свежести, потом чистить CDN. Purge-первым отправляет лавину промахов в origin, всё ещё раздающий устаревшее, и перекеширует старую страницу под свежим s-maxage ещё на целое окно. Браузер, CDN, ISR — одна ставка, сделанная трижды, и она окупается, только если инвалидация доходит до каждого слоя и каждого пода. Теперь, когда встретишь вебхук CMS, вызывающий revalidatePath, — первый вопрос: на скольких подах это работает и доходит ли инвалидация до каждого из них?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.