{ "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 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Код показывает только порядок решений. Map не заменяет Redis или транзакционное хранилище. В реальном read source может быть дорогим или отставать на реплике. Тогда нужно отдельно описать, откуда приходит version, какой stale window допустим и какую гарантию получает читатель. Нельзя переносить этот фрагмент в production без проверки атомарности записи, прав и конкурирующих обновлений.
Начните с одной жалобы и одной попытки чтения. Запишите 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 вслепую |
| Позднее событие удаляет 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 описывает порядок изменения записи у владельца. Эти значения можно связать, но нельзя считать взаимозаменяемыми. Разные язык, роль или формат ответа дадут разные представления.
Успешный unsafe HTTP-запрос инвалидирует target URI в том cache, который его обработал, но это не очищает автоматически браузерный, 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