8 lines
15 KiB
JSON
8 lines
15 KiB
JSON
{
|
||
"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 && 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 < 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>"
|
||
}
|