Симптом обычно виден не в кеше, а на странице: источник уже хранит новый заголовок, а читатель получает старую карточку. Цена ошибки зависит от проекции. Можно показать устаревший статус публикации, оставить доступным снятый материал или скрыть уже разрешённое изменение. Увеличить TTL либо удалить случайный key после жалобы — это не исправление: при следующей записи тот же читатель снова увидит не ту версию.
Для февраля 2021 я бы начал не с выбора Redis или CDN, а с короткого контракта. Нужно назвать владельца исходной записи, ключ именно читаемой проекции, событие изменения и факт, по которому read вправе вернуть значение. Главный инвариант здесь не «кеш быстрый», а «читатель получает только данные, которые вправе видеть в текущем состоянии источника». Ниже все значения синтетические и живут в Map; они объясняют порядок, но не изображают работающую инфраструктуру.
Сначала определяем границу чтения
Кеширует не таблица и не объект целиком, а конкретный ответ на конкретный вопрос. В упражнении таким вопросом будет «что может увидеть публичный читатель у article guide-42?». Исходная запись принадлежит одному source owner. У неё есть visibility, title, внутренняя заметка редактора и монотонная version. Публичная проекция содержит только id, title, version и область читателя. Внутренняя заметка не должна попасть в неё даже при cache hit.
| Часть | Владелец или значение | Почему нужна | Проверка |
|---|---|---|---|
| Источник | article:guide-42 у учебного source owner | только он создаёт следующую версию | после write version растёт с 1 до 2 |
| Read key | article:public:guide-42 | key отделяет public projection от другой области чтения | reader scope явно виден в key |
| Проекция | id, title, sourceVersion | read не переносит редакторское поле по привычке | в object нет editorNote |
| Событие | ArticleChanged с id и sourceVersion | сообщает, для какой версии прежняя entry стала подозрительной | event v2 сравнивается с cached v1 |
| Критерий hit | cached version равна source version | TTL не маскирует уже известную новую запись | иначе read rebuild-ит projection |
У HTTP есть похожая, но не идентичная граница. RFC 7234 описывает cache entry через key и reuse response для эквивалентного request; primary key там связан с методом и target URI, а при content negotiation появляются дополнительные selecting headers. Это полезная дисциплина: один URL без языка, прав или представления часто недостаточен. Но RFC не даёт generic function для прикладной памяти. Поэтому ниже key — часть учебного read contract, а не притворный универсальный API для любого cache store.
Key обязан включать право увидеть проекцию
Плохой key выглядит удобно: article:guide-42. Он быстро строится, но ничего не говорит, какое представление там лежит. Если одна ветка кода записала публичную карточку, а другая позднее ожидает редакторскую, collision уже создан. Нельзя исправить это договорённостью «у нас такой key только для public»: она не проверяется на чтении. Лучше назвать область прямо и строить projection whitelist отдельно от source record.
function publicProjectionKey(id) {
return 'article:public:' + id;
}
// readerScope встроен в key, а editorNote не входит в projection.
const key = publicProjectionKey('guide-42');
// article:public:guide-42
Такой фрагмент не решает authorization. Он только делает её границу видимой там, где рождается cache entry. Реальный проект может иметь tenant, язык, role, feature state или digest query. Их нельзя бездумно дописать в строку и считать задачу закрытой: каждое поле должно влиять на то, что читатель вправе увидеть. Если поле не влияет на проекцию, оно дробит cache и скрывает диагностику. Если влияет, но отсутствует, разные читатели получают одну запись по ошибке.
Запись создаёт версию и повод для invalidation
После изменения source owner должен оставить два связанных факта. Первый — сама запись с новой version. Второй — событие, которое указывает на изменившийся объект и ту же version. Нельзя выпускать event «очистить всё» без владельца и версии: оно не объясняет, какой cache key должен исчезнуть и как поздний потребитель отличит старое сообщение от нового. В нашем контракте write создаёт ArticleChanged, но не делает вид, что это уже доставка через broker.
const record = {
id: 'guide-42',
title: 'Кеш: версия два',
visibility: 'public',
version: previous.version + 1,
};
const event = {
type: 'ArticleChanged',
id: record.id,
sourceVersion: record.version,
cacheKey: publicProjectionKey(record.id),
};
Факт успешной записи важнее намерения. RFC 7234 для HTTP-кеша связывает invalidation с неошибочным ответом на unsafe request и отдельно предупреждает, что это не гарантирует очистку всех подходящих ответов в других кешах. Этот предел полезно перенести в разговор о приложении: событие может существовать, а конкретная entry ещё оставаться в другом слое. Поэтому «мы отправили event» не равно «читатель уже не увидит старое». Нужны ключ, версия и наблюдаемая проверка на read-path.
Событие ускоряет очистку, но read всё равно проверяет версию
Счастливый путь короткий: cache entry v1 существует; source записывает v2; invalidation v2 находит entry v1 и удаляет её; следующий read строит v2. Но на практике опаснее промежуток между двумя шагами. Source уже v2, а event пока не применён. Если read доверяет только наличию key или TTL, он вернёт v1. В учебной модели read сравнивает entry.sourceVersion с record.version. Несовпадение не считается hit: entry пересобирается до ответа.
Это не обещание строгой согласованности любой распределённой системы. Модель смотрит на source synchronously, поэтому может сравнить две версии в одной памяти. Реальный cache store, broker и source storage могут иметь другие границы, задержки и подтверждения. Но контракт полезен уже сейчас: он явно говорит, что cache hit разрешён только при совпадении известной версии и области читателя. Где нельзя получить version source на read, нужно честно выбрать другой механизм и отдельно описать его окно stale.
Проверяем модель до интеграции
Все идентификаторы, заголовки, версии, события и результаты ниже учебные. Модель работает только с Map в памяти: она не подключает Redis, CDN, broker, framework, HTTP-клиент или production traffic.
# Запускается только модель Map из revision-модуля.
node scripts/upgrade-2021-02.mjs --verify-fixture
# Ожидаемые истинные assertions:
staleReadRebuiltVersionTwo: true
delayedEventDidNotEvictCurrentVersion: true
privateSourceIsNotVisibleBeforeEvent: true
Fixture проходит один устойчивый stale/read сценарий. Сначала source v1 строит cache entry v1. Затем source меняется на v2, но event v2 намеренно ещё не применяется. Read видит entry v1 и source v2, возвращает stale-rebuilt с публичной проекцией v2. Когда запоздавшее event приходит позже, оно не удаляет уже current entry v2. В конце source делает запись private v3: public read обязан отказаться от выдачи и удалить предыдущую public entry ещё до delivery события.
Нумерованный маршрут для одного контракта
- Назвать один читательский вопрос и цену stale ответа. Не начинать с общего «почистим кеш».
- Назначить source owner: именно он создаёт следующую version и определяет visibility записи.
- Собрать key из объекта и тех условий, которые меняют право увидеть проекцию. Для public карточки сохранить область
publicв key. - Сделать projection whitelist. Проверить, что внутренние поля не попадают в value даже на cache hit.
- После успешного write сформировать event с id, version и key или детерминированным способом его построить. Не выдавать локальный object за broker delivery.
- На read сравнить cached version с текущей source version. При несовпадении rebuild-ить либо выбрать документированный другой путь, а не вернуть stale как hit.
- Добавить два отрицательных случая: задержанное event и изменение visibility. Оба должны дать безопасный результат для читателя.
Граница этого практического рецепта
В этой статье нет настоящего cache hit-rate, CDN, Redis, очереди, HTTP response или данных пользователя. RFC 7234 и RFC 7232 объясняют HTTP semantics, но не говорят, как конкретный framework хранит объект в памяти. TTL тоже не запрещён: он ограничивает жизнь entry и полезен как дополнительный предел. Он не заменяет contract изменения, если source уже знает новую version. Реальную policy надо связывать с выбранным storage, нагрузкой, правами и допустимым окном stale, а не переносить этот учебный код в production без проверки.
Ожидаемый результат после такого разбора скромный и проверяемый: у одной проекции есть владелец, key, version, event и условие current hit. Если любой из пяти пунктов нельзя назвать, инвалидация пока является надеждой на срок жизни записи. Начните с одного пути чтения, запустите fixture и только затем добавляйте интеграционный test на разрешённом cache store или HTTP-маршруте.
Проверяемые источники
- RFC 7234 — HTTP/1.1 Caching, июнь 2014 — исторический стандарт, действовавший в феврале 2021 года: задаёт ключи, reuse, validation и invalidation HTTP-ответов; не является API прикладного cache store
- RFC 7232 — HTTP/1.1 Conditional Requests, июнь 2014 — описывает entity-tag, If-None-Match и 304 как HTTP-механизм проверки представления; эти поля не заменяют версию прикладной записи