open atlas
↑ К треку
Next.js с нуля до senior NEXT · 09 · 03

Cache-теги по доменам: таксономия инвалидации, проводка вебхуков и шторм переинвалидации

revalidateTag силён настолько, насколько продумана таксономия: теги сущностей и коллекций, префиксы по bounded context. Один глобальный тег делает каждую правку CMS штормом перегенерации сайта; пропущенный тег коллекции держит старую цену часами. Маппинг вебхуков в теги — код.

NEXT Senior ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

Миграция витрины уезжает в прод с одним кеш-правилом, написанным второпях: каждый fetch несёт тег cms — «чтобы никогда не отдавать протухшее». Вебхук из CMS зовёт revalidateTag('cms') на любую правку. На демо работает. Через три недели редактор в 11:40 чинит опечатку в футере, и дашборд краснеет: cache hit ratio падает с 96 процентов до 4, каждая страница сайта перегенерируется разом, API CMS упирается в лимит 50 rps и начинает отдавать 429, p99 TTFB ползёт к восьми секундам — из-за опечатки. Редакторы делают около шестидесяти правок в день, так что это не инцидент — это новая погода. Соседняя команда сделала противоположную ставку: только мелкозернистые пер-продуктовые теги. Их импорт цен мгновенно обновлял страницы товаров — пока листинги категорий ещё шесть часов показывали старую цену, пока не истёк временной revalidate. У обеих команд был рабочий код и сломанный язык: никто не спроектировал, что значат теги.

Механика revalidateTag: инвалидация по смыслу, а не по URL

Прежде чем писать первый вызов revalidateTag (функция Next.js, немедленно помечающая кеш-записи протухшими), спросите: имя тега говорит, что именно изменилось, или только то, что что-то изменилось? Ответ определяет, стоит ли вам правка в одну страницу или весь сайт.

Тег прикрепляется при записи кеш-записи — fetch(url, { next: { tags: ['catalog:product:42'] } }) — а revalidateTag('catalog:product:42') помечает протухшей каждую запись с этим тегом, где бы она ни использовалась: страница товара, поисковая выдача, карусель на главной. В этом превосходство над revalidatePath: вы инвалидируете по доменному смыслу, а не перечислением URL, где данные могли встроиться. Семантику стоит проговорить точно. revalidateTag ничего не перегенерирует охотно — он помечает записи протухшими, и следующий запрос к затронутой странице перезапрашивает и перестраивает её (с теми характеристиками давки, что вы встречали в уроке про ISR (Incremental Static Regeneration — инкрементная статическая регенерация): много конкурентных промахов бьют в origin вместе). Вызванный внутри Server Action, он вдобавок обновляет роутерное представление клиента по завершении; вызванный из route handler — случай вебхука — оставляет страницам обновиться при следующем визите. Ещё одна граница: теги ездят на Data Cache, то есть применяются к записям fetch и к функциям, которые вы явно кешировали с тегами (unstable_cache сегодня), — сырой ORM-запрос без кеширующей обёртки невидим всему механизму.

// lib/data/products.ts
export async function getProduct(id: string) {
  const res = await fetch(`${CMS_URL}/products/${id}`, {
    next: { tags: [`catalog:product:${id}`, 'catalog:products'] },
  });
  return res.json();
}

Таксономия: сущность, коллекция, bounded context (ограниченный контекст — граница команды или домена)

Большинство инцидентов с инвалидацией восходят к одному пропущенному решению: никто не спросил «когда X меняется, какие поверхности обязаны обновиться?» — до того, как написал первый тег. Эти три вида дают словарь для ответа.

Три вида тегов покрывают почти всё. Тег сущностиcatalog:product:42 — именует правду одной строки; он положен каждому fetch, возвращающему данные этой сущности. Тег коллекцииcatalog:products — именует членство и порядок: его несёт любой fetch, чей результат изменится при создании, удалении или переранжировании товара. И композитные страницы несут несколько: главная может держать catalog:products, cms:page:home и pricing:campaign:summer. Префикс — не украшение, а bounded context, и соглашение об именах — это архитектура: catalog:, cart:, cms:, pricing: зеркалят владение командами, исключают коллизии и заставляют grep отвечать на вопрос «кто что может чистить». Проектный тест для любого тега — один вопрос: когда X меняется, какие именно поверхности обязаны перерендериться? Не можете ответить — тег ещё не спроектирован.

Два режима отказа симметричны. Переинвалидация — глобальный тег cms из Hook — означает, что каждая правка чистит всё: hit ratio рушится, штормы перегенерации колотят источники ровно тогда, когда есть трафик, а лимитируемые upstream превращают чистку в аварию. Недоинвалидация — только теги сущностей — означает баги корректности с бизнес-последствиями: цена изменилась, страница товара это говорит, а листинг категории часами цитирует старое число. Неверные цены весят возвратами и юристами, а не только эстетикой. Рабочая эвристика: детальные поверхности получают тег сущности, каждый fetch в форме списка — тег коллекции, а пути записи, меняющие значимые для членства поля, обязаны излучать оба.

Викторина

Каждый fetch на сайте несёт единственный тег cms, и вебхук CMS зовёт revalidateTag('cms') на любую правку. Редактор чинит опечатку в футере. Что произойдёт на самом деле?

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

Расставьте шаги проектирования таксономии cache-тегов для нового bounded context — до написания каких-либо fetch()-вызовов:

  1. 1 Составить карту каждого события записи к поверхностям, которые обязаны измениться: страницы сущностей, листинги коллекций, композитные страницы — отдельно
  2. 2 Выбрать префикс bounded context (например, 'catalog:'), чтобы теги разных команд никогда не пересекались и владение было видно через grep
  3. 3 Определить теги сущностей (один на строку, напр. 'catalog:product:42') и теги коллекций (один на операцию, меняющую членство, напр. 'catalog:products')
  4. 4 Навесить правильный набор тегов на каждый fetch() или вызов unstable_cache в слое данных
  5. 5 Написать TAG_MAP в webhook-обработчике: тип события отображается в минимальный набор тегов — проверить контрольным утверждением, что несвязанная страница остаётся HIT

Вебхуки → теги: таблица маппинга — это код

CMS не говорит тегами; она говорит событиями. Перевод живёт в одном route handler, и записать его буквальной таблицей маппинга — разница между политикой инвалидации, которую можно ревьюить, и той, которую реверс-инжинирят во время инцидентов. Два железных правила вокруг: проверяйте подпись вебхука — неаутентифицированный revalidate-эндпоинт это бесплатный рычаг отказа в обслуживании, один curl-цикл до холодного кеша — и делайте неизвестные типы событий громкими (лог и алерт), а не молча роняйте или, хуже, откатывайтесь к глобальной чистке.

// app/api/webhooks/cms/route.ts
import { revalidateTag } from 'next/cache';
import { NextResponse } from 'next/server';
import { verifySignature } from '~/lib/webhooks';

const TAG_MAP: Record<string, (p: Record<string, string>) => string[]> = {
  'product.updated': (p) => [`catalog:product:${p.id}`],
  'product.priced':  (p) => [`catalog:product:${p.id}`, 'catalog:products'],
  'product.created': ()  => ['catalog:products'],
  'page.published':  (p) => [`cms:page:${p.slug}`],
};

export async function POST(req: Request) {
  const event = await verifySignature(req); // 401 при провале — чистка кеша это привилегия
  const toTags = TAG_MAP[event.type];
  if (!toTags) return NextResponse.json({ error: 'unmapped event' }, { status: 422 });
  const tags = toTags(event.payload);
  tags.forEach((t) => revalidateTag(t));
  return NextResponse.json({ revalidated: tags });
}

Читайте таблицу как политику: правка контента трогает одну сущность; смена цены — сущность и коллекции, показывающие цены; создание — только коллекции (страница сущности ещё не кеширована). Когда ночной импорт цен обновляет 2 000 товаров, маппинг излучает 2 000 тегов сущностей плюс один тег коллекции — детальные страницы обновляются лениво по мере визитов, листинги — один раз. Это форма без шторма.

Новая модель — и как тестировать ту, что есть

Существует новое направление, заслуживающее честного ярлыка: директива 'use cache' с cacheTag() и cacheLife() позволяет любой функции или компоненту стать кешируемой, тегируемой единицей — включая запросы к базе, без fetch. На сегодня она живёт за экспериментальными флагами в canary-сборках; выучите её форму, но стройте на стабильном пути — теги fetch плюс unstable_cache для не-fetch-чтений — и изолируйте тегирование внутри слоя данных, чтобы будущая миграция была заменой обёртки, а не переписыванием.

Каким бы ни был механизм, инвалидация тестируема, и тест — это staging-чеклист с обоими утверждениями: после фикстурного вебхука product.priced страница товара ОБЯЗАНА измениться, листинг категории ОБЯЗАН измениться — а страница about ОБЯЗАНА НЕ измениться (смотрите, как заголовок cache-статуса переключается в MISS/STALE на первых двух и остаётся HIT на третьей). Контроль «обязана не измениться» — то утверждение, которое команды забывают, и единственный автоматический детектор переинвалидации: графики hit ratio расскажут через неделю; контрольная страница — в CI.

Викторина

Ночной импорт цен шлёт события product.updated, замапленные только на теги сущностей. Наутро страницы товаров показывают новые цены, а листинги категорий часами цитируют вчерашние числа. В чём настоящий дефект?

Вспомните перед уходом
  1. 01
    Назовите два режима отказа инвалидации и эвристику, избегающую обоих.
  2. 02
    Что именно происходит при срабатывании revalidateTag и чего он не делает никогда?
Итог

Cache-теги — словарь, которым система говорит, что изменилось, и обе команды витрины провалились, пропустив проектирование словаря: один глобальный тег превратил каждую опечатку в футере в шторм чистки всего сайта против лимитируемой CMS, а только сущностные теги оставили листинги категорий цитировать вчерашние цены часами. Механика невелика: теги прикрепляются при записи в Data Cache, revalidateTag немедленно метит протухшей каждую несущую запись, перегенерация происходит по следующему спросу на страницу — вместе с давками, — Server Action дополнительно обновляет вызвавшего клиента, а route handler оставляет обновление следующему визиту. Сырые ORM-чтения вне Data Cache невидимы всему этому — поэтому тегирование живёт в слое данных. Рабочая таксономия держится на трёх видах: теги сущностей для правды одной строки, теги коллекций для членства и порядка, композитные страницы с несколькими тегами — всё в неймспейсах bounded context, catalog: и cart: и cms:, чтобы владение, защита от коллизий и grep-аемость происходили из самого соглашения об именах. Вебхук-хендлер — место, где события становятся тегами, и буквальная таблица маппинга делает политику инвалидации ревьюируемой: ценовые события излучают сущность плюс коллекцию, создания — только коллекцию, неизвестные события падают громко, а проверка подписи не даёт рычагу чистки стать публичным DoS-эндпоинтом. Направление use cache и cacheTag со временем сделает тегируемой любую функцию, но оно экспериментально — стройте на тегах fetch и unstable_cache, изолированных за слоем данных. И тестируйте инвалидацию как поведение: стреляйте фикстурным вебхуком в staging, утверждайте страницы, которые обязаны измениться, и — забытая половина — контрольную страницу, которая обязана не измениться. Теперь, когда ревьюите вебхук-хендлер или новый fetch, сверяйтесь с таксономией: тег сущности для строки, тег коллекции для каждого списка, что её отражает, и контрольная страница в staging-чеклисте, которая обязана остаться HIT.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.