Главная / Статьи

Контроль конкурентности в Node.js: как избежать сбоев API с помощью p-map и устранения узких мест

Бесконечное использование Promise.all может привести к сбоям API. Для ограничения параллельности, соблюдения правил лимитирования скорости и обеспечения стабильной работы систем нижнего уровня в Node.js следует использовать популярные инструменты p-map и Bottleneck.

Издатель: REACTAPP.TOP5 мин чтения1240 слов

Источники: Stop Melting Your APIs: Smart Concurrency Control in Node.js with p-map & Bottleneck

Большинство приложений Node.js не разрушаются из-за ошибок в логике. Они разрушаются потому, что слишком агрессивно пытаются выполнить все операции одновременно.

Представьте такую ситуацию: команда выпускает функционал, который за несколько секунд отправляет примерно 10 000 запросов к API. На локальной машине всё работает плавно и быстро, и результаты выглядят впечатляюще.

Как только тот же код попадает в производственную среду, ситуация меняется:

  • Везде появляются ошибки 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 решает проблему возникновения ошибок 429 из-за ограничений скорости API
    • Если нужна защита обоими способами, объедините эти библиотеки

    Шаблон, стоящий использования в производстве

    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 для обеспечения лимитов скорости
    • Добавьте логику повторных попыток для повышения устойчивости

    Это путь от кода, который просто работает, к коду, который действительно масштабируется.