Вибір між Promise.all, Promise.race та послідовними await-операторами
Дізнайтеся, коли Promise.all() прискорює роботу API Node.js, чому він швидко зазнає невдачі при будь-якому відхиленні, та яка система прийняття рішень для вибору правильного асинхронного паттерну.
Виконання асинхронних завдань у Node.js спочатку здається простим рішенням: можна виконувати кілька операцій одночасно замість того, щоб чекати на них по черзі, і так API стає швидшим. Однак на практиці використання Promise.all() як звички замість свідомого вибору може тихо перетворити підвищення швидкості на проблему надійності. Так само важливо знати, коли операції можна паралелізувати, що має статися, якщо одна з них зламається, та коли підходить інший інструмент, як і знання синтаксису.
1. Послідовний підхід
- Інформацію про користувача
- Замовлення
- Оплати
Наївний перший підхід може виглядати так:
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() приймає рішення — вирішує або відхиляє — у момент, коли перший з об’єктів Promise у групі приймає рішення.
Наприклад:
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 промислового рівня.