Контроль конкурентности в Node.js: как избежать сбоев API с помощью p-map и устранения узких мест
Бесконечное использование Promise.all может привести к сбоям API. Для ограничения параллельности, соблюдения правил лимитирования скорости и обеспечения стабильной работы систем нижнего уровня в Node.js следует использовать популярные инструменты p-map и Bottleneck.
Источники: 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 }
);
Что это решает
- В любой момент времени одновременно выполняются всего пять запросов
- Остальные ожидают в очереди, пока не освободится место
Конкурентность на самом деле не связана с повышением скорости. Речь идет о контроле нагрузки на систему.
Установка значения 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));
- Ограничивает уровень конкурентности
- Распределяет запросы во времени
- Предотвращает всплески нагрузки до их возникновения
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для обеспечения лимитов скорости - Добавьте логику повторных попыток для повышения устойчивости
Это путь от кода, который просто работает, к коду, который действительно масштабируется.