Выбор между Promise.all, Promise.race и последовательными await-запросами
Узнайте, когда функция Promise.all() ускоряет работу API Node.js, почему она быстро терпит неудачу при любом отклонении, а также как выбрать подходящий асинхронный паттерн.
Выполнение асинхронных задач в Node.js сначала кажется простым решением: можно выполнять несколько операций одновременно вместо ожидания их по порядку, что ускоряет работу API. Однако на практике использование Promise.all() как стандартной практики вместо осознанного выбора может тихо превратить ускорение в проблему надежности. Не менее важно знать, когда можно параллелизовать операции, что произойдет, если одна из них сорвется, и когда лучше использовать другой инструмент, чем просто знать синтаксис.
1. Последовательный подход
Представьте API, которому необходимо собрать три вида данных:
- Информация о пользователе
- Заказы
- Оплаты
Простой первоначальный вариант может выглядеть так:
const user = await getUser(userId);
const orders = await getOrders(userId);
const payments = await getPayments(userId);
return {
user,
orders,
payments
};
Этот код работает нормально. Но внимательно посмотрите на порядок выполнения:
getUser()
↓
getOrders()
↓
getPayments()
Каждый шаг ожидает завершения предыдущего. Запрос к данным заказов не может начаться, пока не будет завершен запрос к пользователям, а запросы о платежах — пока не будет завершен запрос к заказам. Если каждый вызов занимает примерно одинаковое время:
getUser() = 200ms
getOrders() = 200ms
getPayments() = 200ms
то общее время выполнения запросов будет примерно таким:
200 + 200 + 200 = 600ms
Это потеря времени, если эти три вызова не связаны между собой.
2. Использование Promise.all()
Когда операции не зависят друг от друга, их можно запускать одновременно:
const [user, orders, payments] = await Promise.all([
getUser(userId),
getOrders(userId),
getPayments(userId)
]);
return {
user,
orders,
payments
};
Теперь порядок выполнения ближе к следующему:
getUser() ────────┐
│
getOrders() ────────┤
├──→ Promise.all()
getPayments() ────────┘
Вместо того чтобы платить за:
A + B + C
ваше общее время ожидания будет примерно таким:
max(A, B, C)
Следовательно, если каждый из трех вызовов по-прежнему занимает около 200 мс:
Sequential: ~600ms
Parallel: ~200ms
Это значимое улучшение. Но есть нюанс, который стоит запомнить:
Promise.all() не ускоряет отдельные операции.
Он просто позволяет независимым операциям выполняться одновременно, а не по очереди.
3. Пример API из реального мира
Рассмотрим экран панели управления, который должен отрисовывать:
Profile
Orders
Wishlist
Notifications
Это может соответствовать четырем отдельным запросам к базе данных или вызовам сервиса. Если написать код последовательно, он может выглядеть так:
const profile = await getUserProfile(userId);
const orders = await getUserOrders(userId);const wishlist = await getUserWishlist(userId);const notifications = await getUserNotifications(userId);return {
profile,
orders,
wishlist,
notifications
};
Теперь сравните это с параллельной версией:
const [
profile,
orders,
wishlist,
notifications
] = await Promise.all([
getUserProfile(userId),
getUserOrders(userId),
getUserWishlist(userId),
getUserNotifications(userId)
]);
return {
profile,
orders,
wishlist,
notifications
}
При условии, что эти четыре вызова действительно не зависят друг от друга, такая переработка может значительно снизить общую задержку API. Это одно из самых простых решений при оптимизации производительности Node.js.
Но это ещё не конец истории — есть нюанс, который необходимо понять перед тем, как применять эту схему везде.
4. Promise.all() сразу возвращает ошибку
Вот что часто вызывает путаницу. Возьмём такой пример:
const results = await Promise.all([
getUser(),
getOrders(),
getPayments()
]);
Что произойдёт, если функция getPayments() выбросит ошибку?
Весь вызов Promise.all() вернёт ошибку, независимо от результата остальных вызовов:
getUser() → SUCCESS
getOrders() → SUCCESS
getPayments() → ERROR
↓ Promise.all()
↓
REJECT
Вы не получите частичный результат с двумя успешными вызовами и маркером о неудачном. Вы получите исключение, и никакие данные не будут переданы:
try {
const [user, orders, payments] = await Promise.all([
getUser(userId),
getOrders(userId),
getPayments(userId)
]);
} catch (error) {
console.error(error);
}
Такое поведение «всё или ничего» имеет смысл, когда для валидности ответа требуется каждая операция из группы. Но это не всегда подходящий вариант.
5. Когда Promise.all() — неправильный выбор
Предположим, панель управления должна отображать:
- Профиль
Если сервис рекомендаций временно недоступен, должен ли полностью отказать загрузки панель управления? Почти наверняка нет. Лучшим решением будет что-то вроде:
Profile → Available
Notifications → Available
Recommendations → Unavailable
Именно для такой ситуации и создан метод Promise.allSettled():
const results = await Promise.allSettled([
getUserProfile(userId),
getRecommendations(userId),
getNotifications(userId)
]);
Вместо того чтобы остановиться при первом отклонении, он ждет завершения всех обещаний и сообщает о результатах всех из них:
[
{
status: "fulfilled",
value: profile
},
{
status: "rejected",
reason: error
},
{
status: "fulfilled",
value: notifications
}
]
После этого логика приложения может решить, как обработать каждый отдельный результат. Разница заключается в следующем:
Promise.all()
One fails
↓
Everything rejects
по сравнению с:
Promise.allSettled()
One fails
↓
You still receive every result
Ни один из подходов по своей сути не является лучшим — они разработаны для решения разных проблем, и выбор правильного зависит от того, следует ли считать одну неудачу фатальной для всей группы.
6. Цепочку операций не следует принуждать к параллельной выполнению
Есть ещё одна ловушка, на которую стоит обратить внимание.
Предположим, ваша рабочая процедура требует от вас:
- Создать пользователя
- Получить ID этого пользователя
- Создать заказ, связанный с этим пользователем
Эти шаги зависят друг от друга.
Вы физически не можете создать заказ до тех пор, пока не существует запись пользователя.
Это означает, что код вроде этого некорректен:
await Promise.all([
createUser(),
createOrder()
]);
Для создания заказа, скорее всего, требуется ID пользователя в качестве входных данных.
Правильный подход — выполнять эти шаги по очереди:
const user = await createUser();
const order = await createOrder(user.id);
Основной принцип здесь прост:
Операции, не связанные между собой, подходят для параллельной обработки.
Операции, зависящие от результатов друг друга, должны выполняться последовательно.
Не стоит использовать параллелизм только потому, что язык это позволяет.
7. Бесконечный параллелизм может перегрузить вашу систему
Существует более тонкая проблема, которую легко упустить из виду.
Рассмотрим этот фрагмент кода:
await Promise.all(
users.map(user => sendEmail(user.email))
);
При 10 пользователях это вряд ли приведет к каким-либо проблемам.
При 10 000 пользователях запускается тысячи одновременных операций.
Увеличение конкурентности автоматически не приводит к улучшению производительности.
Вы можете столкнуться с:
- Ограничениями на подключения к базе данных
- Лимитами скорости API
- Нагрузкой на память
- Задержками в сети
Вместо неограниченной параллельной обработки часто требуется ограниченная конкурентность.
Один из способов достижения этого — использование библиотеки для ограничения конкурентности:
import pLimit from "p-limit";
const limit = pLimit(5);const results = await Promise.all(
users.map(user =>
limit(() => sendEmail(user.email))
)
);
При такой настройке одновременно выполняется не более пяти операций.
Визуально разница выглядит так:
1000 tasks
↓Concurrency limit = 5 ↓5 tasks
5 tasks
5 tasks
5 tasks
...
Это медленнее, чем запуск всех 1 000 задач сразу.
Но это даёт гораздо больший контроль.
В реальных условиях контролируемая обработка часто работает лучше в целом, поскольку избегает перегрузки ресурсов, от которых зависят операции.
8. Promise.race() решает другую проблему
Существует ещё один метод, который часто путают с Promise.all():
Promise.race()
Promise.race() принимает решение — либо разрешает, либо отклоняет — в тот момент, когда первое из обработанных обещаний принимает решение.
Например:
const result = await Promise.race([
serverA(),
serverB()
]);
Визуально:
Server A ───────────────→ 500ms
Server B ───────→ 200ms ↓
Promise.race()
↓
Result
У этого паттерна есть законные применения, такие как сопоставление избыточных запросов друг с другом или реализация поведения таймаута.
Однако имейте в виду следующее:
Promise.race() не останавливает автоматически операции, проигравшие соревнование.
Если вам нужно отменить проигравшие операции, вы должны сделать это самостоятельно, обычно с использованием такого инструмента, как AbortController.
9. Не забывайте о поведении повторных попыток
Предположим, вы выполняете запрос к внешнему сервису:
const result = await paymentService();
Запрос сбрасывается из-за временных проблем с сетью.
Если вы обернёте всё в большой Promise.all() и слепо будете пытаться выполнить операцию заново при сбое, это может привести к новым проблемам.
Вы рискуете спровоцировать «шторм повторных попыток».
Прежде чем пытаться выполнить операцию заново, подумайте о следующем:
- Какие операции действительно безопасно выполнять заново?
- Сколько попыток должно быть разрешено?
- Как долго следует ждать между попытками?
- Является ли операция идемпотентной?
- Что, если внешний сервис уже работает с перегрузкой?
Распространённым подходом к временным ошибкам является экспоненциальное откладывание попыток.
Концептуально:
Attempt 1 → fail
↓
wait
↓
Attempt 2 → fail
↓
wait longer
↓
Attempt 3 → success
Скорость мало значит, если она ухудшает надёжность.
10. Применяйте тот же подход к запросам к базе данных
Хочется думать, что поскольку JavaScript поддерживает одновременные обещания, запросы к базе данных всегда должны выполняться параллельно.
Но это не всегда так.
Возьмем в качестве примера:
await Promise.all([
database.users.findMany(),
database.orders.findMany(),
database.products.findMany(),
database.payments.findMany(),
database.notifications.findMany()
]);
Это запускает примерно пять операций с базой данных одновременно.
Это может быть совершенно нормально.
Или же это может привести к превышению лимитов базы данных в период пиковой нагрузки.
Факторы, которые стоит учитывать, включают:
- Степень сложности каждого запроса
- Размер пула подключений к базе данных
- Количество запущенных экземпляров API
- Общий объем трафика
- Наличие соответствующих индексов
- Время выполнения каждого запроса
- Свободные ресурсы CPU и памяти на сервере базы данных
Оптимизация производительности невозможна в вакууме.
Ваш API — это лишь часть гораздо более крупной системы.
11. Фреймворк для принятия решения о использовании Promise.all()
Перед применением Promise.all() полезно рассмотреть три вопроса.
Вопрос 1: Являются ли операции независимыми?
Если да, их выполнение параллельно может быть целесообразным.
Если нет, необходимо соблюдать порядок, от которого они зависят.
Вопрос 2: Что должно произойти, если одна операция завершится с ошибкой?
Если одна ошибка должна сделать недействительным весь пакет операций:
Promise.all()
скорее всего, является подходящим инструментом.
Если вы предпочитаете собирать результаты тех операций, которые выполнены успешно:
Promise.allSettled()
обычно более подходит для такой ситуации.
Вопрос 3: Сколько операций запускается одновременно?
Три параллельных вызова?
Это, как правило, управляемо.
Десять тысяч?
Это совершенно другая проблема.
Возможно, потребуется внедрить:
- Лимиты на параллельность
- Группировку операций в пакеты
- Очереди обработки
- Пагинацию
- Ограничение скорости выполнения
12. Краткий справочник по выбору подхода
| Ситуация | Лучший подход |
|---|---|
| Независимые операции, все должны завершиться успешно | Promise.all() |
| Независимые операции, допускается частичный успех | Promise.allSettled() |
| Операции зависят друг от друга | Последовательное использование await |
| Вам нужен только тот результат, который появится первым | Promise.race() |
| Множество задач, требующих ограниченной одновременности | p-limit или группировка задач |
| Медленная, незапланированная фоновая работа | Очередь или рабочий процесс |
| Внешние вызовы, склонные к временным сбоям | Логика повторных попыток с задержками |
Важно не запоминать эту таблицу.
Важно понимать причины, лежащие в основе каждого выбора.
13. Основной вывод
При первом знакомстве с Promise.all() естественно предположить:
«Выполнение операций параллельно всегда быстрее».
Но это предположение неверно.
Более точный способ взгляда на это:
Параллельное выполнение оправдано только тогда, когда подложная система действительно может обработать одновременную нагрузку.
Если у вас есть три независимые операции, каждая из которых занимает 100 мс:
Sequential → ~300ms
Parallel → ~100ms
Улучшение очевидно.
Но если увеличить количество операций до 10 000 в базе данных с ограничением в 100 одновременных соединений, неконтролируемый параллелизм может снизить производительность вместо того, чтобы её повысить.
Лучший вопрос, который стоит себе задать:
"Can I run these in parallel?"Ask:"Should I run these in parallel?"
Такой сдвиг в подходе отделяет знание синтаксиса JavaScript от реального понимания того, как ведут себя бэкенд-системы при высокой нагрузке.
Итоги
Promise.all() остается одним из самых ценных инструментов в Node.js для одновременной обработки независимых асинхронных задач.
Однако он не гарантирует ускорения.
Используйте его в следующих случаях:
- Задачи не зависят друг от друга
- Вам действительно нужны все результаты
- Ваша система может справиться с дополнительной конкурентностью
Используйте Promise.allSettled(), когда допустимо, чтобы некоторые операции завершились с ошибкой без срыва работы остальных.
Используйте последовательные вызовы await, когда операции зависят от результатов друг друга.
Устанавливайте ограничения на конкурентность, когда вы обрабатываете большое количество задач одновременно.
И используйте очереди в тех случаях, когда выполнение задачи не требуется до окончания срока жизни HTTP-запроса.
Цель не в том, чтобы просто сократить время выполнения кода на несколько миллисекунд.
Цель — сделать вашу систему быстрее, не делая её хрупкой.
Связанные материалы
- Девять шаблонов Promise для надежного асинхронного JavaScript в производстве — Узнайте о практических шаблонах Promise: параллельные запросы, таймауты, повторные попытки, ограничения на одновременность выполнения и отмена задач — для создания надежного асинхронного JavaScript промышленного уровня.