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

Керування конкурентністю у Node.js: як уникнути краху API за допомогою p-map та Bottleneck

Безобмежний використання Promise.all може пошкодити API. Об’єднайте p-map та Bottleneck у Node.js, щоб обмежити одночасність виконання, дотримуватися правил обмеження частоти запитів та зберегти стабільність систем нижчого рівня.

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

Джерела: Stop Melting Your APIs: Smart Concurrency Control in Node.js with p-map & Bottleneck

Більшість додатків Node.js не зазнають краху через неправильну логіку. Вони зазнають краху тому, що надто агресивно намагаються виконати всі завдання одночасно.

Коли цей самий код потрапляє у продакшн, ситуація змінюється:

  • Скрізь з’являються помилки 429
  • API сторонніх постачальників починає відхиляти запити
  • Споживання пам’яті різко зростає
  • Логіка повторних спроб лише погіршує ситуацію замість того, щоб покращити її

Справжньою проблемою ніколи не був сам зовнішній API. Проблемою була конкурентність без жодних обмежень.

Пастка, прихована всередині Promise.all

Багато розробників спочатку вдаються саме до цього паттерну:

await Promise.all(urls.map(fetchData));

Цей код читається зрозуміло та, схоже, ефективний у роботі.

Але придивіться уважніше до того, що він насправді запускає:

  • Кожен запит виконується одночасно
  • Немає обмежень щодо кількості запитів, які виконуються одночасно
  • Немає механізму контролю навантаження
  • Ліміти швидкості, встановлені API, повністю ігноруються

На практиці ця схема спричиняє раптові сплески навантаження.

Promise.all не забезпечує контролю конкурентності. Він спричиняє її експлозію.

Перший крок: обмеження конкурентності за допомогою p-map

Якщо ваша мета — обмежити кількість операцій, які виконуються паралельно, p-map — це інструмент саме для цього.

import pMap from 'p-map';

const result = await pMap(
  urls,
  async (url) => {
    const res = await fetch(url);
    return res.json();
  },
  { concurrency: 5 }
);

Що це виправляє

  • Водночас виконується лише п’ять запитів
  • Решта залишаються у черзі, поки не звільниться місце
  • Використання CPU та пам’яті залишається стабільним
  • Конкурентність насправді не стосується досягнення вищої швидкості. Йдеться про контроль навантаження на систему.

    Встановлення значення concurrency: 5 не гарантує уникнення обмежень швидкості.

    API не лише відстежує кількість запитів, що надходять одночасно, а й кількість запитів протягом певного часового проміжку.

    Це означає, що потрібно керувати часом, а не лише кількістю паралельних викликів.

    import Bottleneck from "bottleneck";
    
    const limiter = new Bottleneck({
      maxConcurrent: 5,
      minTime: 200 // ~5 requests per second
    });
    const fetchWithLimit = limiter.wrap(async (url) => {
      const res = await fetch(url);
      return res.json();
    });
    const results = await Promise.all(urls.map(fetchWithLimit));
    

    • Обмежує рівень конкурентності
    • Розподіляє запити в часі
    • Зупиняє сплески навантаження ще до їх виникнення

    Замість того, щоб масово надсилати запити до API, ви тепер надсилаєте їх у стабільному, прогнозованому темпі.

    p-map проти Bottleneck

    Кожен інструмент вирішує різну проблему:

    • p-map допомагає у ситуаціях, коли занадто багато обіцянок виконуються одночасно
    • Bottleneck допомагає у ситуаціях, коли обмеження швидкості API спричиняє помилки 429
    • Якщо потрібен захист обома способами, поєднайте ці дві бібліотеки

    Шаблон, який варто використовувати у продакшені

    import pMap from 'p-map';
    import Bottleneck from 'bottleneck';
    
    const limiter = new Bottleneck({
      minTime: 200
    });
    const task = limiter.wrap(async (url) => {
      const res = await fetch(url);
      return res.json();
    });
    await pMap(urls, task, { concurrency: 5 });
    

    Це поєднання забезпечує:

    • Стабільну, обмежену конкурентність
    • Захист від перевищення обмежень швидкості
    • Профіль навантаження, який можна передбачити заздалегідь

    Боротьба з несправностями: повторні спроби з відстрочкою

    Навіть ретельно налаштована система іноді стикається з несправностями. Те, що відрізняє стійку систему від крихкої, — це спосіб її реагування на ці несправності.

    async function fetchWithRetry(url, retries = 3) {
      try {
        const res = await fetch(url);
        if (!res.ok) throw new Error('Failed');
        return res.json();
      } catch (err) {
        if (retries === 0) throw err;
    
    // exponential backoff
        const delay = 2 ** (3 - retries) * 100;
        await new Promise(r => setTimeout(r, delay));
        return fetchWithRetry(url, retries - 1);
      }
    }
    

    Чому тут важлива відстрочка

    • Повторні спроби без жодної відстрочки часто призводять до „бурь повторних спроб“
  • Повторна спроба з відстрочкою дозволяє системі граційно відновитися.
  • Компроміс між пропускною здатністю та затримкою

    Саме тут починають впливати справжні рішення щодо проектування системи.

    • Нижча конкурентність забезпечує більшу стабільність, але уповільнює роботу
    • Вища конкурентність прискорює процес, але підвищує ризики

    Наприклад:

    • Якщо встановити рівень конкурентності на 2, все залишиться стабільним, хоча й повільно
    • Якщо встановити рівень конкурентності на 20, виконання прискориться, але це може призвести до перевищення лімітів швидкості

    Не існує універсального ідеального значення. Його потрібно налаштовувати відповідно до того, що насправді може обробити ваша система та цільовий API.

    Спостережуваність: крок, який ігнорують більшість команд

    Не просто впроваджуйте цю конфігурацію та залишайте її без нагляду в продакшені.

    Стежте за:

    • розміром черги
    • середній час очікування запитів
    • співвідношення успіхів до невдач

    Без можливості бачити, що насправді робить ваша система, її правильна налаштування перетворюється на здогадки.

    Де місце чергам на кшталт BullMQ чи Kafka?

    Це цілком закономірне запитання.

    • Черги повідомлень забезпечують надійну асинхронну обробку
    • p-map та Bottleneck дозволяють здійснювати контроль у межах одного сервісу

    Використовуйте черги, коли:

    • завдання мають залишатися активними після збою чи перезапуску
    • обсяг роботи є великим або розподілений між кількома сервісами

    Використовуйте підхід p-map та Bottleneck, коли:

    • вам потрібна швидка, контрольована обробка, яка залишається в межах одного сервісу

    Виміряний ефект після застосування

    Після одночасного впровадження обмежень на конкурентність та обмежень швидкості виконання одна з систем у продакшені показала наступне:

    • Рівень помилок 429 знизився з приблизно 18% до менше ніж 1%
    • Значно покращилась стабільність під час взаємодії з зовнішніми API
    • Повне припинення проблем, пов’язаних із багаторазовими спробами виконання

    Логіка програми залишилась незмінною. Змінилася лише дисципліна, яка застосовувалася під час її виконання.

    Заключні міркування

    Написання асинхронного коду є базовою навичкою для розробників Node.js. Знання того, як керувати швидкістю виконання цього коду, — ось що відрізняє справжніх інженерів бекенду.

    Якщо ваш сервіс взаємодіє з:

    • зовнішніми API
    • базами даних
    • іншими мікросервісами

    тоді керування конкурентністю перестає бути додатковою функцією та стає ключовою частиною вашої архітектури.

    Підсумок

    Якщо ваш код у продакшені все ще використовує Promise.all без жодних обмежень, до збою залишається лише один сплеск навантаження.

    Практична відправна точка виглядає так:

    • Введіть p-map для обмеження конкурентності
    • Введіть Bottleneck для впровадження обмежень швидкості
    • Додайте логіку повторних спроб для забезпечення стійкості

    Це шлях від коду, який просто працює, до коду, який справді може масштабуватися.