Opanowanie równoległości w Node.js: Unikanie awarii API za pomocą p-map i Bottleneck
Bezgraniczne użycie Promise.all może doprowadzić do awarii API. Połącz p-map z Bottleneck w Node.js, aby ograniczyć jednoczesne wykonywanie zadań, przestrzegać ograniczeń szybkości i zapewnić stabilność systemów poniżej w łańcuchu.
Źródła: Stop Melting Your APIs: Smart Concurrency Control in Node.js with p-map & Bottleneck
Większość aplikacji Node.js nie zawodzi z powodu błędnej logiki. Zawodzą, ponieważ działają zbyt agresywnie, wszystkie jednocześnie.
Załóżmy taki scenariusz: zespół wypuszcza funkcjonalność, która w ciągu kilku sekund wysyła około 10 000 żądań API. Na lokalnym komputerze wszystko działa płynnie i szybko, a wyniki są imponujące.
Gdy ten sam kod trafia do środowiska produkcyjnego, sytuacja się zmienia:
- Wszędzie pojawiają się błędy 429
- API firmy trzeciej zaczyna odrzucać żądania
- Zwiększa się zużycie pamięci
- Logika ponawiania prób pogarsza sytuację zamiast ją poprawić
Prawdziwym problemem nigdy nie było samo zewnętrzne API. Była to konkurencja bez żadnych ograniczeń.
Pułapka ukryta w Promise.all
Wielu programistów najpierw sięga po ten wzorzec:
await Promise.all(urls.map(fetchData));
Czyta się to klarownie i wydaje się efektywne pod względem wydajności.
Ale przyjrzyj się uważniej temu, co faktycznie jest uruchamiane:
- Każda prośba jest wysyłana jednocześnie
- Nie ma ograniczenia co do liczby operacji wykonywanych jednocześnie
- Nie ma mechanizmu ograniczania przepustowości
- Limity szybkości ustalone przez API są całkowicie ignorowane
W praktyce ten wzorzec zachowuje się jak generator nagłych skoków ruchu.
Promise.allnie zapewnia kontroli nad równoczesnością. Powoduje natomiast jej eksplozję.
Krok pierwszy: Ograniczenie równoczesności za pomocą p-map
Jeśli twoim celem jest ograniczenie liczby operacji wykonywanych równolegle, p-map to narzędzie stworzone do tego celu.
import pMap from 'p-map';
const result = await pMap(
urls,
async (url) => {
const res = await fetch(url);
return res.json();
},
{ concurrency: 5 }
);
Co to naprawia
- W danym momencie równolegle wykonywanych jest zaledwie pięć żądań
- Pozostałe czekają w kolejce, aż uwalni się wolne miejsce
Podstawowa koncepcja
Konkurencja nie polega tak naprawdę na osiąganiu większej szybkości. Chodzi o utrzymanie kontroli nad obciążeniem systemu.
Dlaczego to samo w sobie nie wystarcza
Ustawienie concurrency: 5 nie gwarantuje uniknięcia ograniczeń szybkości.
API nie tylko śledzi, ile żądań przychodzi jednocześnie, ale także ilu przychodzi w określonym przedziale czasowym.
To oznacza, że musisz zarządzać czasem, a nie tylko liczbą równoległych wywołań.
Krok drugi: Wprowadzenie ograniczeń szybkości za pomocą Bottleneck
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));
Co to zapewnia
- Utrzymuje konkurencję w określonych granicach
- Rozkłada żądania w czasie
- Zatrzymuje nagłe wzrosty obciążenia przed ich wystąpieniem
Zamiast nagle obciążać API, teraz dostarczasz mu żądania w stałym, przewidywalnym tempie.
p-map kontra Bottleneck
Każde narzędzie rozwiązuje inny problem:
- p-map radzi sobie z sytuacją, gdy zbyt wiele obietnic jest wykonywanych jednocześnie
- Bottleneck radzi sobie z sytuacją, gdy ograniczenia szybkości API powodują błędy 429
- Gdy potrzebujesz obu form ochrony, połącz te dwie biblioteki
Wzorzec wart wykorzystania w produkcji
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 });
To połączenie zapewnia:
- Stabilną, ograniczoną konkurencję
- Ochronę przed przekroczeniem ograniczeń szybkości
- Profil obciążenia, który można przewidzieć z wyprzedzeniem
Radzenie sobie z awariami: ponawianie prób z opóźnieniem
Nawet starannie zarządzany system czasami napotka awarie. To, co odróżnia system odporny od kruchego, to sposób, w jaki reaguje na te awarie.
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);
}
}
Dlaczego opóźnienie jest tu ważne
- Ponawianie prób bez żadnego opóźnienia może prowadzić do burz ponawiania prób
Kompromis pomiędzy przepustowością a opóźnieniem
Tutaj właśnie decydujące znaczenie mają rzeczywiste wybory projektowe systemu.
- Niższa współbieżność zapewnia większe bezpieczeństwo, ale wolniejszą pracę systemu
- Wyższa współbieżność przyspiesza działanie, ale zwiększa ryzyko
Na przykład:
- Ustawienie współbieżności na 2 zapewnia stabilność, choć wolniejszą prędkość działania
- Ustawienie współbieżności na 20 przyspiesza wykonywanie zadań, ale może doprowadzić do przekroczenia limitów szybkości
Nie istnieje uniwersalna, idealna wartość. Trzeba ją dostosować do tego, co faktycznie może obsłużyć dany system oraz API docelowe.
Zobieralność danych: krok, który najczęściej pomija się
Nie wystarczy zainstalować tej konfiguracji i zapomnieć o niej w środowisku produkcyjnym.
Należy obserwować:
- jaki jest rozmiar kolejki oczekujących zadań
- średni czas oczekiwania zapytań
- stosunek sukcesów do niepowodzeń
Branie pod uwagę tego, co faktycznie robi twój system, jest niezbędne, by prawidłowo go dostosować.
Gdzie pasują kolejki takie jak BullMQ czy Kafka?
To uzasadnione pytanie.
- Kolejki wiadomości umożliwiają trwałe, asynchroniczne przetwarzanie
- p-map i Bottleneck zapewniają kontrolę w ramach jednej usługi
Zastosuj kolejkę wtedy, gdy:
- zadania muszą przetrwać awarię lub ponowny start
- obciążenie jest duże lub rozproszone między kilkoma usługami
Zastosuj podejście p-map i Bottleneck wtedy, gdy:
- potrzebujesz szybkiego, kontrolowanego wykonywania zadań w obrębie jednej usługi
Mierzalny wpływ po zastosowaniu tego rozwiązania
Po wprowadzeniu zarówno ograniczeń współbieżności, jak i limitów szybkości, jeden system produkcyjny odnotował:
- Spadek częstości błędów 429 z około 18% do poniżej 1%
- Zauważalnie lepszą stabilność podczas komunikacji z zewnętrznymi API
- Całkowite wyeliminowanie problemu prób ponownych
Logika aplikacji pozostała bez zmian. Zmieniła się natomiast dyscyplina stosowana podczas jej wykonywania.
Podsumowanie
Pisanie kodu asynchronicznego to podstawowa umiejętność dla programistów Node.js. Umiejętność kontrolowania szybkości wykonywania tego kodu odróżnia utalentowanych inżynierów backendowych.
Jeśli twoja usługa komunikuje się z:
- zewnętrznymi API
- bazami danych
- innymi mikrosługami
wtedy kontrola współbieżności przestaje być dodatkowym ulepszeniem i staje się kluczową częścią twojej architektury.
Zakończenie
Jeśli twój kod produkcyjny nadal polega na Promise.all bez żadnych ograniczeń, wystarczy jeden nagły wzrost ruchu, aby doszło do awarii.
Pракtyczny punkt wyjścia wygląda następująco:
- Zastosuj
p-map, aby ograniczyć jednoczesność wykonywania zadań - Zastosuj
Bottleneck, aby wprowadzić limity szybkości - Dodaj logikę ponawiania prób, aby zwiększyć odporność systemu
To jest droga od kodu, który po prostu działa, do kodu, który faktycznie potrafi się skalować.