Галоўная / Артыкулы

Кэшуванне у Node.js: чаму кэш Redis можа перавантажыць вашу базу дадзеных

Дазвольце даклэ расказаць, чаму простая настройка Redis з выкорыстаннем механізма cache-aside синхронізуе навантажэння на вашу базу дадзеных, калі заканчываецца тэрмін дзейнасці важлівага ключа, і як блакіткі та фонавыя процесы апдэйта запобегаюць гэтаму.

Выдавец: REACTAPP.TOP7 хв чытання1648 слоў

Крыніцы: “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);
  }
}

Рэзультатам яе ўжыцця є тое, што аднойчы да базы дадзенаў можа дасягнуць ў максымум адна дорога запита на кожны ключ, незалежна ад таго, скількі запытанняя прыходзяць адночасна.

У працоўнай суперактуальнасі варта рассмотрзець калякія прадактыкаў:

  • Чырвоў блокіравання павінен перавышаць час выканання найгоршага запита. Якщо практыкацыя занову ўтварэння блокіравання трывае дыяго пяці секунд, блокіраванне заканчваецца і другі працоўны аб’ект пачынае дублікатны запит.
  • Безумовна адміністрацыя замка можа выдаліць такі замок, які іншы працоўнік запрацаваў пасля таго, як вашы замок выйшаў за термін. Чырвоныя ўтриманне унікальнага токена як значэння замка і адменістрацыя яго толькі тады, калі ён заўсёды збігаецца, зазвычай за дапамогою невялікага скрыпта на Lua, запобегае гэтаму.
  • pollRedisUntilPopulated патрабуе таймаут і альтернатыўны варіант, інакша запиты на чаканне можу застряць, якщо власнік замка зазнае краху.
  • У межах аднаго процеса Node.js таксама можна з’едыніць адночасныя невдалыя спробы, выкорыстоўваючы адну обяву з процесам выконання на кожны ключ, што зменшае колькасць спроб адміністрацыі замка прычыму, што Redis ўзагалі не будзе включаны.
  • Шаблон 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.
    • Разпадзелены замок гарантуе, што толькі адні разы запрашоўчык можа перзапісаць ключ; трэба астаточна старанна кераваць часам выгорэння замка, його прыналежнасцю і таймаутамі чакання.
  • Адынавечнае апцэнтаванне фону выключае практыку перзапуску процэса з выкарыстоўванням шляху запытку і ёсць найэфектывнейшым варыянтом для ключоў, якія актываўна і высока каштуюць у працэсе обробкі.
  • Расследжвайце кэшаванне як архітектурнае рашэння па захаванню базы дадзенаў пад навантажэнням, а не як спосаб прышвыдчэння роботы.