diff --git a/editorial/agent-rewrites/249.json b/editorial/agent-rewrites/249.json index 6338d73..2363fe9 100644 --- a/editorial/agent-rewrites/249.json +++ b/editorial/agent-rewrites/249.json @@ -1,7 +1 @@ -{ - "index": 249, - "slug": "editorial-2021-02-practice-cache-invalidation", - "title": "Инвалидация кеша: как не вернуть устаревшие данные", - "excerpt": "Источник уже хранит новую версию, а читатель получает старую карточку. Разбираем ключ, версию, событие изменения и проверку чтения на коротком контролируемом примере.", - "contentHtml": "
Пользователь меняет название статьи. В базе уже лежит новая строка, но соседняя страница ещё показывает старый заголовок. Иногда устаревший ответ живёт минуты, иногда — до истечения TTL. Цена ошибки зависит от данных: читатель может увидеть старый статус заказа, прежнюю цену или публичную карточку снятого с публикации материала.
\nПервый порыв — увеличить частоту очистки или удалить случайный ключ после жалобы. Это убирает один симптом, но не объясняет, какой ответ устарел и почему следующий запрос снова получил его. Надёжнее считать cache entry актуальной только тогда, когда она относится к нужной проекции и собрана из текущей версии источника.
\nTTL отвечает на один вопрос: сколько времени запись можно хранить. Он не отвечает на другой: была ли запись изменена после того, как её положили в кеш. Если источник сменился через секунду после записи, десятиминутный TTL оставит старую проекцию ещё на 599 секунд.
\nИнвалидация должна связывать четыре факта: владельца исходной записи, ключ читательской проекции, версию записи и событие изменения. На чтении система проверяет, что эти факты согласованы. Событие ускоряет удаление старой entry, но не должно быть единственной защитой: оно может задержаться, потеряться или прийти после следующего изменения.
\nВозьмём запись guide-42. Ею владеет source service. Запись имеет поля title, visibility и монотонную version. Публичный читатель получает только id, title и sourceVersion. Внутреннее поле editorNote не должно попасть в кеш даже при успешном cache hit.
Ключ описывает не объект вообще, а конкретный ответ. Поэтому article:public:guide-42 лучше, чем article:guide-42. В первом варианте видна область чтения. Если позже появятся язык, tenant или роль, каждый параметр нужно добавить только после проверки: меняет ли он содержание и право получить ответ.
| Факт | Пример | Проверка |
|---|---|---|
| Источник | guide-42, version 2 | Только владелец создаёт следующую версию |
| Ключ | article:public:guide-42 | В ключе указана публичная область |
| Проекция | id, title, sourceVersion | В ней нет editorNote |
| Событие | ArticleChanged, version 2 | Версия события сравнивается с entry |
| Условие hit | entry version равна source version | Иначе ответ пересобирается |
Запись источника и событие должны нести одну версию. Тогда consumer может отличить старое уведомление от нового. Timestamp не всегда подходит: разные часы, точность округления и повторная доставка усложняют сравнение. Версия выражает порядок изменений одного владельца.
\nНиже — учебный фрагмент на обычном JavaScript. Он использует только Map и синтетические данные. Фрагмент показывает контракт и порядок проверок, но не является готовым адаптером для Redis, CDN, брокера или конкретного фреймворка.
const source = new Map([\n ['guide-42', {\n id: 'guide-42',\n title: 'Версия 1',\n editorNote: 'internal',\n visibility: 'public',\n version: 1,\n }],\n]);\nconst cache = new Map();\n\nfunction publicKey(id) {\n return `article:public:${id}`;\n}\n\nfunction publicProjection(record) {\n return {\n id: record.id,\n title: record.title,\n sourceVersion: record.version,\n };\n}\n\nfunction readPublic(id) {\n const record = source.get(id);\n const key = publicKey(id);\n const entry = cache.get(key);\n\n if (!record || record.visibility !== 'public') {\n cache.delete(key);\n return { status: 'not-visible' };\n }\n\n if (entry && entry.sourceVersion === record.version) {\n return { status: 'hit-current', projection: entry.projection };\n }\n\n const projection = publicProjection(record);\n cache.set(key, {\n sourceVersion: record.version,\n projection,\n });\n return { status: entry ? 'stale-rebuilt' : 'miss-built', projection };\n}\nПервое чтение создаёт entry версии 1. Затем источник получает заголовок «Версия 2» и увеличивает version. Если событие ещё не дошло, следующий readPublic всё равно видит рассогласование и пересобирает проекцию. Он не называет старую entry актуальным hit.
source.set('guide-42', {\n id: 'guide-42',\n title: 'Версия 2',\n editorNote: 'internal',\n visibility: 'public',\n version: 2,\n});\n\nreadPublic('guide-42').status;\n// 'stale-rebuilt'\n\nsource.get('guide-42').visibility = 'private';\nsource.get('guide-42').version = 3;\n\nreadPublic('guide-42').status;\n// 'not-visible'\nИзменение visibility требует особой ветки. Нельзя ждать только event consumer-а: до его запуска публичный ключ уже опасен. Read должен проверить право показа, удалить недопустимую entry и вернуть отказ. Кеш не заменяет авторизацию.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Источник v2, entry v1, ключ совпадает | Событие задержалось или read не сравнивает версии | Воспроизвести write → delayed event → read | Добавить version guard или точечную очистку |
| Источник v2, но читается другой ключ | Ключ не содержит область, язык или tenant | Вывести ключи двух проекций и сравнить поля | Исправить контракт ключа и тест collision |
| Источник private, entry public | Visibility проверяется после cache hit | Сменить только visibility и повторить public read | Сначала отказать и удалить entry |
| Источник v2, entry v2, UI старый | Кеш не доказан как причина | Сверить projection и слой renderer | Проверить другой кеш или клиентское состояние |
| Позднее событие удаляет entry v2 | Consumer удаляет по любому событию | Проверить условие entry.version < event.version | Не удалять current entry |
Vary, ETag или другой validator. Не смешивайте их с внутренней version без явного контракта.Синхронное сравнение source и cache в примере не обещает строгую согласованность распределённой системы. В настоящем сервисе источник, кеш, брокер, реплика и CDN имеют разные задержки. Если read не может получить текущую версию, нужно документировать допустимое stale window и использовать выбранный механизм validation. Нельзя объявлять проблему решённой только потому, что событие записалось в журнал.
\nTTL остаётся полезным предохранителем от бесконечной жизни entry. Он ограничивает ущерб при сбое очистки, но не заменяет событие и проверку версии. Полная очистка кеша тоже не универсальное решение: она создаёт всплеск запросов к источнику и не исправляет неправильный key или ошибочную проекцию.
\nHTTP-кеш имеет собственные правила. RFC 9111 описывает freshness, validation, cache key и invalidation ответов. RFC 9110 описывает семантику HTTP и условные запросы. Эти правила помогают спроектировать HTTP-слой, но не превращают прикладную Map в HTTP-кеш и не проверяют права пользователя.
Для одной выбранной проекции можно назвать владельца источника, точный ключ, версию, событие и условие current hit. Контролируемый тест подтверждает три отрицательных пути: source v2 не возвращает entry v1 как hit до доставки события; запоздалое событие не удаляет entry v2; private source не выдаётся через public key. Только после этого имеет смысл подключать реальный cache store и проверять тот же контракт на интеграционном маршруте.
\nСимптом обычно виден не в кеше, а на странице: источник уже хранит новый заголовок, а читатель получает старую карточку. Цена ошибки зависит от проекции. Можно показать устаревший статус публикации, оставить доступным снятый материал или скрыть уже разрешённое изменение. Увеличить 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