{ "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" }