{ "index": 249, "slug": "editorial-2021-02-practice-cache-invalidation", "title": "Инвалидация кеша: как не вернуть устаревшие данные", "excerpt": "Источник уже хранит новую версию, а читатель получает старую карточку. Разбираем ключ, версию, событие изменения и проверку чтения на коротком контролируемом примере.", "contentHtml": "
Пользователь меняет название статьи. В базе уже лежит новая строка, но соседняя страница ещё показывает старый заголовок. Иногда устаревший ответ живёт минуты, иногда — до истечения TTL. Цена ошибки зависит от данных: читатель может увидеть старый статус заказа, прежнюю цену или публичную карточку снятого с публикации материала.
\nПервый порыв — увеличить частоту очистки или удалить случайный ключ после жалобы. Это убирает один симптом, но не объясняет, какой ответ устарел и почему следующий запрос снова получил его. Надёжнее считать cache entry актуальной только тогда, когда она относится к нужной проекции и собрана из текущей версии источника.
\nTTL отвечает на один вопрос: сколько времени запись можно хранить. Он не отвечает на другой: была ли запись изменена после того, как её положили в кеш. Если источник сменился через секунду после записи, десятиминутный TTL оставит старую проекцию ещё на 599 секунд.
\nИнвалидация должна связывать четыре факта: владельца исходной записи, ключ читательской проекции, версию записи и событие изменения. На чтении система проверяет, что эти факты согласованы. Событие ускоряет удаление старой entry, но не должно быть единственной защитой: оно может задержаться, потеряться или прийти после следующего изменения.
\nВозьмём запись guide-42. Ею владеет source service. Запись имеет поля title, visibility и монотонную version. Публичный читатель получает только id, title и sourceVersion. Внутреннее поле editorNote не должно попасть в кеш даже при успешном cache hit.
Ключ описывает не объект вообще, а конкретный ответ. Поэтому article:public:guide-42 лучше, чем article:guide-42. В первом варианте видна область чтения. Если позже появятся язык, tenant или роль, каждый параметр нужно добавить только после проверки: меняет ли он содержание и право получить ответ.
| Факт | Пример | Проверка |
|---|---|---|
| Источник | guide-42, version 2 | Только владелец создаёт следующую версию |
| Ключ | article:public:guide-42 | В ключе указана публичная область |
| Проекция | id, title, sourceVersion | В ней нет editorNote |
| Событие | ArticleChanged, version 2 | Версия события сравнивается с entry |
| Условие hit | entry version равна source version | Иначе ответ пересобирается |
Запись источника и событие должны нести одну версию. Тогда consumer может отличить старое уведомление от нового. Timestamp не всегда подходит: разные часы, точность округления и повторная доставка усложняют сравнение. Версия выражает порядок изменений одного владельца.
\nНиже — учебный фрагмент на обычном JavaScript. Он использует только Map и синтетические данные. Фрагмент показывает контракт и порядок проверок, но не является готовым адаптером для Redis, CDN, брокера или конкретного фреймворка.
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}\nПервое чтение создаёт entry версии 1. Затем источник получает заголовок «Версия 2» и увеличивает version. Если событие ещё не дошло, следующий readPublic всё равно видит рассогласование и пересобирает проекцию. Он не называет старую entry актуальным hit.
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'\nИзменение visibility требует особой ветки. Нельзя ждать только event consumer-а: до его запуска публичный ключ уже опасен. Read должен проверить право показа, удалить недопустимую entry и вернуть отказ. Кеш не заменяет авторизацию.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Источник v2, entry v1, ключ совпадает | Событие задержалось или read не сравнивает версии | Воспроизвести write → delayed event → read | Добавить version guard или точечную очистку |
| Источник v2, но читается другой ключ | Ключ не содержит область, язык или tenant | Вывести ключи двух проекций и сравнить поля | Исправить контракт ключа и тест collision |
| Источник private, entry public | Visibility проверяется после cache hit | Сменить только visibility и повторить public read | Сначала отказать и удалить entry |
| Источник v2, entry v2, UI старый | Кеш не доказан как причина | Сверить projection и слой renderer | Проверить другой кеш или клиентское состояние |
| Позднее событие удаляет entry v2 | Consumer удаляет по любому событию | Проверить условие entry.version < event.version | Не удалять current entry |
Vary, ETag или другой validator. Не смешивайте их с внутренней version без явного контракта.Синхронное сравнение source и cache в примере не обещает строгую согласованность распределённой системы. В настоящем сервисе источник, кеш, брокер, реплика и CDN имеют разные задержки. Если read не может получить текущую версию, нужно документировать допустимое stale window и использовать выбранный механизм validation. Нельзя объявлять проблему решённой только потому, что событие записалось в журнал.
\nTTL остаётся полезным предохранителем от бесконечной жизни entry. Он ограничивает ущерб при сбое очистки, но не заменяет событие и проверку версии. Полная очистка кеша тоже не универсальное решение: она создаёт всплеск запросов к источнику и не исправляет неправильный key или ошибочную проекцию.
\nHTTP-кеш имеет собственные правила. RFC 9111 описывает freshness, validation, cache key и invalidation ответов. RFC 9110 описывает семантику HTTP и условные запросы. Эти правила помогают спроектировать HTTP-слой, но не превращают прикладную Map в HTTP-кеш и не проверяют права пользователя.
Для одной выбранной проекции можно назвать владельца источника, точный ключ, версию, событие и условие current hit. Контролируемый тест подтверждает три отрицательных пути: source v2 не возвращает entry v1 как hit до доставки события; запоздалое событие не удаляет entry v2; private source не выдаётся через public key. Только после этого имеет смысл подключать реальный cache store и проверять тот же контракт на интеграционном маршруте.
\n