{ "index": 248, "slug": "editorial-2021-02-mechanism-cache-invalidation", "title": "Инвалидация кеша: как не вернуть известную устаревшую версию", "excerpt": "Кеш может быть быстрым и при этом выдавать старую запись после успешного обновления. Разбираем versioned invalidation, границы ключа, запаздывающее событие и проверку, которая не считает stale-значение cache hit.", "contentHtml": "
Пользователь меняет имя профиля, запрос на запись отвечает успешно, а следующая страница всё ещё показывает прежнее имя. Через несколько секунд всё исправляется само. За это время поддержка получает жалобу, оператор видит разные данные в соседних экранах, а команда спорит о том, был ли сбой в базе или в CDN.
Цена ошибки зависит от данных. Для профиля это недоверие к интерфейсу. Для цены или лимита — неверное решение. Для прав доступа — потенциальная утечка. Самый опасный случай начинается после успешной записи: источник уже знает новую версию, но кеш считает старую запись свежей.
Тезис простой: TTL отвечает на вопрос «как долго entry может жить», но не на вопрос «какую версию источник считает текущей». Надёжная инвалидация связывает владельца данных, ключ проекции, версию, событие изменения и правило read. Cache hit разрешён только тогда, когда версия и область выдачи совпадают с источником.
Источник владеет фактом. Он записывает профиль и увеличивает его версию. Кеш хранит производную публичную проекцию. Он не становится владельцем профиля только потому, что умеет быстро вернуть JSON.
source: { id: 42, version: 2, visibility: "public", name: "Ирина" } cache: { key: "profile:public:42", sourceVersion: 1, name: "Ира" } event: { type: "ProfileChanged", id: 42, version: 2 } current hit => cache.sourceVersion === source.version && source.visibility === "public"В примере источник уже содержит v2, а entry построена из v1. Наличие ключа и неистёкший TTL ничего не меняют. Если read вернёт «Ира», он выдаст известную старую проекцию. Правильное действие — перестроить entry из v2 или отказаться от ответа, если новая запись недоступна.
После записи команда отправляет событие и ждёт, что consumer удалит ключ. Это полезный быстрый путь, но не гарантия момента очистки. Брокер может задержать доставку. Consumer может повторить сообщение. Два слоя кеша могут получить его в разное время. Событие может прийти после того, как read уже построил новую entry.
Поэтому событие не должно быть единственным барьером. Его задача — ускорить очистку. Read должен закрыть окно между записью v2 и применением события. Для одного владельца и одного ключа consumer применяет событие только к более старой entry:
function invalidate(entry, event) { if (!entry) return "miss"; if (entry.sourceVersion < event.version) { cache.delete(entry.key); return "evicted-stale"; } return "kept-current"; }Сравнение защищает от запоздалого сообщения. Если read уже собрал v2, позднее событие v2 не должно удалять current entry. Если пришло событие v3, entry v2 устарела и её можно удалить. Это правило предполагает, что один source owner выдаёт монотонные версии для конкретного объекта. При нескольких владельцах нужна другая схема: например, составная версия или явная модель конфликтов.
Ключ должен включать каждое условие, которое меняет выдаваемую проекцию или право её увидеть. Для публичного профиля это может быть profile:public:42. Если ответ зависит от языка, региона, роли или набора полей, эти границы должны быть отражены в ключе либо проверены до cache hit.
Нельзя просто добавить в ключ все доступные параметры. Лишнее поле дробит кеш и ухудшает диагностику. Отсутствующее значимое поле смешивает варианты, которые нельзя смешивать. Если запись стала private, старая public entry должна исчезнуть даже при неистёкшем TTL.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| После записи видна старая версия | Read доверяет TTL или наличию ключа | Сравнить entry.sourceVersion и версию source в одном запросе | Перестроить entry при несовпадении |
| Кеш очищается с задержкой | Событие ждёт брокер или consumer | Сопоставить время write, publish, apply и следующего read | Оставить version guard на read, а событие использовать как ускоритель |
| Новая entry удаляется повторно | Consumer удаляет ключ для любого event | Отправить event v2 после построения entry v2 | Удалять только при entry.version < event.version |
| Private-данные видны из public endpoint | Visibility не проверяется перед hit | Сменить public на private при живой public entry | Сначала проверить право выдачи, затем evict и вернуть отказ |
| Разные пользователи получают один ответ | В ключе нет tenant, роли или другого влияющего признака | Повторить запросы с двумя наборами прав и сравнить key | Расширить ключ или запретить кеширование такой проекции |
Упрощённый read-path выглядит так: получить source, проверить visibility, получить entry, сравнить версии, вернуть entry или построить новую. Порядок важен. Проверка доступа после cache hit уже слишком поздно, а проверка только TTL не знает о последней записи.
async function readPublicProfile(id) { const record = await source.get(id); const key = `profile:public:${id}`; if (!record || record.visibility !== "public") { await cache.delete(key); return { status: "not-visible" }; } const entry = await cache.get(key); if (entry?.sourceVersion === record.version) return { status: "hit-current", value: entry.value }; const value = { id: record.id, name: record.name }; await cache.set(key, { sourceVersion: record.version, value }); return { status: entry ? "stale-rebuilt" : "miss-built", value }; }Код демонстрационный. Он показывает контракт одного объекта и одной public-проекции, а не готовую библиотеку кеширования. В рабочей системе нужно определить, где хранится версия, как читается источник, что происходит при replica lag и может ли public path обращаться к source. Если такой read слишком дорог, нельзя молча убрать проверку и оставить прежнее обещание. Нужно явно принять допустимое окно stale и доказать его отдельным тестом.
TTL ограничивает срок жизни entry. Он помогает освобождать память, уменьшать риск вечного старого значения и задавать верхнюю границу для систем, где источник нельзя проверять на каждом чтении. Но TTL не знает, что запись изменилась через миллисекунду после построения entry.
Если бизнес допускает stale-ответ не дольше минуты, TTL может быть частью контракта. Если после успешной записи пользователь должен сразу увидеть новую цену, одного TTL недостаточно. Нужны version check, событие, явная очистка или другой протокол с такой гарантией. Название директивы не переносит гарантию из HTTP-кеша в кеш приложения.
hit-current, stale-rebuilt, miss-built, not-visible. Они отличают правильную перестройку от обычного cache miss.Versioned invalidation не делает распределённую систему строго согласованной автоматически. Если source и cache читаются из реплик с разной задержкой, read может увидеть старую версию источника и принять старую entry за current. Если событие потеряло key или версию, consumer не сможет безопасно удалить нужную проекцию. Если одна запись питает несколько ключей, одного сравнения недостаточно: нужен список зависимостей или общий namespace.
Отрицательный путь должен быть безопасным. При недоступном source нельзя возвращать старую private-проекцию через public key. При неизвестной версии нельзя считать entry current. При неоднозначном ключе лучше сделать miss или отказать в выдаче, чем смешать варианты. При разрешённом stale нужно назвать срок и показать, где он измеряется.
Не переносите код выше в рабочую систему без проверки хранилища, конкурентных записей, прав доступа, сериализации и отказов сети. Пример ограничен одной записью, одним владельцем и одной проекцией. Его цель — сделать порядок решений проверяемым.
Механизм готов для выбранного пути, если команда может назвать пять вещей: владельца source, точный cache key, источник версии, формат события и условие current hit. Автоматическая проверка должна воспроизвести четыре состояния: v1/v1 возвращает current, source v2 при cache v1 перестраивает ответ до delivery события, запоздалое событие v2 не удаляет entry v2, а private source удаляет public entry и не возвращает значение.
Отдельно проверьте журналы времени write, publish, apply и read. Не подменяйте это проверкой «в итоге стало правильно». Нужен результат каждого состояния и причина решения. Тогда инвалидация перестаёт быть надеждой на таймер и становится контрактом, который можно нарушить, обнаружить и исправить.