Redis: инвалидация и поколение каталога
TTL задаёт срок существования копии, но успешное изменение остатков должно становиться видимым быстрее. Добавим поколение каталога: номер версии основных данных входит в ключ кеша. После изменения увеличиваем поколение, и новые чтения используют другое имя ключа. Старые копии остаются до своего TTL, однако больше не являются текущим представлением каталога.
Третий снимок stages/03 меняет путь чтения; запись поколения будет использована воркером после введения импорта. Для объяснения изменения рассматриваем условную смену номера с 0 на 1. Redis и программы не запускались, статус not_run; ниже ожидаемые переходы, а не выполненные команды. Эта схема применяется к одному маленькому общему каталогу и не требует перечислять все старые ключи.
Версия становится частью имени
Сначала читаем importer:generation. Если ключ отсутствует, используем строку 0. Полученное значение включаем в importer:cache:catalog:{generation}. Затем ищем копию именно этого поколения. Отсутствующая версия не означает ошибку: в свежем учебном Redis каталог ещё не изменялся, поэтому начальная версия определена договором. После первой записи номер должен возрастать через INCR, а не произвольную установку из HTTP-запроса.
Полный файл cache.js показывает последовательность. TTL остаётся тридцатисекундным, поскольку старые поколения требуется освобождать независимо от того, пользуется ли ими кто-нибудь после изменения.
import { readCatalog, generationKey } from './catalog.js';
export async function cachedCatalog(redis) {
const generation = await redis.get(generationKey) || '0';
const key = `importer:cache:catalog:${generation}`;
const cached = await redis.get(key);
if (cached) return { source: 'cache', items: JSON.parse(cached) };
const items = await readCatalog(redis);
await redis.set(key, JSON.stringify(items), 'EX', 30);
return { source: 'primary', items };
}
При версии 0 запрос читает ключ с окончанием 0. После перехода к версии 1 новый запрос не найдёт старую строку под новым именем, прочитает источник и запишет актуальную копию с окончанием 1. При этом старый ключ не нужно удалять синхронно. Его срок жизни выполняет уборку, а поколение выполняет переключение. Два механизма дополняют друг друга, не дублируя одну и ту же обязанность.
Запоздавшее заполнение не становится текущим
Рассмотрим гонку. Запрос A прочитал поколение 0 и начал читать источник. Затем воркер изменил остатки и увеличил поколение до 1. Если A сохранил полученную старую копию позже, он всё равно записывает её под ключ поколения 0. Запрос B уже читает версию 1 и не использует эту копию. Схема уменьшает влияние запоздавшего заполнения, потому что вычисление привязано к версии, полученной до его начала.
Однако ответ самому запросу A может отражать предыдущее состояние. Поколение не превращает несколько отдельных команд Redis в согласованный снимок с моментальной актуальностью. Оно защищает прежде всего текущий namespace кеша от старой записи. Для строгого чтения версии и данных потребуется атомарная операция либо транзакционный источник. Поэтому важно назвать, какую гонку мы устранили, и не обещать отсутствие любых устаревших ответов.
Ниже ожидаемая последовательность имён ключей. Это иллюстрация, не вывод реально выполненного Redis-сеанса.
generation=0 → importer:cache:catalog:0
изменение основных данных; INCR generation
generation=1 → importer:cache:catalog:1
поздняя запись запроса A → всё ещё importer:cache:catalog:0
Изменение и версия должны согласовываться
Если сначала увеличить поколение, а затем изменить остатки, читатель новой версии может сохранить прежние данные уже под текущим ключом. Если изменить остатки, но процесс завершится до INCR, новые чтения продолжат находить старую копию. Поэтому окончательный воркер должен менять источник и поколение одной атомарной операцией. В шестом учебном состоянии покажем простой вариант и его ограничение, а восьмой урок объединит изменения в Lua.
Число поколения не присылается пользователем. Это внутренний серверный признак состояния каталога, который возрастает при успешном применении пакета. Клиентская попытка импортировать данные не означает успешную запись, поэтому HTTP-ответ постановки в очередь не увеличивает версию. В противном случае кеш очищался бы для задач, которые ещё ждут обработки или уже завершились ошибкой без изменения данных.
Версия относится ко всему списку, поэтому один импорт товара инвалидирует полную копию. Для маленькой модели это понятная цена. При большом каталоге можно иметь поколения страниц, групп или отдельных карточек, однако нужно учитывать запросы, объединяющие несколько объектов. Более мелкое разделение уменьшает объём пересчёта, но увеличивает сложность согласования версий и вероятность пропустить зависимую копию.
Почему не просто DEL
Удаление одного cache-ключа после записи выглядит проще. Но уже начавшийся запрос может завершить чтение старых данных после DEL и снова заполнить этот же текущий ключ. Поколение меняет имя, поэтому старое вычисление не возвращает старую строку в активный namespace. DEL подходит там, где гонка приемлема или заполнение согласовано отдельным механизмом. Выбор зависит от требуемой актуальности и стоимости решения, а не от названия команды.
Запуск множества изменений создаёт множество старых поколений. TTL ограничивает длительность их существования, но суммарная память зависит от частоты записей и размера копии. Если каждый импорт создаёт новую многомегабайтную строку, тридцать секунд могут означать заметный объём. Для такого приложения стоит пересмотреть размер кешируемого объекта или частоту заполнения; повышение лимита памяти не устраняет неудачную структуру представления.
При восстановлении старой резервной копии Redis поколение и данные должны восстанавливаться согласованно. Если сохранить только продукты, но оставить кеш и версию другого состояния, приложение может читать несоответствующую копию. В реальном сервисе кеш обычно можно сбросить отдельно после восстановления, а основные данные и отметки импорта требуют общего решения. Учебный пример не выполняет резервирование и не выдаёт namespace за полноценную стратегию восстановления.
Поколение полезно также для диагностики. Если GET возвращает старый остаток, сравните текущую версию и ключ, по которому была получена копия. Одинаковое имя после успешного импорта показывает, что путь записи не увеличил generation или сделал это не в том окружении. Разные версии с неправильными данными указывают уже на согласование источника и заполнения. Такая проверка объясняет проблему предметно, вместо случайного удаления всех ключей базы.
Не используйте поиск и удаление всех ключей по общему шаблону на действующей базе ради каждого изменения каталога. Он может затронуть очередь или чужое приложение и создаёт стоимость, зависящую от количества данных. В нашем контракте namespace известен, а текущий ключ вычисляется из одного значения поколения. Уборка старых копий выполняется TTL и не требует обхода всего Redis при каждой записи.
Команда увеличения описана в Redis INCR, а срок копий — в SET. После третьего состояния чтение привязано к версии, а будущая успешная запись получит обязанность атомарно изменить источник и generation. Следующим шагом ограничим частоту поступающих запросов, прежде чем разрешать им создавать фоновые задачи.