Сохранение честности кэшей Redis в Node.js: аннулирование данных без использования устаревших значений
Сравните механизмы TTL, удаления данных при записи, модели pub/sub и аннулирования ключей с версионированием в Redis для Node.js, рассмотрите проблемы конкурентности, которые они создают, и разработайте обертку для кэша с режимом автоматического перехода в неисправный режим.
Источники: Redis Caching in Node.js, Part 2: Invalidation That Doesn’t Lie
Представьте пользователя, который загружает новое фото профиля, получает сообщение об успехе, перезагружает страницу, но по-прежнему видит старое фото. Он пробует снова, и результат остается тем же; лишь спустя несколько минут новое фото появляется само собой. Сообщение об ошибках, которое следует за этим, практически невозможно воспроизвести, поскольку Redis продолжает предоставлять копию записи, которую база данных уже заменила. Медленная база данных по крайней мере возвращает правильный ответ; быстрая кэш-система, возвращающая неверный ответ, хуже, чем отсутствие кэша вовсе. В этом руководстве рассматривается процесс записи в кэш Redis в Node.js: четыре способа аннулирования действительности записей, ситуации, когда каждый из способов оправдан с точки зрения затрат, условия конкуренции, которые остаются при использовании каждого способа, переиспользуемый обёрточный функция cacheAside(), сочетающий механизмы защиты при чтении с процедурой аннулирования действительности, а также ошибки, которые чаще всего приводят к сохранению устаревших значений.
Аспекты чтения из кэша, такие как загрузка данных в обход кэша, защита от перегрузки и действия при истечении срока действия «горячего» ключа, являются отдельной темой; наш обзор основ Redis-кэширования, типичных ошибок и вопросов для собеседований охватывает базовые аспекты. Ни один из этих методов не предотвращает наличие устаревших данных после записи, что и рассматривается далее в этой статье.
Четыре стратегии аннулирования данных
Приведенные ниже стратегии перечислены примерно в том порядке, в котором их следует рассматривать, от самой простой до самой сложной.
Стратегия 1: использование только TTL
Установите срок действия для каждой записи, согласитесь с тем, что значение может быть устаревшим на указанное количество секунд, и запишите этот показатель в месте, доступном команде по разработке продукта.
Это правильный ответ для ситуаций, когда объем данных превышает ожидания большинства команд. Копии каталогов, иерархии категорий, тексты публичных профилей и часовые курсы валют редко требуют актуальности с точностью до миллисекунды. Если отставание значений может составлять пять минут, достаточно установить срок действия в 300 секунд:
await redis.set(`product:${id}`, JSON.stringify(product), 'EX', 300);
С профильным фото всё иначе. Человек, внесший изменения, ожидает увидеть его немедленно, а срок действия не может этого гарантировать. Для данных, редактируемых пользователем, необходимо аннулировать их действительность сразу после записи.
Стратегия 2: удаление записи при записи
После обновления базы данных следует удалить соответствующий ключ кэша. При следующем запросе, который не найдет данные в кэше, будет загружена новая строка и кэш снова заполнится:
async function updateUser(id, patch) {
const updated = await db.users.update(id, patch);
await redis.del(`user:${id}`);
return updated;
}
Два фактора обеспечивают правильную работу этого подхода.
Удаляйте, а не перезаписывайте. Поскольку у вас уже есть обновленное значение, запись его в Redis с помощью команды SET кажется простой оптимизацией. Проблема возникает при двух одновременных операциях записи: их команды SET могут достичь Redis в обратном порядке по сравнению с коммитами в базе данных, в результате чего старая запись остается в кэше до истечения срока действия TTL. Удаление не зависит от порядка; независимо от того, какая команда придет последней, результатом будет пустой ключ.
Сначала записывайте в базу данных, затем удаляйте. Изменение порядка действий создает более узкий временной окно:
// Wrong: delete before write
await redis.del(`user:${id}`); // 1. cache is empty
const updated = await db.users.update(id, patch); // 2. write the row
// Between 1 and 2, a reader can miss the cache, read the OLD row,
// and put it back. The cache now holds the old value until its TTL.
Между удалением и обновлением любой читатель, который не сможет получить данные из кэша, загрузит старую запись и снова сохранит ее обратно.
Запись в базу данных сначала сужает это окно, но не устраняет его полностью. Ниже приведена схема временных рамок, показывающая, как медленный читатель все еще может восстановить устаревшие данные:
Reader Writer
------ ------
GET user:42 -> miss
SELECT user 42 -> old row
UPDATE user 42 (commit)
DEL user:42
SET user:42 <- old row
Читатель считал строку до того, как записчик сохранил изменения, а затем хранил её после того, как записчик выполнил операцию удаления. Старое значение остаётся в кэше до истечения срока действия TTL. Для возникновения такой ситуации необходимы медленный читатель и записчик, действующие именно в неподходящий момент; поэтому это случается редко. Однако при тысячах чтений ключа в минуту редкость может легко превратиться в ежедневное явление.
Стандартным решением является второе удаление, запланированное на время, немного превышающее срок обработки обычной нагрузки:
async function updateUser(id, patch) {
const updated = await db.users.update(id, patch);
const key = `user:${id}`;
await redis.del(key);
// Catches a reader that loaded the old row before the commit.
setTimeout(() => redis.del(key).catch(() => {}), 500).unref();
return updated;
}
Вызов функции unref() предотвращает, чтобы заданный таймер удерживал процесс в активном состоянии во время выключения, а пустой блок catch гарантирует, что неудачная операция удаления никогда не приведёт к неразрешённой ошибке.
Существуют ещё два момента, на которые следует обратить внимание:
- Транзакции. Если обновление выполняется в рамках транзакции базы данных, выполните операцию удаления после успешного подтверждения транзакции, в обратном вызове вне транзакции. Удаление до подтверждения приведет к воссозданию неверного порядка, показанного выше.
- Сбои. Если процесс завершается между подтверждением и удалением, информация об аннулировании просто теряется, и только TTL в конечном итоге сможет это исправить. Когда потеря информации об аннулировании неприемлема, используйте механизмы захвата изменений данных, такие как Debezium для Postgres или MySQL, либо потоки изменений в MongoDB. В этом случае аннулирование будет зависеть от самого подтверждения транзакции, а не от того, долго ли будет работать обработчик запросов.
Стратегия 3: трансляция информации об аннулировании в локальные кэши памяти
Когда каждый процесс читает данные непосредственно из общедоступного Redis, команда DEL видна всеми ими одновременно, и механизм pub/sub не добавляет ничего нового.
Необходимость в нем возникает тогда, когда процессы используют небольшой слой в памяти перед Redis, такой как Map или lru-cache, чтобы избежать сетевых запросов для очень часто используемых ключей. Когда инстанция A удаляет ключ из Redis, локальная копия в инстанции B остается нетронутой. Опубликование устаревшего ключа позволяет каждой инстанции удалить свою собственную копию:
// Every instance runs this at startup.
// A subscribed connection can't run other commands, hence duplicate().
const sub = redis.duplicate();
await sub.subscribe('cache:invalidate');
sub.on('message', (_channel, key) => localCache.delete(key));
// Whoever writes to the database:
async function updateUser(id, patch) {
const updated = await db.users.update(id, patch);
await redis.del(`user:${id}`);
await redis.publish('cache:invalidate', `user:${id}`);
return updated;
}
Обратите внимание на вызов duplicate(): подключение к Redis в режиме подписчика не может выполнять обычные команды, поэтому для подписки требуется собственное подключение.
Если вы используете Redis 6 или новее, рассмотрите его встроенную поддержку кэширования с клиентской стороны (CLIENT TRACKING) прежде чем создавать собственную. Сервер запоминает ключи, которые были загружены подключением, и уведомляет это подключение при любых изменениях в них. Поддержка этой функции различается в зависимости от библиотек клиентов, поэтому убедитесь, что ваша библиотека её реализует.
Стратегия 4: ключи с версиями
Когда предоставление старого значения является проблемой корректности, а не внешнего вида, необходимо включить номер версии в ключ:
async function getOrder(id) {
const version = (await redis.get(`order:${id}:ver`)) ?? '0';
const key = `order:${id}:v${version}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const order = await db.orders.findById(id);
if (!order) return null;
await redis.set(key, JSON.stringify(order), 'EX', 86400);
return order;
}
async function updateOrder(id, patch) {
const updated = await db.orders.update(id, patch);
await redis.incr(`order:${id}:ver`);
return updated;
}
Чтение данных осуществляется путем получения текущей версии из ключа-счётчика, на основе которой формируется ключ с данными. Запись данных заключается просто в увеличении значения счётчика.
Запустите гонку стратегии 2 снова по этой схеме. Медленный читатель загружает устаревшую запись; тем временем записывающий процесс сохраняет изменения и увеличивает счетчик; затем читатель сохраняет в кэше то, что загрузил. Его запись попадает в order:42:v0 — ключ, который ни один читатель больше не будет искать, поэтому устаревшая копия остается на заброшенном ключе до истечения срока действия. Именно эта защита является ценой дополнительной сложности.
Правила, обеспечивающие безопасность:
- Только записывающие процессы могут изменять счетчик, и только с помощью команды
INCR. Если читающий процесс инициализирует счетчик, установив его в 0 с помощью командыSET, это может стереть изменение, сделанное мгновение ранее, и восстановить предыдущую версию.
v0 или v1.Последствиями являются два обхода по сети при каждом чтении вместо одного, а также сохранение старых версий в памяти до истечения срока их действия. Такой подход оправдан для заказов, балансов, уровней запасов и всего, что связано с выставлением счётов, но чрезмерен для большинства других данных.
Выбор стратегии
Краткое сравнение:
- TTL только: прост в реализации; данные могут быть старыми на срок, равный TTL; используйте его, когда допустима определённая старость данных и вы можете указать её значение.
- Удаление при записи: низкая сложность; данные сразу становятся актуальными, за исключением редких ситуаций конкуренции, которые продолжаются до истечения TTL; стандартный вариант для всего, что редактируют пользователи.
Для типичных сервисов достаточно использования параметра истечения срока хранения в сочетании с функцией удаления при записи, плюс отложенное дополнительное удаление для ключей с высокой частотой чтения, которые могут меняться пользователями. Внедряйте механизм pub/sub в тот день, когда начнете использовать кэш внутри процесса, а ключи с версиями оставьте для небольшого числа объектов, где неверный ответ имеет финансовые последствия.
Обертка Reference Cache Aside()
Логика кэширования, разбросанная по обработчикам запросов, часто приводит к забыванию этих правил, поэтому выгодно написать логику один раз и импортировать ее во все места. Приведенный ниже модуль использует ioredis и объединяет:
- режим fail-open для запросов к Redis, при котором в случае сбоя используется база данных вместо отклонения запросов;
- режим stale-while-revalidate, позволяющий подавать немного устаревшие значения во время обновления;
- механизм single-flight на уровне процесса, при котором одновременные запросы внутри одного процесса делят общую нагрузку;
- межпроцессный замок с разблокировкой по принципу compare-and-delete, чтобы все инстансы не пересоздавали один и тот же ключ;
- аннулирование данных через механизм pub/sub с задержкой второго удаления.
// cache.js
const Redis = require('ioredis');
const { randomUUID } = require('node:crypto');
const logger = require('./logger');
// Fail fast, so a dead Redis makes requests slower instead of hanging them.
const redis = new Redis(process.env.REDIS_URL, {
commandTimeout: 250,
maxRetriesPerRequest: 1,
});
// A subscribed connection can't run other commands, so it gets its own,
// with default options so it keeps reconnecting.
const sub = new Redis(process.env.REDIS_URL);
const CHANNEL = 'cache:invalidate';
// Compare-and-delete, as in Part 1: never release a lock you no longer own.
const UNLOCK = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0
`;
// ---- Invalidation fan-out to in-process caches ----
const listeners = new Set();
sub.subscribe(CHANNEL).catch(err => logger.error({ err }, 'cache: subscribe failed'));
sub.on('message', (_channel, key) => {
for (const fn of listeners) {
try { fn(key); } catch (err) { logger.warn({ err, key }, 'cache: listener failed'); }
}
});
function onInvalidate(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
// ---- Fail-open reads and writes ----
async function readEntry(key) {
const raw = await redis.get(key).catch(() => null);
if (!raw) return null;
try { return JSON.parse(raw); } catch { return null; }
}
async function writeEntry(key, value, { freshSec, staleSec }) {
const now = Date.now();
const entry = {
value,
freshUntil: now + freshSec * 1000,
staleUntil: now + (freshSec + staleSec) * 1000,
};
await redis.set(key, JSON.stringify(entry), 'EX', freshSec + staleSec).catch(() => {});
return value;
}
// ---- Single-flight: per process first, then across processes ----
const inflight = new Map();
function once(slot, fn) {
const existing = inflight.get(slot);
if (existing) return existing;
const p = fn().finally(() => inflight.delete(slot));
inflight.set(slot, p);
return p;
}
async function rebuild(key, loader, opts, { wait }, attempt = 0) {
const lockKey = `lock:${key}`;
const token = randomUUID();
// If Redis is down the SET rejects. Treat that as "got the lock" and just load.
const got = await redis.set(lockKey, token, 'NX', 'EX', opts.lockSec).catch(() => 'OK');
if (got === 'OK') {
try {
// Another instance may have finished a rebuild while we were getting the lock.
const entry = await readEntry(key);
if (entry && Date.now() < entry.freshUntil) return entry.value;
return await writeEntry(key, await loader(), opts);
} finally {
await redis.eval(UNLOCK, 1, lockKey, token).catch(() => {});
}
}
// Someone else holds the lock. A background refresh can leave the work to them.
if (!wait) return undefined;
await new Promise(r => setTimeout(r, 50));
const entry = await readEntry(key);
if (entry && Date.now() < entry.freshUntil) return entry.value;
if (attempt >= 20) return loader(); // about a second of waiting: do it ourselves
return rebuild(key, loader, opts, { wait }, attempt + 1);
}
// ---- Public API ----
async function cacheAside(key, loader, { freshSec = 60, staleSec = 300, lockSec = 5 } = {}) {
const opts = { freshSec, staleSec, lockSec };
const entry = await readEntry(key);
const now = Date.now();
if (entry && now < entry.freshUntil) return entry.value;
if (entry && now < entry.staleUntil) {
// Stale but servable: return it now, refresh once in the background.
once(`refresh:${key}`, () => rebuild(key, loader, opts, { wait: false }))
.catch(err => logger.warn({ err, key }, 'cache: refresh failed'));
return entry.value;
}
// Cold, or past staleUntil: the caller waits for a load.
return once(`load:${key}`, () => rebuild(key, loader, opts, { wait: true }));
}
async function invalidate(key, { redeleteMs = 500 } = {}) {
await redis.del(key).catch(() => {});
await redis.publish(CHANNEL, key).catch(() => {});
// Second delete catches a rebuild that read the old row before the write committed.
setTimeout(() => redis.del(key).catch(() => {}), redeleteMs).unref();
}
module.exports = { cacheAside, invalidate, onInvalidate, redis };
Использование этого механизма из кода приложения требует одного вызова для операций чтения и одного — для операций записи:
const { cacheAside, invalidate } = require('./cache');
const user = await cacheAside(`user:${id}`, () => db.users.findById(id), {
freshSec: 60,
staleSec: 300,
});
async function updateUser(id, patch) {
const updated = await db.users.update(id, patch);
await invalidate(`user:${id}`);
return updated;
}
Некоторые решения, принятые при проектировании обертки, не являются очевидными, поэтому стоит внимательно ознакомиться с ней перед её применением.
Отдельные интервалы времени single-flight для обновления и загрузки
Обновление фона и блокировка загрузки обозначаются разными префиксами — refresh: и load:. Когда процесс обновления фона теряет борьбу за блокировку между процессами, он намеренно возвращает undefined, поскольку другая инстанция уже занимается пересозданием. Если вызывающий код действительно нуждается в значении из этой области, он также ничего не получит.
Второе чтение из кэша после получения блокировки
Как только функция rebuild() получает блокировку, она снова читает соответствующую запись. За время ожидания другой процесс мог завершить пересоздание, и двукратная загрузка приведет к потере ресурсов на выполнение запроса.
Ограниченное ожидание при получении блокировки
Запросчик, который не может получить блокировку, пытается это сделать каждые 50 мс в течение до 20 попыток, примерно одну секунду, после чего запускает сам загрузчик. Несколько дублирующихся запросов предпочтительнее, чем очередь запросчиков, которая никогда не опустеет, если владелец блокировки застрянет.
Получение блокировки при сбое
Если Redis недоступен и команда SET NX возвращает отказ, блок .catch преобразует этот отказ в значение 'OK', и каждый запросчик переходит к загрузчику. Это позволяет поддерживать работу сервиса, но также означает, что база данных берет на себя всю нагрузку до восстановления Redis. Убедитесь, что она способна на это.
Настройка стандартных параметров
lockSecдолжен превышать значение p99 задержки загрузчика, чтобы блокировка не истекала в процессе пересоздания.redeleteMsтакже должен превышать время выполнения самого медленного загрузчика. 500 мс подходят для одного индексированного запроса; агрегация данных на панели управления, занимающая четыре секунды, требует большего времени.
Что намеренно не учитывается
Не включена вероятностная функция преждевременного истечения срока действия. Механизм Stale-while-revalidate уже устраняет проблему резкого истечения срока для ключей, которые часто читаются, и объединение этих двух механизмов в одном формате записи усложняет понимание момента обновления. Для ключей с высоко переменными затратами на пересоздание можно использовать вероятностную проверку вместо сравнения freshUntil в функции cacheAside().
Где реализованы каждые аспекты в коде
- Неудачная попытка загрузки, загрузка и хранение:
cacheAside()иrebuild() - Механизм Stale-while-revalidate: поля
freshUntilиstaleUntilв каждой записи - Ограничение одного запуска внутри процесса:
once() - Блокировка между процессами и безопасное разблокирование:
rebuild()и Lua-скриптUNLOCK
.catch() при каждом вызове Redis, плюс commandTimeoutinvalidate()invalidate() и onInvalidate()Распространенные ошибки, приводящие к устаревшим или поврежденным данным
Они перечислены примерно в порядке степени ущерба, который они обычно вызывают.
Рассмотрение ошибок Redis как фатальных
Простое использование await при вызове Redis приводит к сбою сразу, как только к серверу невозможно подключиться:
const cached = await redis.get(key); // throws when Redis is unreachable
Кэш предназначен для оптимизации. Перебой должен приводить лишь к увеличению задержек, а не к волне ответов с кодом 500. Минимальное решение — преобразовать сбои в случаи отсутствия данных в кэше:
const cached = await redis.get(key).catch(() => null);
При стандартных настройках ioredis одного только метода .catch недостаточно. Когда соединение прерывается, клиент сохраняет команды в буфере и продолжает пытаться их отправить — по умолчанию до 20 раз на запрос, вместо того чтобы сразу сообщить об ошибках. Это приводит к тому, что запросы застревают в ожидании отсутствующего сервера. Настройка параметров commandTimeout и небольшого значения maxRetriesPerRequest, как это делает клиент-обертка, позволяет им быстро достичь загрузчика. Тем не менее каждый запрос теряет несколько сотен миллисекунд во время простоя, что является допустимым для кратковременных сбоев. При длительном простое циркуит-брейкер, прекращающий вызовы к Redis после нескольких последовательных ошибок, помогает восстановить потерянное время.
Аннулирование без TTL
Удаляемые записи теряются: процесс падает после сохранения изменений, развертывание прерывает запрос на полпути, а сообщение приходит в момент, когда подписчик перезагружается. Без параметра TTL любой из этих случаев приводит к тому, что устаревшая ключ-запись остается в Redis бесконечно. Установите срок истечения для каждого ключа, даже если он будет довольно большим — например, один день. Удаляемые записи позволяют сохранять актуальные данные в большинстве случаев; срок истечения служит защитным механизмом, предотвращающим существование некорректных записей вечно.
Хранение значений без их сериализации
Redis хранит строки, поэтому передача обычного объекта приводит к незаметному повреждению записи:
await redis.set(key, JSON.stringify(value)); // correct
await redis.set(key, value); // an object is stored as "[object Object]"
При записи сериализуйте данные с помощью JSON.stringify, а при чтении — десериализуйте с помощью JSON.parse. С самого начала оборачивайте значения в такой «оболочку», как { value, freshUntil, staleUntil }, даже если изначально используется только value; добавление механизма обновления данных при сохранении не заставляет вас одновременно работать с двумя форматами.
Также необходимо осознанно решить, как обрабатывать результаты null. Оболочка хранит их, что называется отрицательным кэшированием; благодаря этому повторные запросы к отсутствующей записи больше не доходят до базы данных, что обычно желательно. В тех случаях, когда запись может появиться вскоре после неудачного запроса, например при работе с только что вставленной записью в реплике, отстающей от основной базы, срок действия кэша для таких случаев должен быть меньше.
Ключи без ограничений, создаваемые на основе данных от пользователя
Ключ, полученный непосредственно из строки запроса, кажется безвредным:
const key = `search:${req.query.q}`;
Каждая уникальная строка поиска генерирует свой собственный ключ, и любой клиент может создать миллионы таких ключей. Дальнейшее поведение системы определяется параметром maxmemory-policy. При использовании режима allkeys-lru большое количество ключей вытесняет наиболее часто используемые записи, в результате чего показатель успешных поисков резко снижается. При режиме noeviction, который используется в самостоятельно развернутых версиях Redis, если не указано иное, память быстро исчерпывается, и операции записи отклоняются с ошибками OOM.
Хеширование запроса не помогает: хеш ограничивает длину каждого ключа, но не количество ключей. Вместо этого следует канонизировать входные данные (удалить пробелы, преобразовать в нижний регистр, упорядочить параметры), чтобы эквивалентные поиски сопоставлялись с одним ключом, делать эти записи кратковременными и хранить в кэше только те поиски, которые выполняются часто или требуют значительных ресурсов. Лучше явно настроить параметры maxmemory и политику вытеснения, а не использовать стандартные настройки.
Использование одной инстанции Redis в разных средах
Если стадионное тестирование и производство используют одну и ту же инстанцию, оба окружения читают и записывают один объект user:42. Специализированные инстанции — это настоящее решение; в качестве альтернативы можно использовать пространства имен для каждого ключа, что может сделать для вас ioredis:
const redis = new Redis(process.env.REDIS_URL, {
keyPrefix: `${process.env.APP_ENV}:`,
});
Основывайте префикс на специально созданной переменной, такой как APP_ENV, поскольку стадионные тесты часто запускаются с параметром NODE_ENV=production. Обратите внимание, что keyPrefix относится только к ключам, а не к каналам pub/sub, поэтому вы сами должны добавлять информацию об окружении в название канала аннулирования.
Изменение общедоступного кэшированного значения
Из-за принципа одновременного доступа все вызовы, происходящие одновременно, получают один общий объект, а не отдельные копии:
const a = cacheAside('user:42', load); // both wait on one promise
const b = cacheAside('user:42', load);
(await a).name = 'changed';
console.log((await b).name); // 'changed'
Любые данные, загружаемые из Redis, преобразуются в новый объект для каждого вызывающего кода, поэтому проблема возникает только в случае использования общих обещаний, из-за чего она встречается редко и трудно воспроизводится. Считайте данные из кэша неизменяемыми или сначала используйте structuredClone(), чтобы скопировать их перед изменениями. Замораживание возвращаемых значений с помощью Object.freeze во время разработки заставляет код в режиме строгой проверки бросать ошибку при некорректной операции, вместо того чтобы тихо повредить данные.
Основные выводы
- Установите срок действия для каждого ключа: удаление данных обеспечивает их актуальность, а срок действия определяет, как долго может сохраняться ошибка.
- Начните с использования механизма кэширования в отдельности и удаления ключей при записи: сначала сохраните данные в базу данных, затем удалите (никогда не перезаписывайте) ключ вне любой транзакции. Для активно изменяемых пользователями ключей запланируйте повторное удаление.
Ещё одно важное замечание: всё вышесказанное предполагает наличие одного автономного сервера Redis. Режимы Sentinel и Cluster осуществляют репликацию асинхронно, поэтому при переключении на резервный сервер могут быть потеряны последние записи, включая ключи блокировки, от которых зависит обертка. Учитывайте это, прежде чем использовать эти механизмы блокировки для чего-то более серьёзного, чем простая координация усилий.