Збереження чесності кешів 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. Якщо читач ініціалізує його, наприклад, командоюSETзначенням 0, це може стерти попереднє збільшення та відновити попередню версію.
v0 чи v1.Витрати складають дві поїздки за зчитуванням замість однієї, плюс пам’ять, яку займають старі версії даних до закінчення їхнього терміну дії. Такий компроміс є доцільним для замовлень, балансів, рівня запасів та всього, що пов’язано з бухгалтерським обліком, але надмірним для більшості інших даних.
Вибір стратегії
Коротке порівняння:
- Лише TTL: просто у реалізації; дані можуть бути до одного TTL старими; використовуйте цей підхід, коли певний старіння є прийнятним та ви можете вказати його значення.
- Видалення під час запису: низька складність; дані оновлюються миттєво, за винятком рідкісних ситуацій конкуренції, які тривають до закінчення TTL; це стандартний підхід для всього, що користувачі можуть редагувати.
Для типового сервісу достатньо використання терміну дії кешу у поєднанні з функцією видалення при запису, а також затриманого додаткового видалення для ключів, які активно читаються та можуть змінюватися користувачами. Додайте механізм pub/sub у день впровадження кешу всередині процесу, а ключі з версіями залиште для невеликої кількості ентитетів, де неправильна відповідь має фінансові наслідки.
Кеш-обгортка Aside()
Логіка кешування, розкидана по обробникам запитів, є причиною того, що ці правила забуваються, тому варто написати логіку один раз та імпортувати її всюди. Наведений нижче модуль використовує ioredis та поєднує:
- функція fail-open у викликах Redis, яка замість відмови запиту переходить до роботи з базою даних,
- режим stale-while-revalidate, що дозволяє подавати трохи застарілі значення під час оновлення,
- модель single-flight на рівні процесу, тож кілька одночасних запитів у одному процесі ділять спільне навантаження,
- механізм блокування між процесами з розблокуванням шляхом порівняння та видалення, щоб інстанції не перебудовували один і той самий ключ,
- анулювання даних через механізм 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 призводить до помилки як тільки до 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, як це робить клієнт-обгортка, дозволяє їм швидко отримати відповідь від сервера. Кожен запит все одно витрачає кілька сотень мілісекунд під час перерви, що є прийнятним для короткочасних проблем. Під час тривалої перерви використання механізму circuit breaker, який при кількох послідовних помилках припиняє виклики до 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; додавання механізму stale-while-revalidate пізніше не змусить вас працювати з двома форматами одночасно.
Також необхідно свідомо вирішувати, що робити з результатами 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, тому ви самі повинні додати інформацію про середовище до назви каналу для скасування дії.
Зміна спільного збереженого значення
Через механізм single-flight усі запити, які виконуються одночасно, отримують один спільний об’єкт, а не окремі копії:
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 під час розробки змушує код у режимі strict-mode кинути помилку при некоректному присвоєнні, замість того щоб тихо пошкодити дані.
Основні висновки
- Встановлюйте термін дії для кожного ключа: видалення зберігає актуальність даних, а термін дії обмежує час існування помилок.
- Почніть із режиму cache-aside та видалення ключа після запису: збережіть дані в базі даних, а потім видаліть (ніколи не перезаписуйте) ключ поза межами будь-якої транзакції. Для ключів, якими активно користуються користувачі, заплануйте їх повторне видалення.
Останнє застереження: усе це передбачає наявність одного окремого сервера Redis. Реалізації Sentinel та Cluster роблять копіювання асинхронно, тож перехід на резервний сервер може призвести до втрати останніх змін, включаючи ключі блокування, від яких залежить обгортка. Врахуйте це, перш ніж покладатися на ці механізми блокування для чогось більш серйозного, ніж проста координація зусиль.