8 lines
23 KiB
JSON
8 lines
23 KiB
JSON
{
|
||
"index": 248,
|
||
"slug": "editorial-2021-02-mechanism-cache-invalidation",
|
||
"title": "Инвалидация кеша: почему TTL не заменяет версию",
|
||
"excerpt": "После успешной записи кеш может вернуть известную старую проекцию. Разбираем versioned invalidation: кто владеет версией, как key отделяет области выдачи, почему delayed event не заменяет проверку на read и как безопасно обработать private-данные.",
|
||
"contentHtml": "<p>Возьмём контролируемый сценарий: пользователь меняет имя профиля, запись отвечает успешно, а следующая страница через несколько секунд всё ещё показывает «Ира» вместо «Ирина». Оператор открыл соседний экран и видит уже новое имя. У команды есть два наблюдения, но нет ответа, какой слой вернул старую проекцию.</p><p>Цена ошибки зависит от данных. Для профиля это недоверие к интерфейсу. Для цены или лимита — неверное решение. Для права доступа — риск показать то, что уже нельзя показывать. Особенно неприятен момент после успешной записи: источник знает версию v2, а кеш считает entry v1 допустимой, потому что её TTL ещё не закончился.</p><p>В этой статье рассматривается один прикладной контракт: source owner создаёт монотонную версию, cache entry хранит версию построенной проекции, event сообщает ту же версию, а read разрешает hit только после проверки версии и области выдачи. Это учебная модель одного объекта. Она не обещает строгую согласованность для любого брокера, CDN или хранилища.</p>\n<h2>Сценарий: запись уже новая, entry ещё старая</h2><p>Сначала источник содержит публичный профиль v1, а кеш хранит публичную проекцию v1. Затем владелец записи сохраняет новое имя и получает v2. В этот момент event об изменении ещё не применён к кешу. Если read доверится только наличию ключа или TTL, он вернёт v1. Правильный вопрос звучит иначе: совпадает ли версия entry с версией source для этого reader scope?</p>\n<pre><code>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'</code></pre><p>В примере source уже содержит v2, а entry собрана из v1. Наличие ключа не делает её текущей. Read должен пересобрать проекцию из v2 либо вернуть документированный безопасный отказ, если источник нельзя прочитать. Статус такого ответа лучше назвать <code>stale-rebuilt</code>, а не обычным <code>hit</code>: эти два результата требуют разной диагностики.</p>\n<h2>Источник владеет фактом, кеш — проекцией</h2><p>Source владеет именем, версией и видимостью записи. Кеш хранит производное значение для конкретного читателя. Он не становится владельцем профиля только потому, что быстро отдаёт JSON. Это разделение помогает найти место, где рождается устаревший ответ: запись изменилась в source, projection не перестроилась или read использовал неправильную границу.</p><p>В публичную entry следует складывать whitelist полей, а не копию всего source. В нашем примере <code>editorNote</code> может существовать у источника для внутренней диагностики, но в public projection его нет. Версия защищает от старой записи, а whitelist — от случайного расширения ответа при следующем изменении модели.</p><p>Номер версии должен иметь одного владельца. Если два процесса независимо выдают v2 для одного объекта, сравнение чисел перестаёт описывать порядок. Тогда понадобится составная версия, ревизия из хранилища или явный протокол разрешения конфликтов. Добавлять timestamp вместо этого решения нельзя без проверки точности часов, порядка записей и поведения при одинаковом времени.</p>\n<h2>Ключ описывает границу ответа</h2><p>Cache key должен отличать проекции, которые нельзя смешивать. Для публичного профиля это может быть <code>profile:public:42</code>. Если результат меняется из-за tenant, роли, языка, региона или набора полей, признак должен попасть в key либо быть проверен до cache hit. Иначе два запроса могут получить один JSON при разных правах или настройках отображения.</p><p>Добавлять в key все параметры подряд тоже ошибочно. Поле, которое не меняет проекцию, дробит кеш и затрудняет поиск дефекта. Поле, которое меняет ответ, но пропущено, создаёт collision между вариантами. Поэтому для каждого сегмента key нужно назвать владельца, смысл и тест на различие двух проекций.</p>\n<table><caption>Диагностика устаревшей выдачи</caption><thead><tr><th scope='col'>Наблюдение</th><th scope='col'>Граница дефекта</th><th scope='col'>Проверка</th><th scope='col'>Действие</th></tr></thead><tbody><tr><td>Source v2, entry v1, key совпадает</td><td>Read доверяет TTL или event запаздывает</td><td>Сравнить версии в одном read</td><td>Перестроить entry до возврата</td></tr><tr><td>Source v2, ожидаемый key отсутствует</td><td>Key не включает reader scope</td><td>Сравнить keys двух проекций</td><td>Исправить builder и добавить collision test</td></tr><tr><td>Source private v3, entry public v2</td><td>Visibility проверяется после hit</td><td>Сменить только visibility до доставки event</td><td>Сначала deny, затем evict</td></tr><tr><td>Source v2 и entry v2, интерфейс старый</td><td>Кеш не доказан как виновник</td><td>Проверить projection и renderer</td><td>Искать другой read layer</td></tr><tr><td>Event v2 пришёл после entry v2</td><td>Consumer удаляет по любому event</td><td>Повторить delayed delivery</td><td>Оставить current entry</td></tr></tbody></table>\n<figure><img src='/assets/editorial/2021/cache-consistency-matrix-2021.svg' alt='Матрица решений read для public source v1 и v2, старой и текущей cache entry, запоздалого event и private visibility' loading='lazy' /><figcaption>Схема показывает пять фиксированных состояний учебной модели. Это не измерение задержки, hit-rate или поведения конкретного кеш-сервиса.</figcaption></figure>\n<h2>Event ускоряет очистку, но не закрывает окно</h2><p>После записи команда обычно отправляет event и ждёт, что consumer удалит key. Это полезный путь, но не доказательство того, что каждый read уже увидит новую проекцию. Consumer может применить событие позже, получить повторную доставку или работать с другим слоем. Если read произойдёт между write и apply, старую entry нужно распознать без помощи event.</p><p>Для одного source owner и одного key consumer может удалять только entry с меньшей версией:</p>\n<pre><code>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}</code></pre><p>Сначала event v2 найдёт entry v1 и удалит её. Затем read построит entry v2. Если после этого придёт то же event v2, сравнение даст равенство и entry останется на месте. Только после event с более высокой версией можно считать текущую entry устаревшей относительно этого объекта.</p><p>У этого правила есть граница: оно не исправляет потерянный key, неверную область проекции, неатомную выдачу версий или зависимость одной записи от нескольких keys. В таких случаях нужен dependency contract или namespace, а не безусловный <code>delete</code>.</p>\n<h2>Что меняет транспорт событий</h2><p>Если для уведомлений используется Redis Pub/Sub, событие живёт только в пути доставки: официальный справочник Redis описывает этот механизм как fire-and-forget и предупреждает, что сообщения, отправленные при отключённом подписчике, теряются. Поэтому Redis Pub/Sub может ускорять eviction, но не должен быть единственным доказательством current state. Для гарантии восстановления понадобится другой механизм — например, повторное чтение source, durable stream или outbox; выбор зависит от системы.</p><p>Это свойство Redis нельзя автоматически переносить на любой брокер. У очереди с подтверждениями будут другие гарантии доставки, но повторное сообщение всё равно возможно. Consumer должен быть идемпотентным: событие не должно удалять entry, созданную из той же или более новой версии.</p>\n<h2>Read проверяет право и версию до возврата</h2><p>Правильный порядок такой: получить source, проверить существование и visibility, вычислить key, получить entry, сравнить версии, затем вернуть текущую проекцию или перестроить её. Проверка visibility после <code>cache.get</code> уже поздняя: public entry могла попасть в ответ до проверки. Сравнение только TTL также не знает, что запись изменилась миллисекунду назад.</p>\n<pre><code>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}</code></pre><p>Это демонстрационный read-path с интерфейсами <code>source.get</code>, <code>cache.get</code>, <code>cache.set</code> и <code>cache.delete</code>. Он показывает решение одного public key, а не готовую библиотеку. В рабочей системе нужно отдельно описать replica lag, конкурентные записи, атомарность построения, сериализацию и поведение при недоступном source.</p><p>Ветка <code>not-visible</code> удаляет старую public entry даже тогда, когда event о private-изменении ещё не доставлен. Это не делает authorization полноценной: право должно проверяться на границе приложения. Но порядок исключает очевидную ошибку — вернуть старую public projection только потому, что её TTL ещё жив.</p>\n<h2>TTL отвечает только за срок жизни</h2><p>TTL ограничивает время хранения entry и помогает контролировать память. Он полезен там, где source нельзя проверять на каждом чтении или где бизнес принимает ограниченное stale-окно. Но TTL не сообщает, что source изменился после построения entry. Пять минут freshness для v1 не превращают v1 в правильный ответ после успешной записи v2.</p><p>В HTTP-кеше freshness, validation и invalidation имеют собственные правила. RFC 7234 был нормативным документом для HTTP-кеширования в феврале 2021 года; сегодня его заменяет RFC 9111. В обоих случаях это правила повторного использования HTTP-ответов, а не инструкция для прикладной <code>Map</code>. <code>sourceVersion</code> в нашем примере не является ETag и не заменяет <code>If-None-Match</code>.</p><p>Если бизнес разрешает stale не дольше минуты, этот срок следует записать в контракте и измерять. Если после записи пользователь должен сразу увидеть новую цену или право, одной директивы TTL недостаточно: нужен version check, targeted invalidation, validation или протокол с сопоставимой гарантией.</p>\n<h2>Воспроизводимая последовательность проверки</h2><p>Дальше не требуется настоящий брокер. В памяти достаточно зафиксировать состояние после каждого действия и сравнить ожидаемый статус с фактическим. Такая проверка не доказывает работу production-инфраструктуры, но ловит ошибку в порядке решений.</p>\n<pre><code>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</code></pre><p>Проверьте сначала v1/v1, затем source v2 при cache v1, затем запоздалое event v2 после rebuild и наконец private v3 до доставки event. После этого добавьте два отрицательных теста: key без language или tenant и entry v2 при source v1. В последнем случае модель должна не принять cache как источник истины: более новая entry может быть результатом гонки, ошибки реплики или неверного владельца версии.</p><p>Логируйте не только итог. Для каждого шага нужны id объекта, source version, cache key, cache version, visibility, event state, status и причина решения. Время write, publish, apply и read помогает увидеть окно задержки, но само по себе не доказывает, что два слоя читали одну и ту же запись.</p>\n<h2>Порядок действий</h2><ol><li>Назовите один reader scenario и цену stale-ответа: старое имя, цена, лимит или право доступа.</li><li>Назначьте source owner. Только он создаёт следующую версию и определяет visibility.</li><li>Опишите public projection whitelist-ом и перечислите поля, которые нельзя отдавать этому reader.</li><li>Соберите key из идентификатора и всех признаков, меняющих ответ или право его увидеть.</li><li>После успешного write создайте event с id, key и той же версией. Не приравнивайте отправку к применению.</li><li>На read проверьте source и visibility до возврата entry.</li><li>Сравните версии. При несовпадении rebuild-ьте проекцию или используйте явно описанный безопасный отказ.</li><li>В consumer удаляйте только entry с версией меньше версии event.</li><li>Повторите delayed delivery, duplicate event, смену public на private и key collision.</li><li>Зафиксируйте статусы <code>hit-current</code>, <code>stale-rebuilt</code>, <code>miss-built</code> и <code>not-visible</code>.</li></ol>\n<h2>Ограничения и критерий готовности</h2><p>Versioned invalidation не делает распределённую систему строго согласованной автоматически. Если source читается с отстающей реплики, read может увидеть старую версию и ошибочно принять старую entry за current. Если событие потеряло key или версию, consumer не сможет безопасно выбрать запись. Если одна запись питает несколько проекций, одного key недостаточно. Эти ограничения должны быть частью design, а не скрываться за коротким TTL.</p><p>Учебный механизм готов для выбранного пути, если команда может назвать владельца source, точный key, источник версии, формат event, условие current hit и безопасный отрицательный путь. Автоматическая проверка должна воспроизвести четыре результата: v1/v1 даёт current, source v2 при cache v1 перестраивает ответ до event, позднее event v2 не удаляет entry v2, а private source удаляет public entry и не возвращает value.</p><p>Если эти состояния нельзя получить из логов или теста, сначала добавьте наблюдаемость и только затем меняйте eviction policy. Иначе команда увидит «в итоге стало правильно», но не узнает, какой слой вернул старое значение и почему. Инвалидация становится надёжнее не от более короткого таймера, а от явно проверяемого порядка source, key, version, event и read.</p>\n<h2>Проверяемые источники</h2><ul><li><a href='https://www.rfc-editor.org/rfc/rfc7234' target='_blank' rel='noopener noreferrer'>RFC 7234: HTTP/1.1 Caching</a> — исторический нормативный источник для рамки февраля 2021 года: cache key, freshness, validation и invalidation HTTP-ответов; документ впоследствии заменён RFC 9111.</li><li><a href='https://www.rfc-editor.org/rfc/rfc9111.html' target='_blank' rel='noopener noreferrer'>RFC 9111: HTTP Caching</a> — актуальная редакция правил HTTP-кеширования, включая cache key, freshness, validation и invalidation; она не описывает API прикладного cache store.</li><li><a href='https://redis.io/docs/latest/develop/pubsub/keyspace-notifications/' target='_blank' rel='noopener noreferrer'>Redis keyspace notifications</a> — официальное описание событий через Pub/Sub и ограничения fire-and-forget при отключении подписчика.</li></ul>"
|
||
}
|