Задаванне кэша 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
Чытач выявіў рэкорд да таго, як запісчык зберыў змяны, а потым сховаў яго пасля выдалення запісчыкам. Тая старая значэнне застаецца у кешы до выканання таймауту. Для такога рэжыму патрэбны медленны чытач і запісчык, які працюе самэлька не тады, таму такое явісце рэдкай, але калі ключ чытаюць тыячы разоў на хвіліну, рэдкае можа легка стаць ўсёчасным.
Стандартны спосаб выправлення — другое выдаленне, запланаванае на трохі большы час, ніж трэбуе звычная праця:
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 і аднаўляе:
- Выконанне запыткаў да Redis, якія у разе неудачы пераходзяць на работу з базай дадзеных уместо таго, каб адхіліць запыткі,
- механізм stale-while-revalidate, які дазволяе выдаваць трохі старыя значэння пад час адкрэптавання данных,
- модель адзінаго запытка на процэс, таму канкурентныя запыткі ў однам процэсе дзелюць адно навантажэння,
- механізм блакавання межа процэсамі з адблакаваннем праз порэванаў і выдалення, таму экземпляры не перабудуюць аднаковы ключы,
- анулювання данных праз систему 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;
}
Некалькі выбораў у дзеянні кэйсу не є очвістнымі, таму прачытайце яго аднойчы перш чым ўзяць його на вжытак.
Адзінаковыя режымы адкрэптавання для чытання і збіркі дадзеных
Апдэты фонавага і блакуванне завантажэнняў пазначаюцца разнымі прыфіксамі — refresh: і load:. Калі апдэт фонавага прызнаеся перад іншым процэсам у борбе за блаканне, ён навмысна вяртае undefined, таму што іншы экземпляр ведае рэбуду. Якщо вызывач практычна патрэбуе цяную, якая магчыма была б спільнаю для таго слота, ён таксама нічога не атрыме.
Другое чытанне кэша пасля захавання блакання
Калі rebuild() захавае блаканне, ён чытае энтрыю ўсёраз. Іншы процэс мог ужо завершыць рэбуду пад час чакання, і два разы запуск чытальніка будзе марнаць запит.
Обмежана чаканне пасля захавання блакання
Абонент, які не можае захаваць блакат, прабуе кожныя 50 мс прыблізна адну секунду, да 20 разоў, а пасля запускае сам загрузчык. Калькі дубліруючых запитоў кращыя, чым черга абонентаў, якая ніколі не спрацоўвае, якщо той, хто трывае блакат, зупініцца.
Захаванне блаката пад час неудачы
Якщо Redis недоступны, SET NX адхоўвае запит, .catch ператварае гэта адхоўненне на 'OK', і кожны абонент праходзіць да загрузчыка. Гэта дапамагае падтрымаць роботу службы, але таксама значыць, што база дадзеных прыме весь навантажэння, пакуль Redis не вярне сабе роботу. Пераканайцеся, што гэта можліва.
Настройка стандартных параметраў
lockSecдолжен перавышаць пазітывнай 99% задзеймленасці загрузчыка, каб блакат не выгорэў пад час перзбірання дадзеных.redeleteMsтаксама должен перавышаць час выканання спадзяльнейшага загрузчыка. 500 мс падходзяць для адного індексаванага запита; агрэгатаванне дадзеных у панелі керування, якое займае чатыры секунды, выкалічвае больш часу.
Што навмесна не ўключаецца
Не павінна быць наявна працэсная ранняя заканчыцца терміну дзейнасці. Функцыя Stale-while-revalidate вже усувае проблему раптовай заканчыцы терміну для ключоў, якія часта чытаюцца, а ўжыванне яе разам з іншымі механізмамі ў адны формат запису ускладнюе разумеенне момента апдэйта. Для ключоў з вельмі мянучысяю вартасцю перабудовы можна выкарыстоўваць працэсную перапалню для замены порівняння freshUntil у функцыі cacheAside().
Дзе кожная проблема рашаецца ў коде
- Непаводлівасць, завантажэнне і зберагчыце:
cacheAside()іrebuild() - Stale-while-revalidate: поля
freshUntilіstaleUntilкожнага запису - Едынакавая робота ў межах адного процэсу:
once() - Блакаванне між процэсамі і безпечнае адблакаванне:
rebuild()і скрыпт LuaUNLOCK
.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, як гэта робіць кліент уярошчальніка, дапамагае запитам быстра досягнуць загрузчыка. Кожны запит усё ж витрачае калькі соткаў мілісекунд пад час перыявання, што ўзьемліва для кораткага часу. Пад час дзейнавання перыявання працэсу трывалага характеру, які перастае выкананні запытоў да 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, якщо не задана іншая настройка, памяць заканчываецца, і запісы адхоўляюцца зі спецыяльнымі памяткамі пра нехватку памяці.
Хэшаванне запыту не дапамагае: хэш обмежвае толькі дыяпазон дужоў кожнага ключа, а не саму колькасць ключоў. У замен трэба стандартызаваць вхідны даны (адчысціваць прасоўкі, пераклацваць на малыя літеры, аранжаваць параметры), ў результате чаго аднаковыя запыты будуць прыкладзаны да аднаго ключа, такі элементы будуць існаваць кораткі час, а ў кэшы будуць зберагваны толькі тые запыты, якія выкарыстоўваюцца часта або є складнымі для обробкі. Кращэ спецыяльна настаўляць параметры 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 копіююць данні асінхронна, і перехад на запасны сервер можа выдаліць найняўэйшыя змены, укладаючы і ключы блакання, ад каторых залежыць прыемнік. Узяце гэта ў расчыт пры викорыстоўванні гэтых блаканняў для чаго-небудзь больш за простую коордынацыю.