Files
progcode/editorial/agent-rewrites/249.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
15 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 249,
"slug": "editorial-2021-02-practice-cache-invalidation",
"title": "Инвалидация кеша: как не вернуть устаревшие данные",
"excerpt": "Источник уже хранит новую версию, а читатель получает старую карточку. Разбираем ключ, версию, событие изменения и проверку чтения на коротком контролируемом примере.",
"contentHtml": "<p>Пользователь меняет название статьи. В базе уже лежит новая строка, но соседняя страница ещё показывает старый заголовок. Иногда устаревший ответ живёт минуты, иногда — до истечения TTL. Цена ошибки зависит от данных: читатель может увидеть старый статус заказа, прежнюю цену или публичную карточку снятого с публикации материала.</p>\n<p>Первый порыв — увеличить частоту очистки или удалить случайный ключ после жалобы. Это убирает один симптом, но не объясняет, какой ответ устарел и почему следующий запрос снова получил его. Надёжнее считать cache entry актуальной только тогда, когда она относится к нужной проекции и собрана из текущей версии источника.</p>\n<h2>Тезис: TTL не сообщает, что данные изменились</h2>\n<p>TTL отвечает на один вопрос: сколько времени запись можно хранить. Он не отвечает на другой: была ли запись изменена после того, как её положили в кеш. Если источник сменился через секунду после записи, десятиминутный TTL оставит старую проекцию ещё на 599 секунд.</p>\n<p>Инвалидация должна связывать четыре факта: владельца исходной записи, ключ читательской проекции, версию записи и событие изменения. На чтении система проверяет, что эти факты согласованы. Событие ускоряет удаление старой entry, но не должно быть единственной защитой: оно может задержаться, потеряться или прийти после следующего изменения.</p>\n<h2>Механизм на одной публичной карточке</h2>\n<p>Возьмём запись <code>guide-42</code>. Ею владеет source service. Запись имеет поля <code>title</code>, <code>visibility</code> и монотонную <code>version</code>. Публичный читатель получает только <code>id</code>, <code>title</code> и <code>sourceVersion</code>. Внутреннее поле <code>editorNote</code> не должно попасть в кеш даже при успешном cache hit.</p>\n<p>Ключ описывает не объект вообще, а конкретный ответ. Поэтому <code>article:public:guide-42</code> лучше, чем <code>article:guide-42</code>. В первом варианте видна область чтения. Если позже появятся язык, tenant или роль, каждый параметр нужно добавить только после проверки: меняет ли он содержание и право получить ответ.</p>\n<table><caption>Контракт одной cache entry</caption><thead><tr><th scope=\"col\">Факт</th><th scope=\"col\">Пример</th><th scope=\"col\">Проверка</th></tr></thead><tbody><tr><td>Источник</td><td><code>guide-42</code>, version 2</td><td>Только владелец создаёт следующую версию</td></tr><tr><td>Ключ</td><td><code>article:public:guide-42</code></td><td>В ключе указана публичная область</td></tr><tr><td>Проекция</td><td><code>id</code>, <code>title</code>, <code>sourceVersion</code></td><td>В ней нет <code>editorNote</code></td></tr><tr><td>Событие</td><td><code>ArticleChanged</code>, version 2</td><td>Версия события сравнивается с entry</td></tr><tr><td>Условие hit</td><td>entry version равна source version</td><td>Иначе ответ пересобирается</td></tr></tbody></table>\n<p>Запись источника и событие должны нести одну версию. Тогда consumer может отличить старое уведомление от нового. Timestamp не всегда подходит: разные часы, точность округления и повторная доставка усложняют сравнение. Версия выражает порядок изменений одного владельца.</p>\n<h2>Конкретный пример</h2>\n<p>Ниже — учебный фрагмент на обычном JavaScript. Он использует только <code>Map</code> и синтетические данные. Фрагмент показывает контракт и порядок проверок, но не является готовым адаптером для Redis, CDN, брокера или конкретного фреймворка.</p>\n<pre><code>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 &amp;&amp; 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}</code></pre>\n<p>Первое чтение создаёт entry версии 1. Затем источник получает заголовок «Версия 2» и увеличивает <code>version</code>. Если событие ещё не дошло, следующий <code>readPublic</code> всё равно видит рассогласование и пересобирает проекцию. Он не называет старую entry актуальным hit.</p>\n<pre><code>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'</code></pre>\n<p>Изменение <code>visibility</code> требует особой ветки. Нельзя ждать только event consumer-а: до его запуска публичный ключ уже опасен. Read должен проверить право показа, удалить недопустимую entry и вернуть отказ. Кеш не заменяет авторизацию.</p>\n<figure><img src=\"/assets/editorial/2021/cache-key-lifecycle-2021.svg\" alt=\"Схема жизненного цикла публичной cache entry: source меняется с версии 1 на версию 2, запоздалое событие приходит после чтения, а read сравнивает версии и пересобирает публичную проекцию\" loading=\"lazy\" /><figcaption>Версия на read-path защищает от устаревшей entry в промежутке между записью источника и доставкой события.</figcaption></figure>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><caption>Диагностика stale-read</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Источник v2, entry v1, ключ совпадает</td><td>Событие задержалось или read не сравнивает версии</td><td>Воспроизвести write → delayed event → read</td><td>Добавить version guard или точечную очистку</td></tr><tr><td>Источник v2, но читается другой ключ</td><td>Ключ не содержит область, язык или tenant</td><td>Вывести ключи двух проекций и сравнить поля</td><td>Исправить контракт ключа и тест collision</td></tr><tr><td>Источник private, entry public</td><td>Visibility проверяется после cache hit</td><td>Сменить только visibility и повторить public read</td><td>Сначала отказать и удалить entry</td></tr><tr><td>Источник v2, entry v2, UI старый</td><td>Кеш не доказан как причина</td><td>Сверить projection и слой renderer</td><td>Проверить другой кеш или клиентское состояние</td></tr><tr><td>Позднее событие удаляет entry v2</td><td>Consumer удаляет по любому событию</td><td>Проверить условие <code>entry.version &lt; event.version</code></td><td>Не удалять current entry</td></tr></tbody></table>\n<h2>Порядок действий</h2>\n<ol><li>Опишите симптом: какой читатель получил какую старую проекцию и чем это опасно.</li><li>Зафиксируйте source id, текущую версию, вычисленный read key, версию cache entry, состояние события и visibility.</li><li>Проверьте, что ключ относится к нужной области чтения и включает все параметры, которые меняют ответ.</li><li>Сравните версии до очистки ключа. Если source новее entry, воспроизведите чтение при задержанном событии.</li><li>Проверьте projection whitelist. В кеше должны лежать только поля, разрешённые этой проекцией.</li><li>Отдельно проверьте изменение visibility. Public read должен отказать до доставки события.</li><li>Сделайте consumer идемпотентным: запоздалое событие не удаляет entry, собранную из более новой версии.</li><li>Для HTTP-слоя отдельно проверьте cache key, <code>Vary</code>, <code>ETag</code> или другой validator. Не смешивайте их с внутренней version без явного контракта.</li></ol>\n<h2>Ограничения</h2>\n<p>Синхронное сравнение source и cache в примере не обещает строгую согласованность распределённой системы. В настоящем сервисе источник, кеш, брокер, реплика и CDN имеют разные задержки. Если read не может получить текущую версию, нужно документировать допустимое stale window и использовать выбранный механизм validation. Нельзя объявлять проблему решённой только потому, что событие записалось в журнал.</p>\n<p>TTL остаётся полезным предохранителем от бесконечной жизни entry. Он ограничивает ущерб при сбое очистки, но не заменяет событие и проверку версии. Полная очистка кеша тоже не универсальное решение: она создаёт всплеск запросов к источнику и не исправляет неправильный key или ошибочную проекцию.</p>\n<p>HTTP-кеш имеет собственные правила. RFC 9111 описывает freshness, validation, cache key и invalidation ответов. RFC 9110 описывает семантику HTTP и условные запросы. Эти правила помогают спроектировать HTTP-слой, но не превращают прикладную <code>Map</code> в HTTP-кеш и не проверяют права пользователя.</p>\n<h2>Критерий готовности</h2>\n<p>Для одной выбранной проекции можно назвать владельца источника, точный ключ, версию, событие и условие current hit. Контролируемый тест подтверждает три отрицательных пути: source v2 не возвращает entry v1 как hit до доставки события; запоздалое событие не удаляет entry v2; private source не выдаётся через public key. Только после этого имеет смысл подключать реальный cache store и проверять тот же контракт на интеграционном маршруте.</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> — актуальный Internet Standard о cache key, freshness, validation и invalidation; заменяет RFC 7234.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics</a> — актуальный Internet Standard о семантике HTTP и условных запросах; не задаёт контракт прикладного кеша.</li></ul>"
}