Головна / Статті

Кешування Stampedes у Node.js: чому кеш Redis може перевантажити вашу базу даних

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

Видавець: REACTAPP.TOP7 хв читання1648 слів

Джерела: “I Thought Redis Would Make My API Faster.” It Didn’t.

Додавання Redis перед повільним запитом — це класичне рішення для бази даних під великим тиском, і у спокійні дні це здається очевидною перемогою. Однак під час піку навантаження проста конфігурація з використанням кешу може значно погіршити ситуацію: як тільки термін дії популярного ключа закінчується, усі одночасні запити зазнають невдачі, що призводить до серйозного навантаження на базу даних. У цій статті пояснюється ця проблема, відома як «стампід у кеші», описується, як вона поширюється через API Node.js, та наводяться два підходи, які допомагають її уникнути: розподілений механізм блокування для оновлення даних та попередня обробка у фоновому режимі.

Рішення, яке працює до піку навантаження

Звичайною конфігурацією є використання кешу. При кожному запиті API шукає дані у Redis; якщо їх немає, він запитує базу даних та записує результат назад у Redis для подальшого використання. При роботі у дні з низьким трафіком це миттєво зменшує затримки.

Потім настає ранковий пік у понеділок, і API перестає відповідати. Процес Node.js показує низьке споживання CPU, тому проблема не в циклі подій. Однак основна база даних працює з 100% завантаженням CPU.

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 секунд, коли п’ятдесят копій конкурують за процесор, блокування та ввод-вивід.
  • Інстанції API утримують тисячі відкритих HTTP-з’єднань під час очікування, що споживає пам’ять та описники файлів.
  • Навантаження на процесор у рівні API залишається низьким, тому автоскалер, налаштований на процесорну потужність, може ніколи не додавати пропускну здатність, а додавання інстанцій 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 чи консуматор для захоплення змін даних — який оновлює ключ до того, як він закінчить свою дію. У наведеному нижче прикладі робот, який запускається кожні дев’ять хвилин, оновлює список популярних елементів та зберігає його з таким самим терміном дії у 10 хвилин, тож ключ ніколи не втрачає актуальності під час нормальної роботи.

    // 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 надає більш детальну інформацію.

    Вибір між ними

    Механізм блокування зберігає знайомий підхід із використанням кешу окремо та підходить для даних, які запитуються непередбачувано. Попередня обробка на фоні підходить для невеликого набору відомих, дорогих та часто читаних ключів, таких як список трендів або підсумок на панелі керування, та забезпечує найбільш передбачувану затримку. Додавання невеликої випадкової похибки до TTL також допомагає, коли багато ключів записуються одночасно та інакше закінчували б свою дію одночасно.

    Основні висновки

    • Redis є швидким; проблеми виникають через те, як додаток реагує на неможливість знаходження даних, а не через сам кеш.
    • Примітивний підхід із використанням кешу окремо перетворює одне закінчення терміну дії ключа на синхронізований наплив запитів до найповільнішого компонента, а проблеми можуть бути приховані під низьким навантаженням CPU API.
    • Розподілений механізм блокування гарантує, що лише один користувач буде генерувати ключ; необхідно ретельно керувати закінченням терміну дії блокування, правами власності та часом очікування.
  • Оновлення фоновою схемою повністю усуває необхідність перегенерації з шляху запиту та є найкращим варіантом для активних, дорогих ключів.
  • Розглядайте кешування як архітектурне рішення щодо захисту бази даних під навантаженням, а не як швидкий спосіб підвищення продуктивності.