Znalezienie prawdziwego wąskiego gardła w wolnym punkcie końcowym Node.js
Naucz się systematycznego sposobu na śledzenie opóźnień w warstwie backendowej wzdłuż ścieżki żądania – od kodu Node.js po zapytania do bazy danych – przy użyciu pomiarów czasu i polecenia EXPLAIN ANALYZE.
Koniec punktu końcowego backendu działa powoli, a reakcja jest niemal zawsze taka sama:
"Node.js jest wolny."
Kiedyś to też było twoje standardowe wyjaśnienie.
Następnie, po poświęceniu czasu na badanie rzeczywistych problemów wydajności, pojawiła się inna lekcja:
Miejsce, w którym pojawia się wolność, nie jest automatycznie miejscem, które ją powoduje.
Serwis Node.js może działać dokładnie tak, jak zaplanowano, podczas gdy rzeczywiste opóźnienie pochodzi z bazy danych, API firmy trzeciej, sieci lub pojedynczego słabo zoptymalizowanego zapytania ukrytego gdzieś w ścieżce żądania.
Poniżej znajduje się metoda, którą warto zastosować za każdym razem, gdy koniec punktu końcowego backendu wydaje się nieuzasadnionie wolny.
1. Zacznij od rzeczywistego problemu
Wyobraź sobie taką ścieżkę:
GET /api/users?email=user@example.com
Odpowiedź jest poprawna.
Ale zazwyczaj zajmuje to około 2 do 3 sekund.
Natychmiastowa reakcja polega zwykle na takich pomysłach jak:
- Dostosowanie kodu Node.js
- Wprowadzenie warstwy cache’owania
- Zwiększenie mocy serwera
- Uruchomienie dodatkowych instancji
- Ponowne napisanie fragmentów kodu JavaScript
Żaden z tych rozwiązań nie opiera się jeszcze na dowodach — to jedynie spekulacje.
To, o co należy najpierw zapytać, to:
Gdzie właściwie znika czas?
2. Mierz przed wprowadzaniem jakichkolwiek zmian
Zamiast od razu przechodzić do modyfikacji kodu, najpierw zmierz czas trwania każdego poszczególnego kroku.
Naprzимер:
console.time("getUsers");
const users = await getUsers();console.timeEnd("getUsers");
Jeśli wyświetlony wynik to:
getUsers: 2720ms
ten jeden numer już podaje ci coś przydatnego.
Prawdopodobnie wąskim gardłem nie jest sama warstwa HTTP.
Dzieje się to gdzieś wewnątrz getUsers().
Czas zagłębić się o jeden poziom głębiej.
3. Pomiar zapytania do bazy danych
Załóżmy, że funkcja, która to obsługuje, wygląda tak:
console.time("db-query");
const users = await prisma.user.findMany({
where: {
email: email
}
});console.timeEnd("db-query");
A wynik pomiaru czasu wygląda następująco:
db-query: 2680ms
To znacznie zawęża zakres poszukiwań.
To nie Node.js zużywa 2,6 sekundy na obsługę żądania.
To jest wywołanie do bazy danych.
Dlatego właśnie natychmiastowe dostosowywanie na poziomie aplikacji może być stratą czasu, której nigdy nie odzyskasz.
4. Zapytaj teraz PostgreSQL, co robi
To właśnie ten moment, gdy należy użyć polecenia EXPLAIN ANALYZE.
Naprzимер:
EXPLAIN ANALYZE
SELECT *
FROM users
WHERE email = 'user@example.com';
Wynik może ujawnić coś w tym rodzaju:
Seq Scan on users
(actual time=0.025..2720.532 rows=1)
Kluczowym szczegółem, na który należy zwrócić uwagę, jest:
Seq Scan
PostgreSQL przegląda całą tabelę wiersz po wierszu, zamiast przechodzić bezpośrednio do odpowiedniej rekordu za pomocą indeksu.
Gdy tabela staje się wystarczająco duża, taki sekwencyjny przegląd staje się naprawdę kosztowny.
5. Rozwiązanie to nie „optymalizacja Node.js”
Jeśli wyszukiwania według email odbywają się często, dodanie odpowiedniego indeksu może znacząco obniżyć koszt zapytania.
Naprzимер:
CREATE INDEX idx_users_email
ON users(email);
Zainstaluj ponownie to samo zapytanie później:
EXPLAIN ANALYZE
SELECT *
FROM users
WHERE email = 'user@example.com';
Plan wykonywania powinien teraz pokazywać coś bliższego:
Index Scan using idx_users_email
zamiast:
Seq Scan
Dokładna wartość w milisekundach nie jest tu tak naprawdę istotna.
Ważne jest to, że strategia wykonywania została zmieniona na podstawie rzeczywistych pomiarów, a nie domysłów.
6. Większa lekcja
Wszystko to nie dotyczy w istocie PostgreSQL.
To lekcja na temat jak debugować w sposób systematyczny.
Gdy żądanie trwa zbyt długo, unikaj natychmiastowego obwiniania frameworka.
Wyobraź sobie pełną ścieżkę, którą przemierza żądanie:
Client
↓
HTTP
↓
Node.js
↓
Business Logic
↓
Redis / Database / External API
↓
Response
Każdy pojedynczy element w tej łańcuchu może być prawdziwym wąskim gardłem.
dokładnie którego.
7. Prosty proces debugowania
Zawsze, gdy punkt końcowy okazuje się wolny, należy zastosować mniej więcej tę sekwencję działań.
Krok 1 — Pomiary całego żądania
Request: 2.8
Krok 2 — Podział żądania na komponenty
Authentication: 20ms
Business logic: 50ms
Database: 2.6s
Response serialization: 15ms
Gdy już masz taką strukturę, znalezienie przyczyny staje się o wiele prostsze.
Krok 3 — Zbadanie najwolniejszego komponentu
Jeśli segment bazy danych pochłania 2,6 sekundy:
Nie marnuj godziny na dopracowywanie JavaScriptu.
Idź prosto do bazy danych.
Krok 4 — Sprawdzenie zapytania
Przyjrzyj się uważnie:
EXPLAIN ANALYZE
Sprawdź również:
- Skany sekwencyjne
- To, czy rzeczywiście używane są indeksy
- Łączenia
- Operacje sortowania
- Warunki filtrowania
- Ile wierszy jest skanowanych
- Ile wierszy faktycznie zostaje zwróconych
Krok 5 — Popraw jedną rzecz
Niektóre możliwe rozwiązania:
- Dodaj odpowiedni indeks
- Ponownie napisz zapytanie o nieefektywnej strukturze
- Usuń niepotrzebne łączenie
- Rozwiąż problem wzorca zapytania N+1
- Zmniejsz ilość pobieranych niepotrzebnych danych
Krok 6 — Pomiierz ponownie
Nigdy nie ufaj bezpodstawnie temu, że twoja poprawka zadziałała.
Potwierdź to nowym pomiarem.
8. Nie zapominaj o zewnętrznych API
Baza danych nie zawsze jest przyczyną opóźnienia.
Rozważmy następujący scenariusz:
const user = await getUser();
const payment = await getPaymentDetails();const orders = await getOrders();return {
user,
payment,
orders
};
Jeśli każde pojedyncze wezwanie zajmuje:
getUser() → 100ms
getPaymentDetails() → 900ms
getOrders() → 700ms
wtedy punkt końcowy działa znacznie wolniej, niż powinien.
A tutaj odpowiedzią nie jest „sprawić, by Node.js działał szybciej”.
Często chodzi po prostu o zmianę sposobu wykonywania niezależnych wezwań.
Naprzимер:
const [user, payment, orders] = await Promise.all([
getUser(),
getPaymentDetails(),
getOrders()
]);
W ten sposób operacje, które nie zależą od siebie, są wykonywane równocześnie, a nie po kolei.
Niemniej jednak istnieje tu ważna uwaga:
Nie używajPromise.all()bez wcześniejszego przemyślenia.
Gdy operacje zależą od siebie, wymagają ograniczeń dotyczących równoczesności lub mogą obciążyć usługę poniżej w łańcuchu, wykonywanie wszystkiego równolegle może w rzeczywistości pogorszyć sytuację zamiast ją poprawić.
Odpowiednia korekta wydajności zawsze zależy od konkretnego obciążenia, z którym się mierzysz.
9. Uważaj na zapytania typu N+1
Istnieje jeszcze jedna pułapka wydajnościowa, która na pierwszy rzut oka wydaje się nieszkodliwa.
Weźmy takie przykład:
const users = await getUsers();
for (const user of users) {
user.orders = await getOrders(user.id);
}
Jeśli masz do czynienia z 100 użytkownikami, ten wzorzec po cichu generuje:
1 query → get users
100 queries → get orders
To oznacza aż 101 oddzielnych zapytań do bazy danych potrzebnych do obsługi jednego wywołania API.
To jest dobrze znany problem zapytań typu N+1.
w zależności od twojej sytuacji lepsze strategie mogą obejmować:
- Bezpośrednie łączenie tabel
- Wykorzystanie funkcji ładowania relacji w ORM
- Ładowanie rekordów partiami zamiast pojedynczo
- Użycie klauzuli
WHERE IN - Ponowne przemyślenie struktury odpowiedzi
Jak zawsze, wybór odpowiedniego podejścia zależy w całości od obciążenia pracy.
10. Nie zwiększaj zbyt wcześnie rozmiaru serwera
Częstą, instynktowną reakcją na wolną API jest:
"Dodajmy więcej procesorów i pamięci RAM.
To może pomóc w niektórych przypadkach.
Jednak często po prostu wydajemy więcej pieniędzy, nie rozwiązując rzeczywistego problemu.
Jeśli zapytanie do bazy danych jest źle napisane, wzmocnienie serwera Node.js nie sprawi, że to zapytanie będzie wykonywane szybciej.
Zanim zwiększysz moc sprzętową, zadaj sobie pytanie:
Czy aplikacja rzeczywiście jest ograniczona przez procesor lub pamięć?
Jeśli nie, dodanie mocy serwera prawdopodobnie nie rozwiąże rzeczywistego wąskiego gardła.
11. Przyzwyczajenia do debugowania, których staram się przestrzegać
Pożyteczny model myślowy do radzenia sobie z wolnością backendu wygląda następująco:
Is the API slow?
↓
Measure it
↓
Which layer is slow?
↓
Measure that layer
↓
Find the actual bottleneck
↓
Make one change
↓
Measure again
Zamiast:
API slow
↓
Optimize Node.js
↓
Add Redis
↓
Increase server
↓
Hope it gets faster
Druga ścieżka to domysły.
Pierwsza ścieżka to prawdziwa inżynieria.
12. Lista kontrolna wydajności mojego backendu
Zanim przystąpisz do jakiejkolwiek optymalizacji, warto potwierdzić:
- Jaki jest rzeczywisty czas odpowiedzi od początku do końca?
- Czy obciążenie jest ograniczone przez CPU?
- Czy sama zapytanie do bazy danych jest wolne?
- Czy gdzieś nie kryje się wzorzec N+1?
- Czy istnieją odpowiednie indeksy?
- Co ujawnia polecenie
EXPLAIN ANALYZE? - Czy API od strony trzeciej dodaje opóźnienie?
- Czy zadania niezależne są wykonywane sekwencyjnie, mimo że nie muszą?
- Czy kod pobiera więcej danych, niż faktycznie potrzebuje?
- Czy w odpowiednich miejscach wykorzystywany jest Redis?
- Czy zbiór połączeń jest poprawnie skonfigurowany?
- Czy wprowadzona zmiana rzeczywiście wpłynęła na zmierzone wartości?
Ostatnia myśl
Jedną z najcenniejszych lekcji w pracy nad backendem jest następująca:
Rzadko udaje się rozwiązać problemy wydajnościowe poprzez domysły.
Słaba wydajność API Node.js nie oznacza automatycznie, że winny jest sam Node.js.
Prawdziwym winowajcą może być:
PostgreSQL
Redis
External APIs
Network
N+1 queries
Poor indexes
Serialization
Connection pools
Application logic
Znajomość sposobu dostosowywania poszczególnych technologii nie jest kluczową umiejętnością.
Prawdziwą umiejętnością jest potrafienie określić dokładnie, gdzie znajduje się wąskie gardło.
Gdy już wiemy dokładnie, gdzie znika czas, rozwiązanie zazwyczaj przychodzi samo.
Najpierw mierz. Znajdź wąskie gardło. Popraw je. Zmierz ponownie.
To właśnie taka metoda kieruje obecnie rozwiązywaniem problemów z wydajnością backendu.
Literatura pokrewna
- Budowanie rozwiązań do obsługi błędów w prozdrowotnych aplikacjach Node.js — Dowiedz się, jak klasyfikować błędy w Node.js, projektować spersonalizowaną hierarchię błędów, centralizować obsługę błędów asynchronicznych oraz zabezpieczać ślady wywołań dla odporności aplikacji w produkcji.
- 20 wzorów Node.js, które zapobiegają przerwom w serwerach produkcyjnych — Poznaj 20 praktycznych wzoró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 staną się konieczne ponowne uruchomienia.