Strona główna / Artykuły / Wydajność API Node.js: Ramy optymalizacji oparte na kolejności priorytetów

Wydajność API Node.js: Ramy optymalizacji oparte na kolejności priorytetów

Dowiedz się, jak sklasyfikować naprawy wydajności API Node.js według poziomu nakładu pracy i wpływu, aby najpierw naprawić problemy z poolowaniem połączeń oraz zapytaniami typu N+1, zanim będziesz dążyć do ekskluzywnych optymalizacji.

1300 słów

Większość artykułów o przyspieszeniu API Node.js przedstawia prosty wykaz piętnastu wskazówek, w którym obok zarządzania połączeniami z bazą danych znajduje się na przykład szybszy serializator JSON, jakby każdy punkt zasługiwał na taką samą uwagę. To mylące. Niektóre rozwiązania wymagają zaledwie dziesięciu minut i znacznie skracają czas odpowiedzi. Inne natomiast wymagają miesięcy pracy, by przynieść znikomy efekt. Zrozumienie różnic jest o wiele ważniejsze niż zapamiętanie każdego punktu z listy.

Poziom 1: Zrób to najpierw (duży wpływ, mało wysiłku)

Te zmiany nie kosztują prawie nic w implementacji. Wymagają niewiele kodu, niosą małe ryzyko i w praktyce znacznie częściej są rzeczywistą przyczyną wolności działania niż egzotyczne rozwiązania, do których się ucieka.

Włącz funkcję keep-alive dla wszystkich wyjściowych żądań HTTP. Domyślnie klient HTTP w Node otwiera nowe połączenie przy każdym wywołaniu, więc każde żądanie do zewnętrznego serwisu ponownie generuje pełny koszt procedury handshake TCP oraz negocjacji TLS. Ponowne użycie jednego połączenia w różnych żądaniach eliminuje ten dodatkowy obciążenie przy każdym kolejnym wezwaniu do tego samego hosta.

const agent = new https.Agent({ keepAlive: true, maxSockets: 50 });

Zawsze komunikuj się z bazą danych przez pulę połączeń, a nie przez pojedyncze połączenie. Przy rzeczywistym obciążeniu równoczesnym pojedyncze połączenie faktycznie staje się kolejką, za którą musi czekać cała transakcja. Pula połączeń o odpowiedniej wielkości umożliwia twojej API obsługę ruchu równoczesnego w sposób zgodny z jej rzeczywistą strukturą, czyli równolegle.

Pozwól niezależnym, asynchronicznym wywołaniom działać równolegle, a nie jeden po drugim. Gdy dwa instrukcje await nie polegają na wynikach drugiej z nich, nie ma powodu, by zmuszać je do oczekiwania w kolejce.

// costs the sum of both calls
const user = await getUser(id);
const orders = await getOrders(id);

// costs roughly the slower of the two
const [user, orders] = await Promise.all([getUser(id), getOrders(id)]);

Dodaj indeksy do kolumn, które faktycznie są filtrowane, łączone lub sortowane w zapytaniach. Spośród wszystkich punktów na tej liście jest to bez wątpienia najskuteczniejsze rozwiązanie dla każdego punktu końcowego odczytującego dane z tabeli, która stale się powiększa, a dodanie tego rozwiązania często zajmuje zaledwie jedną linię kodu.

Wyeliminuj wzorce zapytań typu N+1. Pobieranie listy, a następnie wysyłanie kolejnego zapytania dla każdego elementu w pętli wydaje się nieszkodliwe przy niewielkiej liczbie rekordów testowych, ale staje się poważnym problemem, gdy mamy do czynienia z tysiącami rzeczywistych rekordów. Zastąp pętlę jednym zapytaniem zbiorczym do pobrania powiązanych danych.

Każde zapytanie, które w przeciwnym razie mogłoby zwrócić nieograniczoną liczbę wyników, należy ograniczyć za pomocą LIMIT. Koniec punktu dostępu, który zwraca „wszystkie zamówienia, które ten klient kiedykolwiek złożył”, bez żadnego ograniczenia, działa dobrze dla zupełnie nowego konta, ale zawodzi w przypadku kont z wieloletnią historią zamówień.

Poziom 2: Wartość prawdziwej inwestycji (duży wpływ, duży wysiłek)

Rozwiązania z tego poziomu to nie drobne modyfikacje. Wymagają rzeczywistej pracy nad projektem, a często także nowej infrastruktury, ale rozwiązują kategorie problemów, których nie mogą obejść żadne rozwiązania z poziomu 1.

Zastosuj warstwę cacheowania dla operacji odczytu, które są kosztowne i występują często. Umieszczenie Redis przed wolnym zapytaniem agregującym lub drogim wywołaniem API firmy trzeciej może skrócić czas odpowiedzi z 200 ms do około 2 ms. Trudniejsze nie jest utworzenie cache’a, lecz zaprojektowanie strategii unieważniania danych w taki sposób, aby cache nigdy nie udostępniał przestarzałych wyników.

Całkowicie wyeliminuj powolne, niekrytyczne zadania z cyklu żądanie-odpowiedź. Wysyłanie e-maila z potwierdzeniem, tworzenie raportów, aktualizacja danych analitycznych – nic z tego nie musi zostać ukończone, zanim odpowiesz klientowi. Połączenie kolejki, takiej jak BullMQ lub SQS, z dedykowanym procesem roboczym może skrócić czas obsługi żądania, które wcześniej trwało 2 sekundy, do około 80 milisekund.

Przejdź od paginacji opartej na offsetach do paginacji opartej na kursorze w przypadku dużych lub złożonych zbiorów wyników. Paginacja oparta na OFFSET staje się coraz wolniejsza w miarę przechodzenia do głębszych stron, ponieważ baza danych musi nadal skanować wszystkie wcześniejsze wiersze. Podejście oparte na kursorze wymaga mniej zasobów niezależnie od tego, czy znajdujesz się na stronie 5, czy na stronie 5000.

Rozszerzaj rozwiązanie horyzontalnie za pomocą prawdziwego balansera obciążenia oraz centralnego przechowywania sesji lub stanu pamięci podręcznej. Bez względu na to, jak dobrze dostosujesz system, pojedynczy proces Node w końcu napotka swoje ograniczenia. Uruchamianie kilku instancji za balanserem obciążenia, z których każda korzysta ze wspólnej pamięci Redis i puli połączeń, podnosi te ograniczenia, bez konieczności przyspieszania poszczególnych procesów indywidualnie.

Profil przed dalszą optymalizacją. Gdy już naprawione zostaną oczywiste problemy, domyślanie się tego, co jest powolne, przestaje być wiarygodną strategią. Użycie rzeczywistego narzędzia do profilowania lub narzędzia APM, takiego jak clinic.js, produktu APM w chmurze, czy nawet wywołanie polecenia EXPLAIN ANALYZE na podejrzanej zapytaniu, pokazuje, gdzie faktycznie traci się czas, a nie tam, gdzie zakłada się to na podstawie domysłów.

Poziom 3: Zazwyczaj nie warto przywiązywać do tego wagi (niski wpływ, często przeceniane)

Rozwiązania te pojawiają się ciągle w dyskusjach na temat wydajności, jednak rzadko wpływają znacząco na prawdziwy API produkcyjny, głównie dlatego, że dotyczą części stacku, które od początku nie stanowiły rzeczywistego wąskiego gardła.

Dopracowanie serializacji JSON. Istnieją szybsze biblioteki do obsługi JSON, które rzeczywiście pomagają, ale tylko w skali, do której zdecydowana większość API nigdy nie dochodzi. Jeśli twoim prawdziwym problemem jest zapytanie do bazy danych trwające 300 ms, redukcja czasu serializacji o kilka milisekund rozwiązuje niewłaściwy problem.

Zmiana frameworków wyłącznie po to, by zmniejszyć ich obciążenie. Różnica wydajności między Express a rzekomo szybszą alternatywą istnieje, ale jest niewielka w porównaniu z kosztami wynikającymi z użycia tabely bez indeksów lub zapytań typu N+1. Wybór frameworka może mieć znaczenie z wielu innych powodów; surowa szybkość zazwyczaj nie należy do nich.

Najpierw próba zastosowania techniki klastrowania, zanim zajmie się się czymkolwiek innym. Uruchamianie wielu procesów Node w celu wykorzystania dodatkowych rdzeni CPU rzeczywiście pomaga w zadaniach obciążonych pracą procesora. Nie pomoże to jednak endpointowi, który działa wolno z powodu oczekiwania na zapytanie bez indeksu, ponieważ oczekiwanie pozostaje oczekiwaniem, bez względu na to, ile procesów stoi w bezczynności.

Ponowne napisanie kluczowych ścieżek kodu w języku o niższym poziomie abstrakcji wyłącznie dla poprawy wydajności. Istnieją uzasadnione przypadki takiego podejścia, gdy konkretny, udowodniony problem związany z pracą procesora wymusza takie działanie. Zazwyczaj jednak próbuje się tego zrobić, zanim ktokolwiek faktycznie potwierdzi, że to właśnie tam traci się czas, co zamienia skuteczną technikę w zmarnowany wysiłek skierowany na niewłaściwy cel.

Jak faktycznie to wykorzystać

Zanim rozważysz cokolwiek innego, upewnij się, że załatwisz wszystkie problemy z poziomu 1 – są one tanie, mało ryzykowne i rozwiązują większość problemów wydajności w praktyce. Przejdź do poziomu 2 tylko wtedy, gdy analiza pokazuje, że rozwiązania z poziomu 1 nie wystarczą w konkretnych punktach, a nie traktuj tego jako ogólnej procedury stosowanej we wszystkich przypadkach. Nie dotykaj poziomu 3, dopóki nie masz konkretnych dowodów, pochodzących z narzędzia do analizy wydajności, a nie tylko przypuszczeń, że jedna z tych technik faktycznie stanowi wąskie gardło. Większość API o wolnej wydajności jest powolna z powodu nierozwiązanych problemów z poziomu 1, a nie z powodu braku jakiejś mało znanej optymalizacji zaczerpniętej z jakiegoś wpisu na blogu.

Literatura pokrewna

  • Edge Isolates i Wasm kontra Node.js: Kompromisy w czasie wykonywania i praktyki produkcyjne — Omawia, w jaki sposób izolaty V8 oraz WebAssembly przewyższają Node.js oparty na kontenerach w środowisku Edge, a następnie przedstawia praktyki operacyjne sprawiające, że Node.js nadaje się do użycia w produkcji.
  • Jak process.nextTick() cicho zahamowuje pętlę zdarzeń Node.js — Wyjaśnia, dlaczego rekurencyjne wywołania process.nextTick() całkowicie blokują fazę poll w libuv oraz jak naprawić problem zahamowania pętli zdarzeń za pomocą setImmediate().
  • 20 wzorców Node.js, które zapobiegają przerwom w serwerze produkcyjnym — Poznaj 20 praktycznych wzorców Node.js – od obsługi błędów po płynne wyłączanie i zarządzanie zasobami połączeń – które zapobiegają awariom, zanim konieczne stanie się ponowne uruchomienie.