{ "index": 247, "slug": "editorial-2021-02-field-cache-invalidation", "title": "Инвалидация кеша: как доказать, что читатель получил старую проекцию", "excerpt": "Пользователь видит старое значение после записи в source. Разбираем key, version, delayed event и visibility, чтобы выбрать точечное действие вместо очистки всего кеша.", "contentHtml": "
Пользователь меняет имя, получает успешный ответ, а соседняя страница ещё час показывает старое значение. Команда очищает весь кеш. Симптом исчезает, но причина остаётся неизвестной: событие задержалось, запрос попал в другой key, проекция не обновилась или браузер показывает старый ответ. Цена ошибки — не только одна жалоба. Полная очистка создаёт лишнюю нагрузку, стирает след диагностики и может скрыть проблему до следующего изменения.
\nКеш нельзя считать текущим только потому, что запись в него существует. Текущесть должна следовать из контракта: какой source владеет данными, какой key обозначает проекцию, какая version попала в entry и имеет ли читатель право получить эту проекцию. Если этих фактов нет, команда спорит о TTL и purge, не проверяя состояние.
\nДля одного read path достаточно связать source id, монотонную version, read key и cache entry. При чтении сравните version source с version entry. Если entry старше, пересоберите проекцию или удалите её. Если source стал недоступен публичному читателю, сначала запретите ответ и удалите entry. Позднее событие не должно удалять уже актуальную entry.
\nЭта схема относится к кешу прикладной проекции. Она не делает базу, брокер и HTTP-кеш одной системой. Один и тот же объект может иметь разные проекции для языка, tenant или роли. В таком случае каждый контекст входит в контракт key и проверяется отдельно.
\nПусть source хранит запись guide-42. После первой записи приложение строит публичную проекцию версии 1 и сохраняет её под ключом article:public:guide-42. Затем владелец записывает версию 2. Cache entry всё ещё содержит версию 1. Событие ArticleChanged(2) может задержаться, но read уже видит рассогласование и не должен вернуть v1 как обычный hit.
Значение version должен выдавать владелец source. Не назначайте его в consumer-е и не используйте timestamp, если несколько записей могут получить одинаковое время. При успешной записи source version увеличивается. Событие несёт id и ту же version. Consumer удаляет entry только когда её версия меньше версии события.
\nfunction 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}\nВ этом фрагменте source.get должен читать авторитетную запись, а не случайную реплику. Если read идёт с реплики, результат с current.version, меньшей уже сохранённой cache version, — сигнал отставания: его нельзя записывать поверх более новой entry, нужно повторить чтение с требуемой консистентностью. Возврат source-read-behind-cache делает такую ошибку видимой, вместо того чтобы принять её за обычный hit.
Код показывает порядок решений, но не атомарность. Map не заменяет Redis или транзакционное хранилище. В реальном read отдельно опишите источник version, допустимое окно stale-read и гарантию для читателя. Запись rebuilt projection должна быть условной: конкурентный writer не должен позволить результату v1 перезаписать уже сохранённый v2. Нельзя переносить этот фрагмент в production без проверки атомарности, прав и конкурирующих обновлений.
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\nЗапустите оба блока подряд в Node.js: второй использует readPublic и applyInvalidation из первого. source и cache — две независимые Map. Значения version, поле visibility и состав public projection — проектные; важен не текст Old/New, а наблюдаемые статусы и переходы версий.
Начните с одной жалобы и одной попытки чтения. Запишите source id, current version, вычисленный key, version entry, состояние события и visibility. Значения можно обезличить. Важно сохранить связь между ними. Если видны только два заголовка, старый и новый, нельзя отличить устаревшую entry от чтения другой проекции.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| source v2, entry v1, key совпадает | Событие задержалось или read не проверяет version | Сравнить версии до и после controlled read | Добавить version guard или точечную инвалидацию |
| source v2, но key отличается | В key пропущен язык, tenant или reader scope | Вывести keys для двух проекций одного id | Исправить builder и проверить отсутствие collision |
| source private v3, entry public v2 | Visibility проверяется после cache hit | Изменить только visibility и повторить public read | Запретить ответ и удалить entry до delivery события |
| source v2, entry v2, текст всё ещё старый | Ошибка renderer, клиента или другого cache layer | Сверить projection fields и цепочку ответа | Искать следующий слой, не очищать этот key вслепую |
| read source v1, entry v2 | Реплика отстаёт или key ведёт к чужой проекции | Повторить authoritative read и сверить scope | Не перезаписывать v2, зафиксировать anomaly и повторить чтение |
| Позднее событие удаляет v2 | Consumer удаляет entry при любом событии | Проверить условие entry.sourceVersion < event.version | Оставить entry для равной version |
Событие обновления помогает быстро удалить entry, но read не должен зависеть от идеальной доставки. В последовательности ниже событие v2 приходит после read. До события cache содержит v1. Read сравнивает версии, строит v2 и сохраняет её. Когда consumer получает v2, он видит равные версии и оставляет entry. Это защищает от лишнего miss и от повторной пересборки.
\nbuild 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\nОтрицательный путь важнее счастливого hit. Если source стал private, старая public entry нельзя отдавать до прихода события. Иначе задержка доставки превращается в утечку уже запрещённого представления. TTL не решает эту задачу: пять минут freshness не дают права показывать данные после изменения visibility.
\nВерсия также не защищает состав проекции. Не копируйте весь source object в public cache через spread. Явно перечислите поля, которые разрешены читателю. В примере это id, title и summary. Поле editorNote не должно попасть в entry даже при правильной version.
HTTP validator и прикладная version решают разные задачи. ETag описывает выбранное HTTP-представление. If-None-Match позволяет запросу проверить его и получить 304. Source version описывает порядок изменения записи у владельца. Эти значения можно связать, но нельзя считать взаимозаменяемыми. Разные язык, роль или формат ответа дадут разные представления.
Если HTTP-кеш получил ответ со статусом 2xx или 3xx на unsafe-запрос, RFC 9111 требует инвалидировать target URI в этом кеше. Он может инвалидировать и другие URI из Location или Content-Location, но только при совпадающем origin. Это правило относится к HTTP-кешу, который получил ответ, и не очищает автоматически браузерный, reverse-proxy или прикладной кеши. Для каждого слоя назовите key, validator или purge contract. Затем проверьте конкретный ответ: статус 200 от origin сам по себе не доказывает свежесть downstream-слоёв.
Пример использует один объект и память процесса. Он не проверяет Redis eviction, broker retries, outbox, репликацию, CDN, браузерный cache, multi-region и транзакцию между source и событием. Он также не даёт production latency, hit-rate или гарантии отсутствия stale-read. Эти свойства требуют отдельного теста на выбранном storage и реального маршрута.
\nРешение готово к проверке на интеграционном маршруте, когда для одной тестовой записи видны все шесть фактов: 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.
\n