{"index":249,"slug":"editorial-2021-02-practice-cache-invalidation","title":"Инвалидация кеша: ключ, событие и проверка чтения","excerpt":"Учебный контракт для одного публичного представления: владелец записи, key с областью читателя, версия, событие изменения и read с защитой от stale entry.","contentHtml":"
Симптом обычно виден не в кеше, а на странице: источник уже хранит новый заголовок, а читатель получает старую карточку. Цена ошибки зависит от проекции. Можно показать устаревший статус публикации, оставить доступным снятый материал или скрыть уже разрешённое изменение. Увеличить TTL либо удалить случайный key после жалобы — это не исправление: при следующей записи тот же читатель снова увидит не ту версию.
\nДля февраля 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.
\nПлохой key выглядит удобно: article:guide-42. Он быстро строится, но ничего не говорит, какое представление там лежит. Если одна ветка кода записала публичную карточку, а другая позднее ожидает редакторскую, collision уже создан. Нельзя исправить это договорённостью «у нас такой key только для public»: она не проверяется на чтении. Лучше назвать область прямо и строить projection whitelist отдельно от source record.
function publicProjectionKey(id) {\n return 'article:public:' + id;\n}\n\n// readerScope встроен в key, а editorNote не входит в projection.\nconst key = publicProjectionKey('guide-42');\n// article:public:guide-42\nТакой фрагмент не решает authorization. Он только делает её границу видимой там, где рождается cache entry. Реальный проект может иметь tenant, язык, role, feature state или digest query. Их нельзя бездумно дописать в строку и считать задачу закрытой: каждое поле должно влиять на то, что читатель вправе увидеть. Если поле не влияет на проекцию, оно дробит cache и скрывает диагностику. Если влияет, но отсутствует, разные читатели получают одну запись по ошибке.
\nПосле изменения source owner должен оставить два связанных факта. Первый — сама запись с новой version. Второй — событие, которое указывает на изменившийся объект и ту же version. Нельзя выпускать event «очистить всё» без владельца и версии: оно не объясняет, какой cache key должен исчезнуть и как поздний потребитель отличит старое сообщение от нового. В нашем контракте write создаёт ArticleChanged, но не делает вид, что это уже доставка через broker.
const record = {\n id: 'guide-42',\n title: 'Кеш: версия два',\n visibility: 'public',\n version: previous.version + 1,\n};\n\nconst event = {\n type: 'ArticleChanged',\n id: record.id,\n sourceVersion: record.version,\n cacheKey: publicProjectionKey(record.id),\n};\nФакт успешной записи важнее намерения. RFC 7234 для HTTP-кеша связывает invalidation с неошибочным ответом на unsafe request и отдельно предупреждает, что это не гарантирует очистку всех подходящих ответов в других кешах. Этот предел полезно перенести в разговор о приложении: событие может существовать, а конкретная entry ещё оставаться в другом слое. Поэтому «мы отправили event» не равно «читатель уже не увидит старое». Нужны ключ, версия и наблюдаемая проверка на read-path.
\nСчастливый путь короткий: 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.
\nВсе идентификаторы, заголовки, версии, события и результаты ниже учебные. Модель работает только с Map в памяти: она не подключает Redis, CDN, broker, framework, HTTP-клиент или production traffic.
\n# Запускается только модель Map из revision-модуля.\nnode scripts/upgrade-2021-02.mjs --verify-fixture\n\n# Ожидаемые истинные assertions:\nstaleReadRebuiltVersionTwo: true\ndelayedEventDidNotEvictCurrentVersion: true\nprivateSourceIsNotVisibleBeforeEvent: true\nFixture проходит один устойчивый 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 события.
public в key.В этой статье нет настоящего cache hit-rate, CDN, Redis, очереди, HTTP response или данных пользователя. RFC 7234 и RFC 7232 объясняют HTTP semantics, но не говорят, как конкретный framework хранит объект в памяти. TTL тоже не запрещён: он ограничивает жизнь entry и полезен как дополнительный предел. Он не заменяет contract изменения, если source уже знает новую version. Реальную policy надо связывать с выбранным storage, нагрузкой, правами и допустимым окном stale, а не переносить этот учебный код в production без проверки.
\nОжидаемый результат после такого разбора скромный и проверяемый: у одной проекции есть владелец, key, version, event и условие current hit. Если любой из пяти пунктов нельзя назвать, инвалидация пока является надеждой на срок жизни записи. Начните с одного пути чтения, запустите fixture и только затем добавляйте интеграционный test на разрешённом cache store или HTTP-маршруте.
\n