Кэшуванне у Node.js: чаму кэш Redis можа перавантажыць вашу базу дадзеных
Дазвольце даклэ расказаць, чаму простая настройка Redis з выкорыстаннем механізма cache-aside синхронізуе навантажэння на вашу базу дадзеных, калі заканчываецца тэрмін дзейнасці важлівага ключа, і як блакіткі та фонавыя процесы апдэйта запобегаюць гэтаму.
Крыніцы: “I Thought Redis Would Make My API Faster.” It Didn’t.
Адыягулінь Redis-а пры медленным запытку ёсць стандартны спосаб рашэння проблемы базы дадзеных, якая перабоўлена, і ў спакойны дзень гэта выглядае як чыстая паводлівасць. Аднак у моменты максимальнага трафіку простая наладка з выкорыстоўваннем кэша можа значна пагаршыць ситуацыю: як толькі заканчываецца тэрмін дзейнасці популярнага ключа, усе одночасныя запыткі не могу яго знайсці і атакуюць базу дадзеных. У гэтым артыкуле пояснюецца гэта збой, вядомы як «каш-стампід», паказваецца, як ён распростраńваецца через API на базе Node.js, і прыводзяцца два спосабы, якія можу гэтаго запобiec: распадзелены замак для регенераціі і прадзейнае вырахунакі на фоне.
Рашэння, якое працуе да моменту максимальнага трафіку
Звычная наладка — гэта выкорыстоўвань кэша. Пры кожным запытку API шукае ў Redis; якщо ключа няма, ён запытае базу дадзеных і збірае рэзультат назад у Redis для наступнага разу. Як толькі ён запускаецца у дзень з низкім трафікам, ён адразу ж зменшае час адпаведзення.
Потым наступае ўскорэнне у понедзелкі вечарам, і API перестае адпавядаць. Процес Node.js паказвае низкі расход CPU, таму прычыной не можа быць цикл здарэнняў. Аднак галоўная база дадзенаў працуе з расходам CPU 100%.
Redis не ўсталела прычына
Сам Redis рэдка бывае прычыной проблем. Ён выкананае сотні тысяяў операцый за секунду з задзейніцэю меншай за калікс мілісекунд. Проблему стварае аднос межы прыкладнам і кэшам, а точней — як прыкладнае програма паводзіцца, калі дадзеныя відсутны.
Звычны ментальны модэль — гэта тыха бібліятэка: вы запытваеце на стойке адпрыемнікай (Redis), і як кніга там няма, бібліятэкар (API) атрымляе яе з шаф (PostgreSQL) і залічвая копію на стойцы. У масштабным форматах гэта працоўка нагадвае рынак цэнных папероў. Калі тыясчы кліентаў запытаюцца пра аднаковы популярны элемент у той момент, калі заканчываецца тэрмін його кешавання, усі яны адразу бачаць, што стойка порожняя, і ўсі направляюцца да шаф.
Структура натовпнага руху
Возьмімо такі канец-тэп, як GET /products/trending. Стварэнне адпаведзення на яго включае кілька з’ѐеднанняў, падзапытоў і частковую агрэгацію ў Node.js, і на гэта йдзе або 1,2 секунды. Рэзультат кешуецца на дзесять хвілін.
У стабільным стане гэта ўсё ж дужа эфектыва: з дзесяці тысяч запытоў толькі адзін доходзіць да базы дадзеных, а рэшта служаюць з Redis за апоўну 5 мс.
Дзеяўчы, заўважкім, што тэрмін жыцця TTL у 10 хвілін заканчываецца о 09:00:00. У 09:00:01 адразу прыходзіць пяцьдзесят запитоў. Кожны з іх не успевае, але кожны з іх пачынае той самы запит на 1,2 секунды. База дадзенаў раптам выкананае пяцьдзесят важкіх запытаў паралельна. Буфер з’яносоў практычна мгнова запоўняецца, і кожны іншы запит API, які патрэбуе базы дадзенаў, нават для несвязаных дадзеных, тепер стаўляецца ў чаргу пасля гэтых пяцьдзесят.
Варта быць точным: чаканне на з’янос з буфера не блакуе цыкл адбудовы Node.js, таму што гэтае чаканне ўзаемна асінхронна. У замен запыты накапліваюцца як невыпаныя обявы і відкрытыя сокеты, таму процес выглядае як бездзейны, пакалі корыстнікі бачаць таймауты.
Гэта так званая «беготня па кешы», або эфект «купы сялян». Кеш не зменшыў навантажэння; ён сінхронізаваў сплеск навантажэння ў адзін момент.
Чаму ўсё паглыбляецца замест таго, каб вярнуцца да нормы
Натоўп не ёсць проста періямам спаду. Ён жыве за рахунак сабе самога.
- Часы выканання запитоў растуць стрэмгана. Запит, які зазвычай выкананы за 1,2 секунды, можа зайняць аж 25 секунд, калі п’ятдесят экземпляраў борацца за CPU, блакіткі і I/O.
- Экземпляры API утримваюць тыячы адкрытых HTTP-з’єднанняў, пакуль чакаюць, спрацоўваючы памяць і дэскрыптары файлаў.
- CPU на роўні API застаецца низкім, таму автаскалер, які керуецца показнікам CPU, можа ніколи не дадаць дапамогі, а дадаванне новых экземпляраў API толькі адправіць больш запитоў у базу дадзеных.
- Новыя запиты продавжаюць прыходзіць, але не выкананыя, таму што медленныя запиты ўсё яшчэ не запісалі результаты. У канцы канцоў база дадзеных перестае працаваць чераз вымор памяці або таймауты з’єднанняў.
Запобежэнне натоўпам
Аддыяўанне быстрэйшага хранілніка не можа вырашыць проблему, якая на самай справе ўскладнена нехваткай коордынаціі. Рашэнняя — змяніць тых, каму разрашана регенеравацыя дорогіх дадзэнняў, і калі.
Шаблон 1: распрашранены замок для регенеравацыі
Калі API працуе як калькі інстансаў, регенеравацыя павінна быць коордынаваная між усімі імі, што значыць неабходнае наяўнасць замка, які храніцца ў Redis. Першы запит, які не знаходзіць дадзеныя ў кэшы, прабуе захапіць замок прытым, як і шукае ў базе дадзэнняў. Будзь-які іншы запит, які не знаходзіць дадзеныя пакуль замок у захопленні, не падходзіць да базы дадзэнняў і выконвае адна з двух дзейнасцей:
- Чакае і прабуе знову: на час, напрыклад 50 мс, співае і зноў пераглядае Redis, пакуль не з’явіцца новая значэнне.
- Прадае застарелыя дадзеныя: вяртае паканальную значэнне, якщо продукт можа прыміць трохі застарелыя рэзультаты.
У прыкладзе нижэй викорыстоўваецца ioredis. Спачатку чытаецца кэш. Калі яго няма, працоўнае аб’екта прабуе выконваць SET з параметрамі NX і PX 5000; гэта стварае блокіраванне толькі якщо ніхто іншы яго не трывае, і ён заканчваецца чырвоў пяці секунд. Аб’ект, які выграў, выканае запит, зберагае рэзультат на 600 секунд і звалічвае блокіраванне ў блоке finally; усі іншы продаўляюць перагледкі, пакуль значэнне не будзе заполнена.
// Example: Using Redis for a locking/syncing pattern (Conceptual)
import { Redis } from 'ioredis';
const redis = new Redis();
async function getCachedTrendingProducts() {
const cacheKey = 'products:trending';
const lockKey = 'lock:products:trending';
// Attempt 1: Get data
let data = await redis.get(cacheKey);
if (data) return JSON.parse(data);
// Attempt 2: Cache miss. Try to acquire the regeneration lock.
const acquiredLock = await redis.set(lockKey, 'locked', 'NX', 'PX', 5000); // 5s lock
if (acquiredLock) {
// We hold the lock. We are the ONLY worker hitting the DB.
try {
// 1.2s Database Operation
const dbData = await fetchTrendingProductsFromDB();
// Store in Redis (10 min TTL)
await redis.set(cacheKey, JSON.stringify(dbData), 'EX', 600);
return dbData;
} finally {
// Release lock
await redis.del(lockKey);
}
} else {
// We did NOT get the lock. Someone else is already regenerating.
// Wait and Retry (e.g., exponential backoff or simple polling)
return pollRedisUntilPopulated(cacheKey);
}
}
Рэзультатам яе ўжыцця є тое, што аднойчы да базы дадзенаў можа дасягнуць ў максымум адна дорога запита на кожны ключ, незалежна ад таго, скількі запытанняя прыходзяць адночасна.
У працоўнай суперактуальнасі варта рассмотрзець калякія прадактыкаў:
- Чырвоў блокіравання павінен перавышаць час выканання найгоршага запита. Якщо практыкацыя занову ўтварэння блокіравання трывае дыяго пяці секунд, блокіраванне заканчваецца і другі працоўны аб’ект пачынае дублікатны запит.
pollRedisUntilPopulated патрабуе таймаут і альтернатыўны варіант, інакша запиты на чаканне можу застряць, якщо власнік замка зазнае краху.Шаблон 2: прадзейснаванне на фоне
Найболей надзеяны ўпрадк паводзіць да раздзелення процэса аддачы дадзейнаў і ўтварэння іх. Адпаведальныя за прыемку запыткаў, тое самае сінхронная лінія, чыста чытаюць з кэшу і ніколі яго не перзбудовваюць. Якщо значэнне відсутня, яны негайна вяртаюць порожні стан або стан „завантажэння“, што значна краща, чым утримванне з’яўлення працою калі летуе секунды.
Перзбудова дадзейнаў адбываецца ў фонавым процэсе, такім як задача cron, працоўнік BullMQ або прыемнік дадзейнаў змены, які апштановвае ключ перад тым, как ён выйдзе з часовага рэжыму. У прыкладзе нижчэй працоўнік, запланаваны на кожныя дзев’ять хвілін, перзбудоввае спіс популярных элементаў і запішае яго з тым самым часовым рэжымам у трохце дзесяці хвілін, таму ключ ніколи не застаецца без дадзейнаў пад нормальным працаваннем:
// Separate background worker file
// For example, running every 9 minutes via Cron or BullMQ
async function regenerateTrendingProductsCache() {
const cacheKey = 'products:trending';
try {
// Proactive DB fetch (1.2s)
const dbData = await fetchTrendingProductsFromDB();
// Refresh the 10-minute cache
await redis.set(cacheKey, JSON.stringify(dbData), 'EX', 600);
console.log('Cache proactively refreshed.');
} catch (err) {
console.error('Proactive refresh failed:', err);
}
}
Паколькі вхідныя запыткі завжды знаходзяць готовую кантэнцыю ключа, не існуе момента, калі яны моглі б адночасна перайсці да базы дадзеных. Два фактора памагаюць захаваць стабільнасць: адзначыце процэс апдэтаў на аднам планавальніку або захавайце яго пад блокам забароджэння, каб несколькі экземпляраў не апдэтаваліся адночасна, а таксама створыце паведамленне, калі апдэт не выйшаў, таму што сам блок catch толькі фіксуе адказку, а ключ заканчыць сваю дзейнасць за мінуту. Частка, якая кантролюе планаванне, адносна дэталяў розглядаецца ў нарадзе па стварэнню надзеяных фонавых заданняў з BullMQ і Redis.
Выбір межы ямі
Закрыцье кэша падтримвае звычны прыем кэш-асайд і падходзіць для дадзеных, якія запрашваюцца неперакладывана. Прыготавленне дадзеных у фоне падходзіць для невялкага набора вядомых, дорогіх і часта чытаемых ключоў, такіх як спіс популярных элементаў аб статыстыка на панелі керування, і дае самую прыведамую затрымку. Дадаць невялікі варыябельны элемент у час жыцця ключоў таксама дапамагае, калі многі ключы запісваюцца адночасна і інакш бы выгорэлі разам.
Галоўныя выводы
- Redis ўзлётны; проблемы выступаюць з-за таго, як прыложэнне реагуе на недаступнасць дадзеных, а не з-за самага кэша.
- Просты прыем кэш-асайд ператварае адзін час выгорэння ключа на сінхронную атаку на вашы найпамедлівейшы ресурс, а проблемы можу быць схованыя за низкім выкарыстаннем CPU API.
- Разпадзелены замок гарантуе, што толькі адні разы запрашоўчык можа перзапісаць ключ; трэба астаточна старанна кераваць часам выгорэння замка, його прыналежнасцю і таймаутамі чакання.