Wybór między Promise.all, Promise.race a sekwencyjnymi awaitami
Dowiedz się, kiedy Promise.all() przyspiesza API Node.js, dlaczego szybko zawodzi przy każdej odrzuceń oraz jak wybrać odpowiedni wzorzec asynchroniczny za pomocą ram decyzyjnych.
Wykonywanie zadań asynchronicznych w Node.js na początku wydaje się prostym rozwiązaniem: można uruchomić kilka operacji jednocześnie zamiast czekać na nie kolejno, co sprawia, że API staje się szybsze. W praktyce jednak traktowanie Promise.all() jako nawyku standardowego, a nie świadomej decyzji, może potajemnie przekształcić wzrost szybkości w problem z niezawodnością. Równie ważne jest znanie momentu, w którym operacje można bezpiecznie paralelizować, tego, co powinno się stać, jeśli jedna z nich zawiedzie, oraz kiedy inne narzędzie będzie lepsze, co nie mniej istotne niż znajomość składni.
1. Podejście sekwencyjne
Załóżmy API, które musi połączyć trzy elementy:
- Informacje o użytkowniku
- Zamówienia
- Płatności
Naiwny pierwszy podejście mogłoby wyglądać tak:
const user = await getUser(userId);
const orders = await getOrders(userId);
const payments = await getPayments(userId);
return {
user,
orders,
payments
};
Ten kod działa poprawnie. Ale przyjrzyj się uważnie kolejności wykonywania:
getUser()
↓
getOrders()
↓
getPayments()
Każdy krok czeka, aż poprzedni się zakończy. Operacja pobierania zamówień nie może rozpocząć się, dopóki nie zakończy się operacja pobierania użytkowników, a operacja pobierania płatności nie może rozpocząć się, dopóki nie zakończy się operacja pobierania zamówień. Jeśli każde wezwanie trwa mniej więcej tyle samo czasu:
getUser() = 200ms
getOrders() = 200ms
getPayments() = 200ms
wtedy całkowity czas wysyłania żądania wynosi mniej więcej tyle:
200 + 200 + 200 = 600ms
To jest zmarnowany czas, jeśli te trzy wezwania nie mają ze sobą nic wspólnego.
2. Użyj Promise.all()
Gdy operacje nie zależą od siebie, można je uruchomić jednocześnie:
const [user, orders, payments] = await Promise.all([
getUser(userId),
getOrders(userId),
getPayments(userId)
]);
return {
user,
orders,
payments
};
Teraz przepływ wykonywania przypomina coś takiego:
getUser() ────────┐
│
getOrders() ────────┤
├──→ Promise.all()
getPayments() ────────┘
Zamiast ponosić koszt:
A + B + C
całkowity czas oczekiwania wynosi mniej więcej tyle:
max(A, B, C)
Zatem jeśli każde z tych trzech wezwań nadal zajmuje około 200 ms:
Sequential: ~600ms
Parallel: ~200ms
To jest istotna korzyść. Ale istnieje niuans, który warto pamiętać:
Promise.all() nie przyspiesza żadnej pojedynczej operacji.
Po prostu umożliwia wykonywanie niezależnych operacji równocześnie, zamiast jeden po drugim.
3. Przykład API z rzeczywistego świata
Rozważmy ekran panelu kontrolnego, który musi wyświetlić:
Profile
Orders
Wishlist
Notifications
To mogłoby odpowiadać czterem oddzielnym zapytaniom do bazy danych lub wywołaniom usług. Napisane sekwencyjnie, mogłyby wyglądać w ten sposób:
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
};
A teraz porównaj to z wersją równoległą:
const [
profile,
orders,
wishlist,
notifications
] = await Promise.all([
getUserProfile(userId),
getUserOrders(userId),
getUserWishlist(userId),
getUserNotifications(userId)
]);
return {
profile,
orders,
wishlist,
notifications
}
Przy założeniu, że te cztery wywołania rzeczywiście nie zależą od siebie, ta przepracowanie może znacznie zmniejszyć całkowitą opóźnioność API. Jest to jeden z najprostszych sposobów na poprawę wydajności w Node.js.
Ale to nie koniec historii — istnieje pewien haczyk, który musisz zrozumieć, zanim zastosujesz ten wzorzec wszędzie.
4. Promise.all() zawodzi szybko
Oto to, co sprawia problemy. Weźmy ten przykład:
const results = await Promise.all([
getUser(),
getOrders(),
getPayments()
]);
Co się stanie, jeśli getPayments() wywoła błąd?
Celowe wezwanie Promise.all() zawsze zawiedzie, bez względu na to, jak przebiegły pozostałe wywołania:
getUser() → SUCCESS
getOrders() → SUCCESS
getPayments() → ERROR
↓ Promise.all()
↓
REJECT
Nie otrzymujesz częściowego wyniku z dwoma udanymi wywołaniami oraz oznaczenia dla tego, które zawiodło. Otrzymujesz wyjątek, a żadne dane nie trafiają dalej:
try {
const [user, orders, payments] = await Promise.all([
getUser(userId),
getOrders(userId),
getPayments(userId)
]);
} catch (error) {
console.error(error);
}
To zachowanie typu „wszystko albo nic” ma sens, gdy każda operacja w grupie jest konieczna, aby odpowiedź była ważna. Ale nie zawsze jest to właściwe rozwiązanie.
5. Kiedy Promise.all() to zły wybór
Powiedzmy, że panel sterowania musi pokazywać:
- Profil
Jeśli usługa zaleceń jest tymczasowo niedostępna, czy cała karta sterująca powinna przestać się ładować? Prawie na pewno nie. Lepszym rozwiązaniem byłoby coś w rodzaju:
Profile → Available
Notifications → Available
Recommendations → Unavailable
To jest dokładnie sytuacja, do której została stworzona funkcja Promise.allSettled():
const results = await Promise.allSettled([
getUserProfile(userId),
getRecommendations(userId),
getNotifications(userId)
]);
Zamiast zatrzymać się przy pierwszej odmowie, czeka ona, aż wszystkie obietnice zostaną rozstrzygnięte, i zgłasza informacje o nich wszystkich:
[
{
status: "fulfilled",
value: profile
},
{
status: "rejected",
reason: error
},
{
status: "fulfilled",
value: notifications
}
]
Następnie logika aplikacji może zdecydować, jak postąpić z każdym pojedynczym wynikiem. Różnica sprowadza się do tego:
Promise.all()
One fails
↓
Everything rejects
w porównaniu z:
Promise.allSettled()
One fails
↓
You still receive every result
Żaden z tych podejść nie jest z natury lepszy — zostały one zaprojektowane do rozwiązywania różnych problemów, a wybór odpowiedniego zależy od tego, czy pojedynczy błąd powinien być traktowany jako fatalny dla całej grupy.
6. Operacje łańcuchowe nie powinny być przymuszane do wykonywania równolegle
Istnieje jeszcze jedna pułapka, na którą warto zwrócić uwagę.
Załóżmy, że twój proces pracy wymaga od ciebie:
- Stworzenia użytkownika
- Pobrania ID tego użytkownika
- Stworzenia zamówienia powiązanego z tym użytkownikiem
Te kroki są od siebie zależne.
Fizycznie nie można stworzyć zamówienia przed istnieniem rekordu użytkownika.
To oznacza, że pisanie czegoś w tym stylu jest błędne:
await Promise.all([
createUser(),
createOrder()
]);
Krok tworzenia zamówienia prawdopodobnie wymaga ID użytkownika jako wejścia.
Prawidłowym podejściem jest wykonywanie tych kroków jeden po drugim:
const user = await createUser();
const order = await createOrder(user.id);
Zasada kierująca jest tutaj prosta:
Operacje, które nie są ze sobą powiązane, nadają się do wykonywania równolegle.
Operacje, które polegają na wynikach innych operacji, muszą być wykonywane sekwencyjnie.
Nie stosuj paralelizmu tylko dlatego, że język na to pozwala.
7. Nieograniczony paralelizm może przytłoczyć twój system
Istnieje subtelniejszy problem, który łatwo przeoczyć.
Rozważmy ten fragment kodu:
await Promise.all(
users.map(user => sendEmail(user.email))
);
Przy 10 użytkownikach raczej nie powinno to sprawić żadnych problemów.
Przy 10 000 użytkownikach uruchamiasz tysiące operacji jednocześnie.
Możesz napotkać:
- Ograniczenia dotyczące połączeń z bazą danych
- Limity szybkości API
- Nawantrzenie pamięci
- Zatory w sieci
Zamiast nieograniczonej równoległej eksploatacji, często potrzebna jest ograniczona konkurencja.
Jednym ze sposobów na jej osiągnięcie jest użycie biblioteki ograniczającej konkurencję:
import pLimit from "p-limit";
const limit = pLimit(5);const results = await Promise.all(
users.map(user =>
limit(() => sendEmail(user.email))
)
);
W takim ustawieniu jednocześnie może być wykonywanych maksymalnie pięć operacji.
Wizualnie różnica wygląda tak:
1000 tasks
↓Concurrency limit = 5 ↓5 tasks
5 tasks
5 tasks
5 tasks
...
To jest wolniejsze niż uruchomienie wszystkich 1000 zadań naraz.
Jednak daje znacznie większą kontrolę.
A w warunkach rzeczywistych kontrolowana eksploatacja często daje lepsze wyniki ogólne, ponieważ unika przeciążenia zasobów, od których zależą Twoje operacje.
8. Promise.race() rozwiązuje inne problemy
Istnieje jeszcze jedna metoda, którą myli się z Promise.all():
Promise.race()
Promise.race() ustala się – rozwiązując lub odrzucając – w momencie, gdy pierwsza obietnica w grupie się ustali.
Na przykład:
const result = await Promise.race([
serverA(),
serverB()
]);
Wizualnie:
Server A ───────────────→ 500ms
Server B ───────→ 200ms ↓
Promise.race()
↓
Result
Ten wzorzec ma uzasadnione zastosowania, takie jak porównywanie ze sobą zbędnych żądań lub implementacja zachowania timeoutu.
Jednak pamiętaj o tym:
Promise.race() nie zatrzymuje automatycznie operacji, które przegrywają tę rywalizację.
Jeśli musisz anulować przegrywające operacje, musisz to zrobić samodzielnie, zazwyczaj za pomocą czegoś takiego jak AbortController.
9. Nie zapominaj o zachowaniu ponawiania prób
Załóżmy, że wysyłasz żądanie do zewnętrznej usługi:
const result = await paymentService();
Żądanie zawodzi z powodu chwilowego problemu sieciowego.
Jeśli otoczysz wszystko dużym Promise.all() i ślepo będziesz próbował ponownie po niepowodzeniu, możesz spowodować nowy problem.
Ryzykujesz wywołanie burzy ponownych prób.
Zanim spróbujesz ponownie, rozważ:
- Które operacje są faktycznie bezpieczne do ponownej próby?
- Ile prób powtórzenia powinno być dozwolonych?
- Jak długo należy czekać między próbami?
- Czy operacja jest idempotentna?
- A co, jeśli usługa zewnętrzna już ma trudności pod obciążeniem?
Powszechnym rozwiązaniem na błędy tymczasowe jest eksponencjalne opóźnienie.
Koncepcyjnie:
Attempt 1 → fail
↓
wait
↓
Attempt 2 → fail
↓
wait longer
↓
Attempt 3 → success
Szybkość ma niewielkie znaczenie, jeśli podważa niezawodność.
10. Zastosuj to samo rozumowanie do zapytań bazodanowych
Kuszące jest myślenie, że ponieważ JavaScript obsługuje równoczesne obietnice, zapytania bazodanowe powinny zawsze być wysyłane równolegle.
To nie zawsze jest prawdą.
Weźmy ten przykład:
await Promise.all([
database.users.findMany(),
database.orders.findMany(),
database.products.findMany(),
database.payments.findMany(),
database.notifications.findMany()
]);
To uruchamia mniej więcej pięć operacji bazy danych jednocześnie.
To może być zupełnie w porządku.
Lub może to przeciążyć twoją bazę danych podczas szczytu ruchu.
Czynniki, które warto wziąć pod uwagę, to:
- Jak złożona jest każda zapytanie
- Rozmiar puli połączeń do bazy danych
- Ile instancji API jest uruchomionych
- Ogólny wolumen ruchu
- Czy istnieją odpowiednie indeksy
- Ile czasu zajmuje wykonanie każdego zapytania
- Zapas mocy obliczeniowej i pamięci na serwerze bazy danych
Optymalizacja wydajności nie może odbywać się w próżni.
11. Ramy decyzyjne dotyczące użycia Promise.all()
Zanim zastosujesz Promise.all(), pomocne jest rozważenie trzech pytań.
Pytanie 1: Czy operacje są niezależne?
Jeśli tak, ich wykonywanie równolegle może być opłacalne.
Jeśli nie, należy zachować kolejność, od której zależą.
Pytanie 2: Co powinno się stać, jeśli jedna operacja zawiedzie?
Jeśli pojedyncza awaria powinna unieważnić całą partię:
Promise.all()
prawdopodobnie jest to odpowiedni narzędzie.
Jeśli wolisz zebrać wszystkie wyniki uzyskane pomyślnie:
Promise.allSettled()
zazwyczaj jest to lepsze rozwiązanie.
Pytanie 3: Ile operacji jest uruchamianych jednocześnie?
Trzy równoległe wywołania?
To zazwyczaj da się obsłużyć.
Dziesięć tysięcy?
To zupełnie inny wyzwanie.
Powinieneś rozważyć wprowadzenie:
- Limitów równoległości
- Grupowania operacji
- Kolejkowania zadań
- Paginacji
- Ograniczania szybkości wykonywania
12. Krótka przewodnik do wyboru podejścia
| Sytuacja | Lepsze podejście |
|---|---|
| Operacje niezależne, wszystkie muszą się udać | Promise.all() |
| Operacje niezależne, częściowy sukces jest do przyjęcia | Promise.allSettled() |
| Operacje zależą od siebie | Sekwencyjne await |
| Potrzebny jest tylko ten, który skończy się pierwszy | Promise.race() |
| Liczne zadania wymagające ograniczonej równoczesności | p-limit lub grupowanie zadań |
| Wolne, nieważne zadania w tle | Kolejka lub proces roboczy |
| Zawodne połączenia z zewnętrznymi serwerami | Logika ponawiania prób z opóźnieniami |
Nie chodzi o zapamiętanie tej tabeli.
Chodzi o zrozumienie uzasadnienia każdego wyboru.
13. Główny wniosek
Gdy po raz pierwszy spotykamy się z Promise.all(), naturalne jest założenie:
"Wykonywanie zadań równolegle jest zawsze szybsze."
To założenie nie jest prawdziwe.
Bardziej dokładny sposób patrzenia na to jest następujący:
Równoległe wykonywanie jest opłacalne tylko wtedy, gdy system pod nim faktycznie może obsłużyć taką liczbę zadań jednocześnie.
Jeśli masz trzy niezależne operacje, z których każda trwa 100 ms:
Sequential → ~300ms
Parallel → ~100ms
Poprawa jest oczywista.
Jednak jeśli zwiększysz to do 10 000 operacji przeciwko bazie danych ograniczonej do 100 jednoczesnych połączeń, niekontrolowany paralelizm może pogorszyć wydajność zamiast ją poprawić.
Lepiej postawić sobie takie pytanie:
"Can I run these in parallel?"Ask:"Should I run these in parallel?"
Taka zmiana sposobu myślenia odróżnia znajomość składni JavaScript od rzeczywistego zrozumienia tego, jak systemy backendowe zachowują się pod obciążeniem.
Ostateczny wniosek
Promise.all() pozostaje jednym z najcenniejszych narzędzi w Node.js do równoczesnego wykonywania niezależnych zadań asynchronicznych.
Niemniej jednak nie gwarantuje to przyspieszenia.
Użyj go wtedy, gdy:
- Operacje nie są od siebie zależne
- Rzeczywiście potrzebujesz wszystkich wyników
- Twój system może obsłużyć dodatkową równoczesność
Użyj Promise.allSettled(), gdy dopuszczalne jest, aby niektóre operacje zawiodły bez zakłócenia pozostałych.
Użyj sekwencyjnych wywołań await, gdy operacje zależą od wyników siebie nawzajem.
Użyj ograniczeń równoczesności, gdy obsługujesz dużą liczbę zadań jednocześnie.
I uciekaj się do kolejek, gdy praca nie musi zostać zakończona w czasie trwania żądania HTTP.
Celem nie jest tylko skrócenie czasu wykonywania kodu o kilka milisekund.
Celem jest sprawienie, by twój system działał szybciej, nie stając się przy tym kruchy.
Literatura pokrewna
- Dziewięć wzorów Promise dla niezawodnego asynchronicznego JavaScript-u używanego w produkcji — Poznaj praktyczne wzory Promise – żądania równoległe, timeouty, ponawianie prób, limity współbieżności oraz anulowanie – służące do tworzenia odpornego, profesjonalnego asynchronicznego JavaScript-u.