Node.js kontra Go dla API JSON: taka sama przepustowość, 2,6 raza mniej pamięci
Test obciążeniowy przeprowadzony równolegle na identycznych usługach HTTP w Node.js i Go pokazuje, w których przypadkach przepisanie kodu przynosi korzyści (pamięć operacyjna) a w których nie (przepustowość i opóźnienie).
"Przepisz to na Go" to propozycja, którą większość zespołów Node.js słyszy prędzej czy później, i zazwyczaj przychodzi ona wraz z twierdzeniami o szybkości, nasyceniu pętli wydarzeń oraz niższych rachunkach chmurowych, ale bez żadnych pomiarów. Sposobem na rozstrzygnięcie tej kwestii jest stworzenie tego samego małego serwisu w obu językach, załadowanie go w identyczny sposób i sprawdzenie, jakie różnice przetrwają po wielokrotnych uruchomieniach. Ten artykuł opisuje takie doświadczenie, co faktycznie pokazało, które z uzyskanych wyników były artefaktami oraz jak przekształcić te dane w rozsądną decyzję dotyczącą migracji własnych usług.
Czas postawienia tego pytania nie jest przypadkowy. Przejście zespołu TypeScript na kompilator natywny oparty na Go sprawiło, że „przeniesienie go na Go” wydaje się standardową odpowiedzią na problemy z wydajnością (zobacz co oznacza przeredagowanie w TypeScript 7 na Go w praktyce). Kompilator jest jednak programem przetwarzającym dane partiami, ograniczonym przez możliwości procesora; API internetowe ma zupełnie inny profil, i właśnie dlatego wymaga własnych pomiarów.
Celowo nudna usługa
Przedmiotem testów jest minimalne API w formacie JSON, działające z pamięcią lokalną przechowującą użytkowników, wypełnioną 10 000 rekordami. Udostępnia ono dwa ścieżki:
GET /users/:idwyszukuje użytkownika i zwraca go lub błąd JSON 404.POST /usersanalizuje treść w formacie JSON, sprawdza ją i dodaje nowego użytkownika.
Nie ma bazy danych ani frameworka. Obie te decyzje są celowe: podróż do bazy danych i powrót zajęłyby zbyt dużo czasu, maskując wszelkie różnice w wydajności, a framework dodałby własny obciążenie niewiążące się z językiem. Trasy, reguły walidacji oraz dane początkowe są identyczne w obu implementacjach.
Środowisko i jego wrodzona asymetria
Wszystko działało na pojedynczym laptopie z procesorem Apple Silicon o 16 rdzeniach, z systemem macOS 26.6, Node v22.15.0 i Go 1.24.4. Node działał jako jeden proces, bez modułu cluster ani worker_threads, więc obsługa żądań wykorzystywała jeden rdzeń. Moduł net/http w Go działał przy użyciu domyślnego wartości GOMAXPROCS, co umożliwia planerowi rozproszenie gorutin na wszystkich rdzeniach.
Miej tę asymetrię na uwadze przez resztę artykułu. To najważniejsza uwaga w całym porównaniu, a generator obciążeń konkurował z oboma serwerami o te same 16 rdzeni.
Dwie implementacje
Wersja Node wykorzystuje wyłącznie wbudowany moduł http. Należy zauważyć, że routowanie odbywa się poprzez ręczną weryfikację wartości req.method oraz funkcji startsWith w adreście URL, magazynem jest zwykły obiekt Map, a każda odpowiedź jest serializowana za pomocą JSON.stringify i wyraźnie zamykana. Scenariusz POST jest opisany w komentarzu; stosuje te same sprawdzenia co kod napisany w Go poniżej.
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url.startsWith("/users/")) {
const id = req.url.split("/")[2];
const user = users.get(id);
if (!user) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(user));
return;
}
// POST /users: parse body, validate name + email, insert. Same checks as Go below.
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
Wersja w Go rejestruje obsługę na obiekcie ServeMux i skraca prefiks ścieżki, aby uzyskać identyfikator. Bazę danych stanowi mapa chroniona mutexem, co jest konieczne, ponieważ Go obsługuje żądania równocześnie na kilku gorutynach, podczas gdy pojedynczy pętla zdarzeń w Node’u nigdy nie dotyka Map z dwóch miejsc jednocześnie. Odpowiedzi są tworzone za pomocą json.NewEncoder, który bezpośrednio wysyła dane do ResponseWriter.
mux.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/users/")
user, ok := store.get(id)
w.Header().Set("Content-Type", "application/json")
if !ok {
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(user)
})
Walidacja na trasie POST jest taka sama w obu językach: name musi być niepustą ciągłą literami, a email musi zawierać znak @. Oto funkcja w Go; odpowiednik w Node’u wykorzystuje typeof oraz String.prototype.includes do tych samych dwóch sprawdzeń, więc żadna z wersji nie korzysta z prostszego sposobu implementacji.
func validate(u User) string {
if u.Name == "" {
return "name required"
}
if !strings.Contains(u.Email, "@") {
return "invalid email"
}
return ""
}
Sposób nałożenia obciążenia
Obciążenie pochodziło z autocannon, przy serii testów trwających 15 sekund na dwóch poziomach jednoczesności: 50 połączeń dla umiarkowanego obciążenia oraz 300 połączeń w celu odtworzenia sytuacji intensywnego ruchu.
autocannon -c 50 -d 15 http://localhost:PORT/users/500
autocannon -c 300 -d 15 http://localhost:PORT/users/500
Ścieżka POST została przetestowana w ten sam sposób z ciałem danych w formacie JSON, ponieważ dekodowanie i walidacja danych wejściowych są bliższe rzeczywistej pracy z żądaniami niż prosty wyszukiwanie w tablicy.
Pamięć była mierzona oddzielnie. Rozmiar używanej pamięci przez każdy serwer (ps -o rss) był sprawdzany w stanie spoczynku oraz natychmiast po serii 300 połączeń, przy czym każde pomiary przeprowadzano z nowo uruchomionego procesu, aby pozostałości po poprzednim teście nie wpłynęły na wyniki. Kluczowe jest to, że narzędzie do pomiaru pamięci nie było uruchamiane podczas testów opóźnień; jak pokazują kolejne sekcje, ten detal zmienił wyniki.
Zanim przyjrzysz się wynikom, potraktuj ten scenariusz poważnie: mowa jest o jednym laptopie, a nie o izolowanym zestawie do testów wydajności, przy czym generator obciążeń dzieli procesor z serwerami. Podane liczby odnoszą się konkretnie do tego obciążenia i nie stanowią ogólnego werdyktu na temat Node w porównaniu z Go. Podczas przeprowadzania takich porównań zapisz kod serwera, skrypt do testów wydajności oraz surowe wyniki (tutaj plik benchmark.json) obok swoich wniosków, oraz odnotuj, które liczby przyjąłeś, a które odrzuciłeś.
Szybkość przetwarzania: brak zwycięzcy
W żadnym z testów Go nie ustanowiło znaczącej przewagi pod względem liczby zapytań. Przy 50 połączeniach na trasie GET to Node okazało się lepsze, o około 5 000 zapytań na sekundę. Na trasie POST przy 300 połączeniach oba rozwiązania różniły się zaledwie kilkuset zapytaniami na sekundę. Powszechnie twierdzone, że Node „zadycha pod obciążeniem”, nie potwierdziło się.
To wyjaśnienie częściowo tłumaczy ten fenomen. Dzięki generatorowi obciążeń oraz temu, że oba serwery dzielą się 16 rdzeniami przez loopback, żaden proces nie cierpiał z powodu braku zasobów CPU, co zmniejsza różnice w czasie działania, które mogłyby wystąpić na przeciążonym serwerze produkcyjnym. Na małej, dedykowanej instancji ta różnica prawdopodobnie by się zwiększyła. To jest rzeczywisty limit testowania na laptopach i powinien być ujawniony, a nie ukryty.
Latencja: również remis po usunięciu zakłóceń
To jest lekcja, która odnosi się do każdego testu wydajności na maszynie współdzielonej. Jedna wartość z najgorszego przypadku to najbardziej niestabilny wynik, jaki można uzyskać, ponieważ powstaje ona wskutek błędu planeru lub procesu w tle. Zanim zaufasz temu przerażającemu maksymalnemu wynikowi, uruchom test ponownie na maszynie bez obciążeń, sprawdź wartość p99 i upewnij się, czy wynik się powtarza.
Pamięć: jedyna różnica, która przetrwała
Pamięć dostępna w czasie rzeczywistym była jedyną różnicą, która była zarówno duża, jak i powtarzalna:
- Bez obciążeń: Go – 13 MB, Node – 49 MB.
- Bezpośrednio po ciągłym obciążeniu 300 połączeniami: Go – 32 MB, Node – 84 MB.
Pomiar powtórzono trzy razy, za każdym razem z nowego procesu, uzyskując identyczne wyniki. Przy tym obciążeniu Node zużywa mniej więcej 2,6 raza więcej pamięci do wykonywania funkcjonalnie identycznych zadań.
Wyjaśnienie ma przede wszystkim charakter strukturalny. Proces Node zawiera silnik V8, jego kompilator JIT oraz pamięć typu heap zarządzaną metodą garbage collection, dostosowaną do języka dynamicznego, natomiast binarnik Go jest kompilowany z góry z lżejszym środowiskiem wykonawczym. Jeśli płacisz za ograniczenia pamięci w kontenerze, różnica ta wzrasta we wszystkich replikach: punkt końcowy rozwiązany w wielu podach płaci za standardowe wymagania Node we wszystkich z nich.
Czego ten eksperyment nie pokazuje
Lekko jest interpretować to jako „Go przewyższa Node”, ale dane tego nie potwierdzają. Przepustowość i opóźnienie są ze sobą powiązane, a w testach z niską konkurencją zwyciężył Node. Jeśli twoim ograniczeniem są żądania na sekundę lub opóźnienie na końcu kolejki na serwerze z wolnymi rdzeniami, ten test pokazuje, że przepisanie kodu niewiele ci da.
Nie mówi też nic o pracy związanej z bazą danych. Większość usług produkcyjnych spędza większość czasu oczekiwania na zapytania, a nie na serializacji JSON, i ten czas jest taki sam w obu językach. Jeśli twoja usługa polega na operacjach wejścia/wyjścia z Postgresem, te wartości są dla ciebie w dużej mierze bez znaczenia.
Ostatecznie utrzymuje się podstawowa asymetria: Go używało każdego rdzenia domyślnie, natomiast Node z jednym procesem używało tylko jednego. Część tego, co wydaje się zaletą Go, to w rzeczywistości fakt, że „Go automatycznie paralelizuje zadania”. Rozsądnym pierwszym krokiem jest użycie własnego modułu cluster w Node, który zapewnia po jednym pracowniku na każdy rdzeń, dzięki czemu Node korzysta z tego samego sprzętu, który Go otrzymywało bezpłatnie. Nie zostało to tutaj przetestowane, a rozwiązanie to jest znacznie mniej inwazyjne niż wprowadzenie drugiego języka. Należy pamiętać, że każdy pracownik w klastrze to odrębny proces z własnym buforem pamięci, więc klastrowanie może poprawić wykorzystanie CPU, ale jednocześnie zwiększa ogólną ilość pamięci potrzebną, a nie zmniejsza ją.
Zmiana liczb na decyzję
Biorąc pod uwagę te wyniki, całkowite przepisanie nie spełnia wymogów. Skuteczniejszym rozwiązaniem jest bardziej celowe działanie: przenieść tylko ten wymagający dużo pamięci endpoint, który działa na wielu replikach, gdzie zmniejszenie ilości pamięci używanej staje się wyraźnym problemem, a wszystko inne pozostawić w Node.
To ograniczenie jest istotne, ponieważ przenoszenie kodu nigdy nie jest bezkosztowe. Drugi język oznacza zupełnie inny zestaw narzędzi, inną ścieżkę wdrażania, dodatkowe instrukcje dla inżynierów pełniących dyżury oraz rozdzielenie umiejętności w zespole. Te koszty są uzasadnione tam, gdzie ograniczeniem jest ilość pamięci, a nie tam, gdzie tak nie jest.
Gdy pojawi się kolejna propozycja przeniesienia z Node na Go, możesz ją ocenić w ten sam sposób:
- Zbuduj obie wersje danego usługi lub endpointu.
- Załaduj je przy takim poziomie jednoczesnego używania zasobów, jaki osiąga twój rzeczywisty ruch, włączając ścieżkę zapisu.
Główne wnioski
- Dla prostego API JSON w pamięci, Node i Go osiągnęły na tym sprzęcie praktycznie takie same wydajność i opóźnienie.
- Najwyraźniejszą, powtarzalną przewagą Go było około 2,6 raza mniejsze zużycie pamięci pod obciążeniem, co ma największe znaczenie dla usług szeroko replikowanych.
- Domyślne planowanie wielordzeniowe w Go w porównaniu z jednoprocesowym Node stanowi czynnik zakłócający; spróbuj najpierw klastrowania, zanim będziesz coś przepisywać.
- Artefakty testów wydajności, szczególnie pojedyncze maksymalne skoki opóźnienia, są częste na komputerach współdzielonych; spróbuj je odtworzyć, zanim wyciągniesz wnioski.
- Migruj selektywnie tam, gdzie osiągnięte korzyści są rzeczywiste i przewyższają koszt opanowania drugiego języka.
Literatura pokrewna
- Node.js Streams Explained: Jak naprawić awarie z powodu braku pamięci przy obsłudze plików — Dowiedz się, dlaczego ładowanie całych plików do pamięci powoduje awarie serwerów Node.js oraz w jaki sposób strumienie czytelne, zapisywalne, dwukierunkowe i transformujące rozwiązują ten problem dzięki mechanizmowi backpressure.
- One Weather CLI, pięć narzędzi: Rust, Go, Zig, Bun i Node.js — Jak ten sam mały interfejs CLI oparty na HTTP i JSON wygląda w językach Rust, Go, Zig, Bun i Node.js oraz co oznaczają rozmiar pliku binarnego, czas kompilacji i trudności przy konfiguracji dla Twojego wyboru.
- Czekanie vs Obliczania: Konkurencja i Równoległość w Node.js i Go — Przykłady działające w Node.js i Go, które rozróżniają konkurencję od równoległości, pokazujące, kiedy wystarczają obietnice (promises), kiedy potrzebne są wątki robocze, oraz jak różnią się goroutiny.