Strona główna / Artykuły

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.

Wydawca: REACTAPP.TOP5 min czytania1240 słów

Ź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.all nie 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
  • Użycie CPU i pamięci pozostaje stabilne
  • 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
  • Ponawianie prób z opóźnieniem pozwala systemowi na poprawne odzyskanie funkcjonalności.
  • 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ć.