diff --git a/editorial/agent-rewrites/248.json b/editorial/agent-rewrites/248.json index 7534ad9..a51f8e1 100644 --- a/editorial/agent-rewrites/248.json +++ b/editorial/agent-rewrites/248.json @@ -1,7 +1,7 @@ { "index": 248, "slug": "editorial-2021-02-mechanism-cache-invalidation", - "title": "Инвалидация кеша: как не вернуть известную устаревшую версию", - "excerpt": "Кеш может быть быстрым и при этом выдавать старую запись после успешного обновления. Разбираем versioned invalidation, границы ключа, запаздывающее событие и проверку, которая не считает stale-значение cache hit.", - "contentHtml": "

Пользователь меняет имя профиля, запрос на запись отвечает успешно, а следующая страница всё ещё показывает прежнее имя. Через несколько секунд всё исправляется само. За это время поддержка получает жалобу, оператор видит разные данные в соседних экранах, а команда спорит о том, был ли сбой в базе или в CDN.

Цена ошибки зависит от данных. Для профиля это недоверие к интерфейсу. Для цены или лимита — неверное решение. Для прав доступа — потенциальная утечка. Самый опасный случай начинается после успешной записи: источник уже знает новую версию, но кеш считает старую запись свежей.

Тезис простой: TTL отвечает на вопрос «как долго entry может жить», но не на вопрос «какую версию источник считает текущей». Надёжная инвалидация связывает владельца данных, ключ проекции, версию, событие изменения и правило read. Cache hit разрешён только тогда, когда версия и область выдачи совпадают с источником.

Сначала разделите источник и проекцию

Источник владеет фактом. Он записывает профиль и увеличивает его версию. Кеш хранит производную публичную проекцию. Он не становится владельцем профиля только потому, что умеет быстро вернуть JSON.

source: { id: 42, version: 2, visibility: "public", name: "Ирина" } cache: { key: "profile:public:42", sourceVersion: 1, name: "Ира" } event: { type: "ProfileChanged", id: 42, version: 2 } current hit => cache.sourceVersion === source.version && source.visibility === "public"

В примере источник уже содержит v2, а entry построена из v1. Наличие ключа и неистёкший TTL ничего не меняют. Если read вернёт «Ира», он выдаст известную старую проекцию. Правильное действие — перестроить entry из v2 или отказаться от ответа, если новая запись недоступна.

Почему одного события недостаточно

После записи команда отправляет событие и ждёт, что consumer удалит ключ. Это полезный быстрый путь, но не гарантия момента очистки. Брокер может задержать доставку. Consumer может повторить сообщение. Два слоя кеша могут получить его в разное время. Событие может прийти после того, как read уже построил новую entry.

Поэтому событие не должно быть единственным барьером. Его задача — ускорить очистку. Read должен закрыть окно между записью v2 и применением события. Для одного владельца и одного ключа consumer применяет событие только к более старой entry:

function invalidate(entry, event) { if (!entry) return "miss"; if (entry.sourceVersion < event.version) { cache.delete(entry.key); return "evicted-stale"; } return "kept-current"; }

Сравнение защищает от запоздалого сообщения. Если read уже собрал v2, позднее событие v2 не должно удалять current entry. Если пришло событие v3, entry v2 устарела и её можно удалить. Это правило предполагает, что один source owner выдаёт монотонные версии для конкретного объекта. При нескольких владельцах нужна другая схема: например, составная версия или явная модель конфликтов.

Ключ описывает границу ответа

Ключ должен включать каждое условие, которое меняет выдаваемую проекцию или право её увидеть. Для публичного профиля это может быть profile:public:42. Если ответ зависит от языка, региона, роли или набора полей, эти границы должны быть отражены в ключе либо проверены до cache hit.

Нельзя просто добавить в ключ все доступные параметры. Лишнее поле дробит кеш и ухудшает диагностику. Отсутствующее значимое поле смешивает варианты, которые нельзя смешивать. Если запись стала private, старая public entry должна исчезнуть даже при неистёкшем TTL.

Диагностика устаревшей выдачи
СимптомПричинаПроверкаДействие
После записи видна старая версияRead доверяет TTL или наличию ключаСравнить entry.sourceVersion и версию source в одном запросеПерестроить entry при несовпадении
Кеш очищается с задержкойСобытие ждёт брокер или consumerСопоставить время write, publish, apply и следующего readОставить version guard на read, а событие использовать как ускоритель
Новая entry удаляется повторноConsumer удаляет ключ для любого eventОтправить event v2 после построения entry v2Удалять только при entry.version < event.version
Private-данные видны из public endpointVisibility не проверяется перед hitСменить public на private при живой public entryСначала проверить право выдачи, затем evict и вернуть отказ
Разные пользователи получают один ответВ ключе нет tenant, роли или другого влияющего признакаПовторить запросы с двумя наборами прав и сравнить keyРасширить ключ или запретить кеширование такой проекции
Матрица состояний source и cache: current hit, stale rebuild, miss-built и отказ при private visibility
Матрица показывает решение read для пяти комбинаций версии источника, кеша, события и видимости. Это схема состояний, а не измерение задержки или hit-rate.

Read должен проверять версию до возврата

Упрощённый read-path выглядит так: получить source, проверить visibility, получить entry, сравнить версии, вернуть entry или построить новую. Порядок важен. Проверка доступа после cache hit уже слишком поздно, а проверка только TTL не знает о последней записи.

async function readPublicProfile(id) { const record = await source.get(id); const key = `profile:public:${id}`; if (!record || record.visibility !== "public") { await cache.delete(key); return { status: "not-visible" }; } const entry = await cache.get(key); if (entry?.sourceVersion === record.version) return { status: "hit-current", value: entry.value }; const value = { id: record.id, name: record.name }; await cache.set(key, { sourceVersion: record.version, value }); return { status: entry ? "stale-rebuilt" : "miss-built", value }; }

Код демонстрационный. Он показывает контракт одного объекта и одной public-проекции, а не готовую библиотеку кеширования. В рабочей системе нужно определить, где хранится версия, как читается источник, что происходит при replica lag и может ли public path обращаться к source. Если такой read слишком дорог, нельзя молча убрать проверку и оставить прежнее обещание. Нужно явно принять допустимое окно stale и доказать его отдельным тестом.

TTL остаётся полезным, но решает другую задачу

TTL ограничивает срок жизни entry. Он помогает освобождать память, уменьшать риск вечного старого значения и задавать верхнюю границу для систем, где источник нельзя проверять на каждом чтении. Но TTL не знает, что запись изменилась через миллисекунду после построения entry.

Если бизнес допускает stale-ответ не дольше минуты, TTL может быть частью контракта. Если после успешной записи пользователь должен сразу увидеть новую цену, одного TTL недостаточно. Нужны version check, событие, явная очистка или другой протокол с такой гарантией. Название директивы не переносит гарантию из HTTP-кеша в кеш приложения.

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

  1. Выберите один читательский сценарий и назовите цену stale-ответа: неверное имя, цена, лимит или право доступа.
  2. Назначьте source owner. Только он создаёт новую версию и определяет видимость записи.
  3. Опишите публичную проекцию whitelist-ом. Не копируйте в кеш весь объект источника.
  4. Соберите ключ из идентификатора и всех признаков, которые меняют ответ или право его увидеть.
  5. После успешной записи создайте событие с идентификатором и той же версией. Не выдавайте факт публикации за момент применения во всех слоях.
  6. На read проверьте существование и visibility источника до возврата entry.
  7. Сравните версии. При несовпадении перестройте проекцию или примените документированный безопасный отказ.
  8. В consumer удаляйте только entry с версией меньше версии события.
  9. Проверьте задержанное событие, повторную доставку и смену public на private.
  10. Зафиксируйте статусы hit-current, stale-rebuilt, miss-built, not-visible. Они отличают правильную перестройку от обычного cache miss.

Ограничения и отрицательный путь

Versioned invalidation не делает распределённую систему строго согласованной автоматически. Если source и cache читаются из реплик с разной задержкой, read может увидеть старую версию источника и принять старую entry за current. Если событие потеряло key или версию, consumer не сможет безопасно удалить нужную проекцию. Если одна запись питает несколько ключей, одного сравнения недостаточно: нужен список зависимостей или общий namespace.

Отрицательный путь должен быть безопасным. При недоступном source нельзя возвращать старую private-проекцию через public key. При неизвестной версии нельзя считать entry current. При неоднозначном ключе лучше сделать miss или отказать в выдаче, чем смешать варианты. При разрешённом stale нужно назвать срок и показать, где он измеряется.

Не переносите код выше в рабочую систему без проверки хранилища, конкурентных записей, прав доступа, сериализации и отказов сети. Пример ограничен одной записью, одним владельцем и одной проекцией. Его цель — сделать порядок решений проверяемым.

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

Механизм готов для выбранного пути, если команда может назвать пять вещей: владельца source, точный cache key, источник версии, формат события и условие current hit. Автоматическая проверка должна воспроизвести четыре состояния: v1/v1 возвращает current, source v2 при cache v1 перестраивает ответ до delivery события, запоздалое событие v2 не удаляет entry v2, а private source удаляет public entry и не возвращает значение.

Отдельно проверьте журналы времени write, publish, apply и read. Не подменяйте это проверкой «в итоге стало правильно». Нужен результат каждого состояния и причина решения. Тогда инвалидация перестаёт быть надеждой на таймер и становится контрактом, который можно нарушить, обнаружить и исправить.

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

" + "title": "Инвалидация кеша: почему TTL не заменяет версию", + "excerpt": "После успешной записи кеш может вернуть известную старую проекцию. Разбираем versioned invalidation: кто владеет версией, как key отделяет области выдачи, почему delayed event не заменяет проверку на read и как безопасно обработать private-данные.", + "contentHtml": "

Возьмём контролируемый сценарий: пользователь меняет имя профиля, запись отвечает успешно, а следующая страница через несколько секунд всё ещё показывает «Ира» вместо «Ирина». Оператор открыл соседний экран и видит уже новое имя. У команды есть два наблюдения, но нет ответа, какой слой вернул старую проекцию.

Цена ошибки зависит от данных. Для профиля это недоверие к интерфейсу. Для цены или лимита — неверное решение. Для права доступа — риск показать то, что уже нельзя показывать. Особенно неприятен момент после успешной записи: источник знает версию v2, а кеш считает entry v1 допустимой, потому что её TTL ещё не закончился.

В этой статье рассматривается один прикладной контракт: source owner создаёт монотонную версию, cache entry хранит версию построенной проекции, event сообщает ту же версию, а read разрешает hit только после проверки версии и области выдачи. Это учебная модель одного объекта. Она не обещает строгую согласованность для любого брокера, CDN или хранилища.

\n

Сценарий: запись уже новая, entry ещё старая

Сначала источник содержит публичный профиль v1, а кеш хранит публичную проекцию v1. Затем владелец записи сохраняет новое имя и получает v2. В этот момент event об изменении ещё не применён к кешу. Если read доверится только наличию ключа или TTL, он вернёт v1. Правильный вопрос звучит иначе: совпадает ли версия entry с версией source для этого reader scope?

\n
source = { id: 'profile-42', version: 2, visibility: 'public', name: 'Ирина' }\ncacheEntry = { key: 'profile:public:42', sourceVersion: 1, value: { name: 'Ира' } }\nevent = { type: 'ProfileChanged', key: 'profile:public:42', version: 2 }\ncurrentHit = cacheEntry.sourceVersion === source.version\n  && source.visibility === 'public'

В примере source уже содержит v2, а entry собрана из v1. Наличие ключа не делает её текущей. Read должен пересобрать проекцию из v2 либо вернуть документированный безопасный отказ, если источник нельзя прочитать. Статус такого ответа лучше назвать stale-rebuilt, а не обычным hit: эти два результата требуют разной диагностики.

\n

Источник владеет фактом, кеш — проекцией

Source владеет именем, версией и видимостью записи. Кеш хранит производное значение для конкретного читателя. Он не становится владельцем профиля только потому, что быстро отдаёт JSON. Это разделение помогает найти место, где рождается устаревший ответ: запись изменилась в source, projection не перестроилась или read использовал неправильную границу.

В публичную entry следует складывать whitelist полей, а не копию всего source. В нашем примере editorNote может существовать у источника для внутренней диагностики, но в public projection его нет. Версия защищает от старой записи, а whitelist — от случайного расширения ответа при следующем изменении модели.

Номер версии должен иметь одного владельца. Если два процесса независимо выдают v2 для одного объекта, сравнение чисел перестаёт описывать порядок. Тогда понадобится составная версия, ревизия из хранилища или явный протокол разрешения конфликтов. Добавлять timestamp вместо этого решения нельзя без проверки точности часов, порядка записей и поведения при одинаковом времени.

\n

Ключ описывает границу ответа

Cache key должен отличать проекции, которые нельзя смешивать. Для публичного профиля это может быть profile:public:42. Если результат меняется из-за tenant, роли, языка, региона или набора полей, признак должен попасть в key либо быть проверен до cache hit. Иначе два запроса могут получить один JSON при разных правах или настройках отображения.

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

\n
Диагностика устаревшей выдачи
НаблюдениеГраница дефектаПроверкаДействие
Source v2, entry v1, key совпадаетRead доверяет TTL или event запаздываетСравнить версии в одном readПерестроить entry до возврата
Source v2, ожидаемый key отсутствуетKey не включает reader scopeСравнить keys двух проекцийИсправить builder и добавить collision test
Source private v3, entry public v2Visibility проверяется после hitСменить только visibility до доставки eventСначала deny, затем evict
Source v2 и entry v2, интерфейс старыйКеш не доказан как виновникПроверить projection и rendererИскать другой read layer
Event v2 пришёл после entry v2Consumer удаляет по любому eventПовторить delayed deliveryОставить current entry
\n
Матрица решений read для public source v1 и v2, старой и текущей cache entry, запоздалого event и private visibility
Схема показывает пять фиксированных состояний учебной модели. Это не измерение задержки, hit-rate или поведения конкретного кеш-сервиса.
\n

Event ускоряет очистку, но не закрывает окно

После записи команда обычно отправляет event и ждёт, что consumer удалит key. Это полезный путь, но не доказательство того, что каждый read уже увидит новую проекцию. Consumer может применить событие позже, получить повторную доставку или работать с другим слоем. Если read произойдёт между write и apply, старую entry нужно распознать без помощи event.

Для одного source owner и одного key consumer может удалять только entry с меньшей версией:

\n
function applyInvalidation(cache, event) {\n  const entry = cache.get(event.key);\n  if (!entry) return 'no-entry';\n  if (entry.sourceVersion < event.version) {\n    cache.delete(event.key);\n    return 'evicted-stale';\n  }\n  return 'kept-current';\n}

Сначала event v2 найдёт entry v1 и удалит её. Затем read построит entry v2. Если после этого придёт то же event v2, сравнение даст равенство и entry останется на месте. Только после event с более высокой версией можно считать текущую entry устаревшей относительно этого объекта.

У этого правила есть граница: оно не исправляет потерянный key, неверную область проекции, неатомную выдачу версий или зависимость одной записи от нескольких keys. В таких случаях нужен dependency contract или namespace, а не безусловный delete.

\n

Что меняет транспорт событий

Если для уведомлений используется Redis Pub/Sub, событие живёт только в пути доставки: официальный справочник Redis описывает этот механизм как fire-and-forget и предупреждает, что сообщения, отправленные при отключённом подписчике, теряются. Поэтому Redis Pub/Sub может ускорять eviction, но не должен быть единственным доказательством current state. Для гарантии восстановления понадобится другой механизм — например, повторное чтение source, durable stream или outbox; выбор зависит от системы.

Это свойство Redis нельзя автоматически переносить на любой брокер. У очереди с подтверждениями будут другие гарантии доставки, но повторное сообщение всё равно возможно. Consumer должен быть идемпотентным: событие не должно удалять entry, созданную из той же или более новой версии.

\n

Read проверяет право и версию до возврата

Правильный порядок такой: получить source, проверить существование и visibility, вычислить key, получить entry, сравнить версии, затем вернуть текущую проекцию или перестроить её. Проверка visibility после cache.get уже поздняя: public entry могла попасть в ответ до проверки. Сравнение только TTL также не знает, что запись изменилась миллисекунду назад.

\n
async function readPublicProfile(id) {\n  const record = await source.get(id);\n  const key = 'profile:public:' + id;\n\n  if (!record || record.visibility !== 'public') {\n    await cache.delete(key);\n    return { status: 'not-visible' };\n  }\n\n  const entry = await cache.get(key);\n  if (entry?.sourceVersion === record.version) {\n    return { status: 'hit-current', value: entry.value };\n  }\n\n  const value = { id: record.id, name: record.name };\n  await cache.set(key, { sourceVersion: record.version, value });\n  return { status: entry ? 'stale-rebuilt' : 'miss-built', value };\n}

Это демонстрационный read-path с интерфейсами source.get, cache.get, cache.set и cache.delete. Он показывает решение одного public key, а не готовую библиотеку. В рабочей системе нужно отдельно описать replica lag, конкурентные записи, атомарность построения, сериализацию и поведение при недоступном source.

Ветка not-visible удаляет старую public entry даже тогда, когда event о private-изменении ещё не доставлен. Это не делает authorization полноценной: право должно проверяться на границе приложения. Но порядок исключает очевидную ошибку — вернуть старую public projection только потому, что её TTL ещё жив.

\n

TTL отвечает только за срок жизни

TTL ограничивает время хранения entry и помогает контролировать память. Он полезен там, где source нельзя проверять на каждом чтении или где бизнес принимает ограниченное stale-окно. Но TTL не сообщает, что source изменился после построения entry. Пять минут freshness для v1 не превращают v1 в правильный ответ после успешной записи v2.

В HTTP-кеше freshness, validation и invalidation имеют собственные правила. RFC 7234 был нормативным документом для HTTP-кеширования в феврале 2021 года; сегодня его заменяет RFC 9111. В обоих случаях это правила повторного использования HTTP-ответов, а не инструкция для прикладной Map. sourceVersion в нашем примере не является ETag и не заменяет If-None-Match.

Если бизнес разрешает stale не дольше минуты, этот срок следует записать в контракте и измерять. Если после записи пользователь должен сразу увидеть новую цену или право, одной директивы TTL недостаточно: нужен version check, targeted invalidation, validation или протокол с сопоставимой гарантией.

\n

Воспроизводимая последовательность проверки

Дальше не требуется настоящий брокер. В памяти достаточно зафиксировать состояние после каждого действия и сравнить ожидаемый статус с фактическим. Такая проверка не доказывает работу production-инфраструктуры, но ловит ошибку в порядке решений.

\n
1. source  = public v1, cache = empty              => miss-built v1\n2. read()                                            => hit-current v1\n3. source  = public v2, event v2 = pending          => source changed\n4. read() with cache v1                              => stale-rebuilt v2\n5. apply event v2 after rebuild                      => kept-current v2\n6. source  = private v3, event v3 = pending         => source changed\n7. read() with cache public v2                       => not-visible and evict

Проверьте сначала v1/v1, затем source v2 при cache v1, затем запоздалое event v2 после rebuild и наконец private v3 до доставки event. После этого добавьте два отрицательных теста: key без language или tenant и entry v2 при source v1. В последнем случае модель должна не принять cache как источник истины: более новая entry может быть результатом гонки, ошибки реплики или неверного владельца версии.

Логируйте не только итог. Для каждого шага нужны id объекта, source version, cache key, cache version, visibility, event state, status и причина решения. Время write, publish, apply и read помогает увидеть окно задержки, но само по себе не доказывает, что два слоя читали одну и ту же запись.

\n

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

  1. Назовите один reader scenario и цену stale-ответа: старое имя, цена, лимит или право доступа.
  2. Назначьте source owner. Только он создаёт следующую версию и определяет visibility.
  3. Опишите public projection whitelist-ом и перечислите поля, которые нельзя отдавать этому reader.
  4. Соберите key из идентификатора и всех признаков, меняющих ответ или право его увидеть.
  5. После успешного write создайте event с id, key и той же версией. Не приравнивайте отправку к применению.
  6. На read проверьте source и visibility до возврата entry.
  7. Сравните версии. При несовпадении rebuild-ьте проекцию или используйте явно описанный безопасный отказ.
  8. В consumer удаляйте только entry с версией меньше версии event.
  9. Повторите delayed delivery, duplicate event, смену public на private и key collision.
  10. Зафиксируйте статусы hit-current, stale-rebuilt, miss-built и not-visible.
\n

Ограничения и критерий готовности

Versioned invalidation не делает распределённую систему строго согласованной автоматически. Если source читается с отстающей реплики, read может увидеть старую версию и ошибочно принять старую entry за current. Если событие потеряло key или версию, consumer не сможет безопасно выбрать запись. Если одна запись питает несколько проекций, одного key недостаточно. Эти ограничения должны быть частью design, а не скрываться за коротким TTL.

Учебный механизм готов для выбранного пути, если команда может назвать владельца source, точный key, источник версии, формат event, условие current hit и безопасный отрицательный путь. Автоматическая проверка должна воспроизвести четыре результата: v1/v1 даёт current, source v2 при cache v1 перестраивает ответ до event, позднее event v2 не удаляет entry v2, а private source удаляет public entry и не возвращает value.

Если эти состояния нельзя получить из логов или теста, сначала добавьте наблюдаемость и только затем меняйте eviction policy. Иначе команда увидит «в итоге стало правильно», но не узнает, какой слой вернул старое значение и почему. Инвалидация становится надёжнее не от более короткого таймера, а от явно проверяемого порядка source, key, version, event и read.

\n

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

" }