8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"index": 247,
|
||
"slug": "editorial-2021-02-field-cache-invalidation",
|
||
"title": "Инвалидация кеша: как доказать, что читатель получил старую проекцию",
|
||
"excerpt": "Пользователь видит старое значение после записи в source. Разбираем key, version, delayed event и visibility, чтобы выбрать точечное действие вместо очистки всего кеша.",
|
||
"contentHtml": "<p>Пользователь меняет имя, получает успешный ответ, а соседняя страница ещё час показывает старое значение. Команда очищает весь кеш. Симптом исчезает, но причина остаётся неизвестной: событие задержалось, запрос попал в другой key, проекция не обновилась или браузер показывает старый ответ. Цена ошибки — не только одна жалоба. Полная очистка создаёт лишнюю нагрузку, стирает след диагностики и может скрыть проблему до следующего изменения.</p>\n<p>Кеш нельзя считать текущим только потому, что запись в него существует. Текущесть должна следовать из контракта: какой source владеет данными, какой key обозначает проекцию, какая version попала в entry и имеет ли читатель право получить эту проекцию. Если этих фактов нет, команда спорит о TTL и purge, не проверяя состояние.</p>\n<h2>Тезис: инвалидируйте состояние, а не симптом</h2>\n<p>Для одного read path достаточно связать source id, монотонную version, read key и cache entry. При чтении сравните version source с version entry. Если entry старше, пересоберите проекцию или удалите её. Если source стал недоступен публичному читателю, сначала запретите ответ и удалите entry. Позднее событие не должно удалять уже актуальную entry.</p>\n<p>Эта схема относится к кешу прикладной проекции. Она не делает базу, брокер и HTTP-кеш одной системой. Один и тот же объект может иметь разные проекции для языка, tenant или роли. В таком случае каждый контекст входит в контракт key и проверяется отдельно.</p>\n<h2>Механизм старого чтения</h2>\n<p>Пусть source хранит запись <code>guide-42</code>. После первой записи приложение строит публичную проекцию версии 1 и сохраняет её под ключом <code>article:public:guide-42</code>. Затем владелец записывает версию 2. Cache entry всё ещё содержит версию 1. Событие <code>ArticleChanged(2)</code> может задержаться, но read уже видит рассогласование и не должен вернуть v1 как обычный hit.</p>\n<p>Значение version должен выдавать владелец source. Не назначайте его в consumer-е и не используйте timestamp, если несколько записей могут получить одинаковое время. При успешной записи source version увеличивается. Событие несёт id и ту же version. Consumer удаляет entry только когда её версия меньше версии события.</p>\n<pre><code>function applyInvalidation(cache, key, eventVersion) {\n const entry = cache.get(key);\n if (!entry) return 'nothing-to-remove';\n if (entry.sourceVersion < eventVersion) {\n cache.delete(key);\n return 'removed-stale-entry';\n }\n return 'kept-current-entry';\n}\n\nfunction readPublic(source, cache, id) {\n const current = source.get(id);\n const key = `article:public:${id}`;\n const entry = cache.get(key);\n\n if (current.visibility !== 'public') {\n cache.delete(key);\n return { status: 'not-visible' };\n }\n if (entry && entry.sourceVersion > current.version) {\n return { status: 'source-read-behind-cache' };\n }\n if (!entry || entry.sourceVersion !== current.version) {\n const projection = {\n id: current.id,\n title: current.title,\n summary: current.summary,\n sourceVersion: current.version,\n };\n cache.set(key, projection);\n return { status: 'rebuilt', projection };\n }\n return { status: 'hit-current', projection: entry };\n}</code></pre>\n<p>В этом фрагменте <code>source.get</code> должен читать авторитетную запись, а не случайную реплику. Если read идёт с реплики, результат с <code>current.version</code>, меньшей уже сохранённой cache version, — сигнал отставания: его нельзя записывать поверх более новой entry, нужно повторить чтение с требуемой консистентностью. Возврат <code>source-read-behind-cache</code> делает такую ошибку видимой, вместо того чтобы принять её за обычный hit.</p>\n<p>Код показывает порядок решений, но не атомарность. <code>Map</code> не заменяет Redis или транзакционное хранилище. В реальном read отдельно опишите источник version, допустимое окно stale-read и гарантию для читателя. Запись rebuilt projection должна быть условной: конкурентный writer не должен позволить результату v1 перезаписать уже сохранённый v2. Нельзя переносить этот фрагмент в production без проверки атомарности, прав и конкурирующих обновлений.</p>\n<pre><code>const source = new Map([\n ['guide-42', { id: 'guide-42', title: 'Old', summary: 'v1', version: 1, visibility: 'public' }],\n]);\nconst cache = new Map([\n ['article:public:guide-42', { id: 'guide-42', title: 'Old', summary: 'v1', sourceVersion: 1 }],\n]);\n\nsource.set('guide-42', {\n id: 'guide-42', title: 'New', summary: 'v2', version: 2, visibility: 'public',\n});\nconsole.log(readPublic(source, cache, 'guide-42').status); // rebuilt\nconsole.log(cache.get('article:public:guide-42').sourceVersion); // 2\nconsole.log(applyInvalidation(cache, 'article:public:guide-42', 2)); // kept-current-entry\n\nsource.set('guide-42', {\n id: 'guide-42', title: 'New', summary: 'v2', version: 2, visibility: 'private',\n});\nconsole.log(readPublic(source, cache, 'guide-42').status); // not-visible</code></pre>\n<p>Запустите оба блока подряд в Node.js: второй использует <code>readPublic</code> и <code>applyInvalidation</code> из первого. <code>source</code> и <code>cache</code> — две независимые <code>Map</code>. Значения <code>version</code>, поле <code>visibility</code> и состав public projection — проектные; важен не текст <code>Old</code>/<code>New</code>, а наблюдаемые статусы и переходы версий.</p>\n<h2>Соберите доказательства до purge</h2>\n<p>Начните с одной жалобы и одной попытки чтения. Запишите source id, current version, вычисленный key, version entry, состояние события и visibility. Значения можно обезличить. Важно сохранить связь между ними. Если видны только два заголовка, старый и новый, нельзя отличить устаревшую entry от чтения другой проекции.</p>\n<div class=\"table-scroll\"><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 не проверяет version</td><td>Сравнить версии до и после controlled read</td><td>Добавить version guard или точечную инвалидацию</td></tr><tr><td>source v2, но key отличается</td><td>В key пропущен язык, tenant или reader scope</td><td>Вывести keys для двух проекций одного id</td><td>Исправить builder и проверить отсутствие collision</td></tr><tr><td>source private v3, entry public v2</td><td>Visibility проверяется после cache hit</td><td>Изменить только visibility и повторить public read</td><td>Запретить ответ и удалить entry до delivery события</td></tr><tr><td>source v2, entry v2, текст всё ещё старый</td><td>Ошибка renderer, клиента или другого cache layer</td><td>Сверить projection fields и цепочку ответа</td><td>Искать следующий слой, не очищать этот key вслепую</td></tr><tr><td>read source v1, entry v2</td><td>Реплика отстаёт или key ведёт к чужой проекции</td><td>Повторить authoritative read и сверить scope</td><td>Не перезаписывать v2, зафиксировать anomaly и повторить чтение</td></tr><tr><td>Позднее событие удаляет v2</td><td>Consumer удаляет entry при любом событии</td><td>Проверить условие <code>entry.sourceVersion < event.version</code></td><td>Оставить entry для равной version</td></tr></tbody></table></div>\n<figure><img src=\"/assets/editorial/2021/cache-diagnosis-2021.svg\" alt=\"Схема диагностики старой кешированной проекции: source version, public key, cache version, event и visibility приводят к разным действиям\" loading=\"lazy\" /><figcaption>Один старый текст не доказывает одну причину. Сначала соберите состояние, затем выберите ветку действия.</figcaption></figure>\n<h2>Задержанное событие и отрицательный путь</h2>\n<p>Событие обновления помогает быстро удалить entry, но read не должен зависеть от идеальной доставки. В последовательности ниже событие v2 приходит после read. До события cache содержит v1. Read сравнивает версии, строит v2 и сохраняет её. Когда consumer получает v2, он видит равные версии и оставляет entry. Это защищает от лишнего miss и от повторной пересборки.</p>\n<pre><code>build v1 cache: v1\nwrite source v2 event: pending, cache: v1\nread before delivery result: rebuilt v2\ndeliver ArticleChanged2 result: current entry kept\nread after delivery result: hit v2\nwrite visibility private result: deny + evict</code></pre>\n<p>Отрицательный путь важнее счастливого hit. Если source стал private, старая public entry нельзя отдавать до прихода события. Иначе задержка доставки превращается в утечку уже запрещённого представления. TTL не решает эту задачу: пять минут freshness не дают права показывать данные после изменения visibility.</p>\n<p>Версия также не защищает состав проекции. Не копируйте весь source object в public cache через spread. Явно перечислите поля, которые разрешены читателю. В примере это <code>id</code>, <code>title</code> и <code>summary</code>. Поле <code>editorNote</code> не должно попасть в entry даже при правильной version.</p>\n<h2>HTTP-кеш — соседний слой</h2>\n<p>HTTP validator и прикладная version решают разные задачи. <code>ETag</code> описывает выбранное HTTP-представление. <code>If-None-Match</code> позволяет запросу проверить его и получить <code>304</code>. Source version описывает порядок изменения записи у владельца. Эти значения можно связать, но нельзя считать взаимозаменяемыми. Разные язык, роль или формат ответа дадут разные представления.</p>\n<p>Если HTTP-кеш получил ответ со статусом <code>2xx</code> или <code>3xx</code> на unsafe-запрос, RFC 9111 требует инвалидировать target URI в этом кеше. Он может инвалидировать и другие URI из <code>Location</code> или <code>Content-Location</code>, но только при совпадающем origin. Это правило относится к HTTP-кешу, который получил ответ, и не очищает автоматически браузерный, reverse-proxy или прикладной кеши. Для каждого слоя назовите key, validator или purge contract. Затем проверьте конкретный ответ: статус <code>200</code> от origin сам по себе не доказывает свежесть downstream-слоёв.</p>\n<h2>Порядок действий</h2>\n<ol><li>Зафиксируйте симптом: какой reader получил какую старую проекцию и какова цена ошибки.</li><li>Сохраните source id, current version, read key, cache version, event state и visibility до очистки.</li><li>Проверьте, что key принадлежит нужному reader scope и включает каждый параметр, меняющий результат.</li><li>Сравните source version и cache version на контролируемом чтении. Не называйте cache hit корректным, пока версии не сопоставлены.</li><li>Для старой entry выполните rebuild или targeted invalidation. Не удаляйте весь namespace без причины.</li><li>Для изменения visibility проверьте deny и eviction до доставки события.</li><li>Проверьте delayed event: равная version не должна удалять актуальную entry; более новая version должна удалить старую.</li><li>Если версии совпадают, перенесите диагностику на renderer, клиент, HTTP или другой cache layer.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Пример использует один объект и память процесса. Он не проверяет Redis eviction, broker retries, outbox, репликацию, CDN, браузерный cache, multi-region и транзакцию между source и событием. Он также не даёт production latency, hit-rate или гарантии отсутствия stale-read. Эти свойства требуют отдельного теста на выбранном storage и реального маршрута.</p>\n<p>Решение готово к проверке на интеграционном маршруте, когда для одной тестовой записи видны все шесть фактов: source id, source version, key, cache version, event state и reader visibility. После записи v2 read не возвращает v1 как current hit. Позднее событие v2 сохраняет уже построенную v2. После переключения в private public read не возвращает значение и удаляет public entry. Если хотя бы одно условие нельзя доказать логом или тестом, сначала добавьте наблюдаемость, а не увеличивайте TTL и не включайте глобальный purge.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc9111.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9111 — HTTP Caching</a> — действующий стандарт IETF о freshness, validation и invalidation HTTP-ответов; он заменяет RFC 7234 и не задаёт контракт прикладного cache store.</li><li><a href=\"https://redis.io/docs/latest/develop/data-types/streams/\" target=\"_blank\" rel=\"noopener noreferrer\">Redis Streams</a> — официальная документация Redis о append-only stream, XADD, чтении и consumer groups; она описывает транспорт событий, но не гарантирует атомарность source и cache.</li></ul>"
|
||
}
|