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 актуальной только тогда, когда она относится к нужной проекции и собрана из текущей версии источника.

\n

Тезис: TTL не сообщает, что данные изменились

\n

TTL отвечает на один вопрос: сколько времени запись можно хранить. Он не отвечает на другой: была ли запись изменена после того, как её положили в кеш. Если источник сменился через секунду после записи, десятиминутный TTL оставит старую проекцию ещё на 599 секунд.

\n

Инвалидация должна связывать четыре факта: владельца исходной записи, ключ читательской проекции, версию записи и событие изменения. На чтении система проверяет, что эти факты согласованы. Событие ускоряет удаление старой entry, но не должно быть единственной защитой: оно может задержаться, потеряться или прийти после следующего изменения.

\n

Механизм на одной публичной карточке

\n

Возьмём запись guide-42. Ею владеет source service. Запись имеет поля title, visibility и монотонную version. Публичный читатель получает только id, title и sourceVersion. Внутреннее поле editorNote не должно попасть в кеш даже при успешном cache hit.

\n

Ключ описывает не объект вообще, а конкретный ответ. Поэтому article:public:guide-42 лучше, чем article:guide-42. В первом варианте видна область чтения. Если позже появятся язык, tenant или роль, каждый параметр нужно добавить только после проверки: меняет ли он содержание и право получить ответ.

\n
Контракт одной cache entry
ФактПримерПроверка
Источникguide-42, version 2Только владелец создаёт следующую версию
Ключarticle:public:guide-42В ключе указана публичная область
Проекцияid, title, sourceVersionВ ней нет editorNote
СобытиеArticleChanged, version 2Версия события сравнивается с entry
Условие hitentry version равна source versionИначе ответ пересобирается
\n

Запись источника и событие должны нести одну версию. Тогда consumer может отличить старое уведомление от нового. Timestamp не всегда подходит: разные часы, точность округления и повторная доставка усложняют сравнение. Версия выражает порядок изменений одного владельца.

\n

Конкретный пример

\n

Ниже — учебный фрагмент на обычном JavaScript. Он использует только Map и синтетические данные. Фрагмент показывает контракт и порядок проверок, но не является готовым адаптером для Redis, CDN, брокера или конкретного фреймворка.

\n
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.

\n
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 и вернуть отказ. Кеш не заменяет авторизацию.

\n
\"Схема
Версия на read-path защищает от устаревшей entry в промежутке между записью источника и доставкой события.
\n

Симптом → причина → проверка → действие

\n
Диагностика stale-read
СимптомПричинаПроверкаДействие
Источник v2, entry v1, ключ совпадаетСобытие задержалось или read не сравнивает версииВоспроизвести write → delayed event → readДобавить version guard или точечную очистку
Источник v2, но читается другой ключКлюч не содержит область, язык или tenantВывести ключи двух проекций и сравнить поляИсправить контракт ключа и тест collision
Источник private, entry publicVisibility проверяется после cache hitСменить только visibility и повторить public readСначала отказать и удалить entry
Источник v2, entry v2, UI старыйКеш не доказан как причинаСверить projection и слой rendererПроверить другой кеш или клиентское состояние
Позднее событие удаляет entry v2Consumer удаляет по любому событиюПроверить условие entry.version < event.versionНе удалять current entry
\n

Порядок действий

\n
  1. Опишите симптом: какой читатель получил какую старую проекцию и чем это опасно.
  2. Зафиксируйте source id, текущую версию, вычисленный read key, версию cache entry, состояние события и visibility.
  3. Проверьте, что ключ относится к нужной области чтения и включает все параметры, которые меняют ответ.
  4. Сравните версии до очистки ключа. Если source новее entry, воспроизведите чтение при задержанном событии.
  5. Проверьте projection whitelist. В кеше должны лежать только поля, разрешённые этой проекцией.
  6. Отдельно проверьте изменение visibility. Public read должен отказать до доставки события.
  7. Сделайте consumer идемпотентным: запоздалое событие не удаляет entry, собранную из более новой версии.
  8. Для HTTP-слоя отдельно проверьте cache key, Vary, ETag или другой validator. Не смешивайте их с внутренней version без явного контракта.
\n

Ограничения

\n

Синхронное сравнение source и cache в примере не обещает строгую согласованность распределённой системы. В настоящем сервисе источник, кеш, брокер, реплика и CDN имеют разные задержки. Если read не может получить текущую версию, нужно документировать допустимое stale window и использовать выбранный механизм validation. Нельзя объявлять проблему решённой только потому, что событие записалось в журнал.

\n

TTL остаётся полезным предохранителем от бесконечной жизни entry. Он ограничивает ущерб при сбое очистки, но не заменяет событие и проверку версии. Полная очистка кеша тоже не универсальное решение: она создаёт всплеск запросов к источнику и не исправляет неправильный key или ошибочную проекцию.

\n

HTTP-кеш имеет собственные правила. RFC 9111 описывает freshness, validation, cache key и invalidation ответов. RFC 9110 описывает семантику HTTP и условные запросы. Эти правила помогают спроектировать HTTP-слой, но не превращают прикладную Map в HTTP-кеш и не проверяют права пользователя.

\n

Критерий готовности

\n

Для одной выбранной проекции можно назвать владельца источника, точный ключ, версию, событие и условие current hit. Контролируемый тест подтверждает три отрицательных пути: source v2 не возвращает entry v1 как hit до доставки события; запоздалое событие не удаляет entry v2; private source не выдаётся через public key. Только после этого имеет смысл подключать реальный cache store и проверять тот же контракт на интеграционном маршруте.

\n

Проверяемые источники

\n" -} +{"index":249,"slug":"editorial-2021-02-practice-cache-invalidation","title":"Инвалидация кеша: ключ, событие и проверка чтения","excerpt":"Учебный контракт для одного публичного представления: владелец записи, key с областью читателя, версия, событие изменения и read с защитой от stale entry.","contentHtml":"

Симптом обычно виден не в кеше, а на странице: источник уже хранит новый заголовок, а читатель получает старую карточку. Цена ошибки зависит от проекции. Можно показать устаревший статус публикации, оставить доступным снятый материал или скрыть уже разрешённое изменение. Увеличить TTL либо удалить случайный key после жалобы — это не исправление: при следующей записи тот же читатель снова увидит не ту версию.

\n

Для февраля 2021 я бы начал не с выбора Redis или CDN, а с короткого контракта. Нужно назвать владельца исходной записи, ключ именно читаемой проекции, событие изменения и факт, по которому read вправе вернуть значение. Главный инвариант здесь не «кеш быстрый», а «читатель получает только данные, которые вправе видеть в текущем состоянии источника». Ниже все значения синтетические и живут в Map; они объясняют порядок, но не изображают работающую инфраструктуру.

\n

Сначала определяем границу чтения

\n

Кеширует не таблица и не объект целиком, а конкретный ответ на конкретный вопрос. В упражнении таким вопросом будет «что может увидеть публичный читатель у article guide-42?». Исходная запись принадлежит одному source owner. У неё есть visibility, title, внутренняя заметка редактора и монотонная version. Публичная проекция содержит только id, title, version и область читателя. Внутренняя заметка не должна попасть в неё даже при cache hit.

\n
Контракт одной публичной карточки
ЧастьВладелец или значениеПочему нужнаПроверка
Источникarticle:guide-42 у учебного source ownerтолько он создаёт следующую версиюпосле write version растёт с 1 до 2
Read keyarticle:public:guide-42key отделяет public projection от другой области чтенияreader scope явно виден в key
Проекцияid, title, sourceVersionread не переносит редакторское поле по привычкев object нет editorNote
СобытиеArticleChanged с id и sourceVersionсообщает, для какой версии прежняя entry стала подозрительнойevent v2 сравнивается с cached v1
Критерий hitcached version равна source versionTTL не маскирует уже известную новую записьиначе read rebuild-ит projection
\n

У 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 обязан включать право увидеть проекцию

\n

Плохой key выглядит удобно: article:guide-42. Он быстро строится, но ничего не говорит, какое представление там лежит. Если одна ветка кода записала публичную карточку, а другая позднее ожидает редакторскую, collision уже создан. Нельзя исправить это договорённостью «у нас такой key только для public»: она не проверяется на чтении. Лучше назвать область прямо и строить projection whitelist отдельно от source record.

\n
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
\"Вертикальная
Версия на read-path нужна не для украшения event. Она не даёт вернуть v1 в момент, когда source уже находится на v2, но delivery события ещё не дошла до модели.
\n

Запись создаёт версию и повод для invalidation

\n

После изменения source owner должен оставить два связанных факта. Первый — сама запись с новой version. Второй — событие, которое указывает на изменившийся объект и ту же version. Нельзя выпускать event «очистить всё» без владельца и версии: оно не объясняет, какой cache key должен исчезнуть и как поздний потребитель отличит старое сообщение от нового. В нашем контракте write создаёт ArticleChanged, но не делает вид, что это уже доставка через broker.

\n
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

Событие ускоряет очистку, но read всё равно проверяет версию

\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 пересобирается до ответа.

\n

Это не обещание строгой согласованности любой распределённой системы. Модель смотрит на source synchronously, поэтому может сравнить две версии в одной памяти. Реальный cache store, broker и source storage могут иметь другие границы, задержки и подтверждения. Но контракт полезен уже сейчас: он явно говорит, что cache hit разрешён только при совпадении известной версии и области читателя. Где нельзя получить version source на read, нужно честно выбрать другой механизм и отдельно описать его окно stale.

\n

Проверяем модель до интеграции

\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
\n

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 события.

\n

Нумерованный маршрут для одного контракта

\n
  1. Назвать один читательский вопрос и цену stale ответа. Не начинать с общего «почистим кеш».
  2. Назначить source owner: именно он создаёт следующую version и определяет visibility записи.
  3. Собрать key из объекта и тех условий, которые меняют право увидеть проекцию. Для public карточки сохранить область public в key.
  4. Сделать projection whitelist. Проверить, что внутренние поля не попадают в value даже на cache hit.
  5. После успешного write сформировать event с id, version и key или детерминированным способом его построить. Не выдавать локальный object за broker delivery.
  6. На read сравнить cached version с текущей source version. При несовпадении rebuild-ить либо выбрать документированный другой путь, а не вернуть stale как hit.
  7. Добавить два отрицательных случая: задержанное event и изменение visibility. Оба должны дать безопасный результат для читателя.
\n

Граница этого практического рецепта

\n

В этой статье нет настоящего 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

Проверяемые источники

\n"}