Node.js gegen Go für eine JSON-API: Derselbe Durchsatz, 2,6-mal weniger Speicher
Ein side-by-side-Lasttest identischer Node.js- und Go-HTTP-Dienste zeigt, wo eine Umschreibung Vorteile bringt (Resident-Memory-Verbrauch) und wo nicht (Durchsatz sowie Latenz).
„Schreiben Sie es in Go um“ ist ein Vorschlag, den die meisten Node.js-Teams früher oder später hören – meist begleitet von Behauptungen bezüglich Geschwindigkeit, Saturation des Event-Loops und geringerer Cloud-Kosten, ohne jedoch konkrete Messungen. Die Lösung besteht darin, denselben kleinen Dienst in beiden Sprachen zu entwickeln, ihn identisch zu laden und herauszufinden, welche Unterschiede bei wiederholten Ausführungen bestehen bleiben. Dieser Artikel führt durch ein solches Experiment, zeigt auf, was es tatsächlich ergeben hat, welche der Werte künstlich waren, und erklärt, wie man die Ergebnisse in eine fundierte Entscheidung für eine Migration der eigenen Dienste umsetzen kann.
Der Zeitpunkt der Frage ist kein Zufall. Der Wechsel des TypeScript-Teams zu einem auf Go basierenden nativen Compiler hat dazu geführt, dass „es in Go portieren“ wie die Standardlösung für Leistungsprobleme erscheint (siehe was die Umwandlung in Go bei TypeScript 7 in der Praxis bedeutet). Ein Compiler ist jedoch ein CPU-gebundenes Batch-Programm; eine Web-API weist ein völlig anderes Profil auf, und genau deshalb benötigt sie eigene Messungen.
Ein absichtlich langweiliger Service
Das Testobjekt ist eine minimale JSON-API über einen im Speicher befindlichen Benutzerdatenspeicher, der mit 10.000 Datensätzen befüllt ist. Sie stellt zwei Endpunkte zur Verfügung:
GET /users/:idsucht nach einem Benutzer und gibt ihn zurück – oder einen 404-JSON-Fehler.POST /usersanalysiert den JSON-Body, prüft ihn und fügt einen neuen Benutzer hinzu.
Es gibt weder eine Datenbank noch ein Framework. Beide Entscheidungen sind beabsichtigt: Ein Hin- und Rückweg zur Datenbank würde die Laufzeiten dominieren und alle Unterschiede im Betriebsverhalten verbergen, während ein Framework seinen eigenen Overhead hinzufügen würde, der nichts mit der Programmiersprache zu tun hat. Pfade, Validierungsregeln und Startdaten sind in beiden Implementierungen identisch.
Die Umgebung und ihre eingebaute Asymmetrie
Alles wurde auf einem einzigen 16-Kern-Apple-Silicon-Laptop mit macOS 26.6, Node v22.15.0 und Go 1.24.4 ausgeführt. Node lief als ein einziger Prozess ohne das cluster-Modul oder worker_threads, wodurch die Anfragenverarbeitung einen einzigen Kern nutzte. Go’s net/http wurde mit dem Standardwert von GOMAXPROCS ausgeführt, wodurch der Scheduler Goroutinen auf alle Kerne verteilen kann.
Behalten Sie diese Asymmetrie für den Rest des Artikels im Hinterkopf. Es handelt sich um die wichtigste Einschränkung in der gesamten Vergleichsstudie, und der Lastgenerator konkurrierte mit beiden Servern um dieselben 16 Kerne.
Die zwei Implementierungen
Die Node-Version verwendet ausschließlich das eingebaute http-Modul. Beachten Sie, dass die Routing-Logik auf einer manuellen Überprüfung von req.method sowie einem startsWith-Prüfverfahren am URL basiert, der Speicher ist ein einfacher Map, und jede Antwort wird mit JSON.stringify serialisiert und explizit abgeschlossen. Der POST-Fall wird in einem Kommentar zusammengefasst; er verwendet dieselben Überprüfungen wie der Go-Code weiter unten.
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" }));
});
In der Go-Version wird ein Handler bei einem ServeMux registriert und das Pfadpräfix entfernt, um die ID zu erhalten. Der Speicher ist ein von einem Mutex geschütztes Map, was notwendig ist, da Go Anfragen gleichzeitig über mehrere Goroutinen verarbeitet, während der eindimensionale Event-Loop von Node niemals gleichzeitig auf das Map zugreift. Die Antworten werden mit json.NewEncoder geschrieben, der den Inhalt direkt in den ResponseWriter streamt.
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)
})
Die Validierung auf der POST-Route ist in beiden Sprachen identisch: name muss eine nicht leere Zeichenkette sein und email muss ein @ enthalten. Hier ist die Go-Funktion; die Node-Äquivalente verwendet für diese beiden Überprüfungen typeof sowie String.prototype.includes, sodass keiner der Ansätze einen günstigeren Codeweg bietet.
func validate(u User) string {
if u.Name == "" {
return "name required"
}
if !strings.Contains(u.Email, "@") {
return "invalid email"
}
return ""
}
Wie die Last angewendet wurde
Die Last stammte von autocannon und wurde in 15-Sekunden-Intervallen bei zwei Konkurrenzniveaus erzeugt: 50 Verbindungen für eine mäßige Last sowie 300 Verbindungen, um einen stark belasteten Endpunkt nachzubilden.
autocannon -c 50 -d 15 http://localhost:PORT/users/500
autocannon -c 300 -d 15 http://localhost:PORT/users/500
Der POST-Endpunkt wurde auf dieselbe Weise mit einem JSON-Körper getestet, da die Dekodierung und Validierung der Eingaben dem tatsächlichen Anfragenverarbeitungsprozess näher kommen als ein einfacher Schlüsselabruf in einem Map-Struktur.
Der Speicherverbrauch wurde separat gemessen. Die Größe des im Arbeitsspeicher des jeweiligen Servers verfügbaren Speichers (ps -o rss) wurde in Ruhezustand sowie unmittelbar nach einem Test mit 300 Verbindungen erfasst, wobei jede Messung von einem frisch gestarteten Prozess aus durchgeführt wurde, damit Reste eines vorherigen Tests die Ergebnisse nicht verfälschen konnten. Entscheidend ist, dass während der Latenztests der Speichermesswerkzeug nicht auf dem Rechner aktiviert war; wie die folgenden Abschnitte zeigen, änderte dieser Umstand die Ergebnisse.
Bevor Sie sich den Ergebnissen zuwenden, nehmen Sie den Kontext ernst: Es handelt sich um einen einzigen Laptop, nicht um ein isoliertes Benchmarking-System, und der Lastgenerator teilt sich die CPU mit den Servern. Die Zahlen beziehen sich auf diese spezifische Arbeitslast und stellen kein allgemeines Urteil über Node im Vergleich zu Go dar. Wenn Sie einen solchen Vergleich durchführen, speichern Sie den Servercode, das Benchmark-Skript sowie die Rohdaten (hier eine benchmark.json-Datei) neben Ihren Schlussfolgerungen und notieren Sie, welche Zahlen übernommen wurden und welche verworfen wurden.
Durchsatz: kein Sieger
In keiner der Tests zeigte Go einen signifikanten Vorsprung hinsichtlich der Anfragenrate. Bei 50 Verbindungen auf dem GET-Path lag Node etwa 5.000 Anfragen pro Sekunde vorn. Auf dem POST-Path bei 300 Verbindungen lagen beide Systeme nur wenige hundert Anfragen pro Sekunde auseinander. Die gängige Behauptung, Node würde „unter Last zusammenbrechen“, bestätigte sich nicht.
Die Einrichtung erklärt einen Teil davon. Da der Lastgenerator sowie beide Server über Loopback 16 Kerne teilen, hatte kein Prozess einen Mangel an CPU-Ressourcen, wodurch die Laufzeitunterschiede, die man auf einem überlasteten Produktivrechner beobachten könnte, ausgeglichen werden. Auf einer kleinen dedizierten Instanz würde der Unterschied vermutlich größer werden. Das ist eine echte Beschränkung bei Laptop-Benchmarks und sollte offenbart statt verheimlicht werden.
Latenz: ebenfalls Gleichstand, nachdem der Störfaktor beseitigt wurde
Bei 300 Verbindungen lagen die Medianwerte, p99-Werte sowie sogar die maximalen Latenzwerte nur wenige Millisekunden voneinander entfernt.
Eine frühere Messung hatte bei Node einen maximalen Wert von 230 ms ergeben – ein Wert, der ausreichte, um daraufhin eine ganze Schlussfolgerung zu ziehen. Bei erneuter Messung auf einem ruhigen Rechner, ohne dass der Speichersampler um CPU-Ressourcen konkurrierte, verschwand dieser Anstieg. Er stammte von den Messwerkzeugen und nicht von einem Problem mit der Latenz bei Node.
Dies ist eine Lektion, die auf jeden Benchmark auf einem gemeinsam genutzten Rechner zutrifft. Eine einzige Wertangabe im schlimmsten Fall ist die empfindlichste Zahl, die man erheben kann – schließlich entsteht sie durch einen Fehler im Scheduler oder einen Hintergrundprozess. Bevor man einem beunruhigend hohen Maximalwert Glauben schenkt, sollte man den Test auf einem leeren Rechner erneut durchführen, den Wert p99 gleichzeitig betrachten und prüfen, ob sich das Ergebnis wiederholt.
Arbeitsspeicher: der einzige Unterschied, der bestehen blieb
Der verbrauchte Arbeitsspeicher war der einzige Punkt, bei dem sowohl ein großer Unterschied als auch eine Wiederholbarkeit festzustellen waren:
- Im Leerlauf: Go verbraucht 13 MB, Node 49 MB.
- Kurz nach einer anhaltenden Last von 300 Verbindungen: Go verbraucht 32 MB, Node 84 MB.
Die Messung wurde dreimal wiederholt, jeweils mit einem neuen Prozess, und lief jedes Mal identisch ab. Bei einer Last, die etwa 2,6-mal höher ist, verbraucht Node für funktional identische Aufgaben deutlich mehr Arbeitsspeicher.
Die Erklärung ist größtenteils strukturell. Ein Node-Prozess enthält den V8-Engine, dessen JIT-Kompilator sowie einen für eine dynamische Sprache dimensionierten Heap mit Garbage Collection, während ein Go-Binärprogramm im Voraus mit einem schlankeren Laufzeitumfeld kompiliert wird. Wenn man für Speicholimits in Containern bezahlt, verstärkt sich der Unterschied bei jeder Replik: Ein Endpunkt, der in mehreren Pods bereitgestellt wird, zahlt die Node-Grundgebühr in jedem einzelnen davon.
Was dieses Experiment nicht zeigt
Es ist verlockend, dies als „Go schlägt Node“ zu interpretieren, doch die Daten stützen das nicht. Durchsatz und Latenz hängen zusammen, und Node gewann bei Anfragen mit geringer Konkurrenz. Wenn Ihre Einschränkung die Anzahl der Anfragen pro Sekunde oder die Latenz am Ende einer Abfrage auf einem Host mit freien Kernen ist, deutet dieser Benchmark darauf hin, dass ein Neuschreiben kaum Vorteile bringt.
Aus diesem Text geht auch nichts über arbeitssintflüssige Aufgaben aus, die auf einer Datenbank basieren. Die meisten Produktivdienste verbringen den größten Teil ihrer Latenzzeit damit, auf Abfragen zu warten, anstatt JSON zu serialisieren – und diese Zeit ist in beiden Sprachen dieselbe. Wenn Ihr Dienst bei Postgres durch I/O-Beschäftigung eingeschränkt ist, sind diese Zahlen für Sie größtenteils irrelevant.
Zum Schluss bleibt die grundlegende Asymmetrie bestehen: Go nutzt standardmäßig jeden Kern, während Node im Ein-Prozess-Modus nur einen verwendet. Was wie ein Vorteil von Go erscheint, ist in Wirklichkeit eher „Go parallelisiert automatisch“. Ein angemessener erster Schritt ist das eigene cluster-Modul von Node, das einen Worker pro Kern bereitstellt – dadurch erhält Node denselben Hardware-Unterstützungsumfang wie Go kostenlos. Dies wurde hier nicht getestet, und es ist weitaus weniger invasiv als die Einführung einer zweiten Sprache. Bedenken Sie, dass jeder geklusterter Worker ein separater Prozess mit eigenem Heap ist; daher kann das Klonen die CPU-Nutzung verbessern, während der gesamte Speicherverbrauch größer statt kleiner wird.
Die Zahlen in eine Entscheidung umwandeln
Angesichts dieser Ergebnisse reicht eine vollständige Neuschreibung nicht aus, um die Anforderungen zu erfüllen. Eine gezieltere Vorgehensweise hingegen schon: Man sollte nur den merkmalsintensiven Endpunkt übertragen, der auf vielen Replikaten läuft, wo eine Halbierung des verfügbaren Speichers zu einer deutlichen Einsparung führt, und alles andere in Node belassen.
Diese Zurückhaltung ist wichtig, denn der Umzug von Code ist niemals kostenlos. Eine zweite Sprache bedeutet einen weiteren Toolchain, ein anderes Deployment-Pipeline, zusätzliche Handbücher für die Einsatzingenieure sowie eine Aufteilung der Teamkompetenzen. Diese Kosten lohnen sich, wenn Speicher der entscheidende Engpass ist, nicht jedoch, wenn das nicht der Fall ist.
Wenn der nächste Vorschlag für einen Umzug von Node zu Go eintrifft, können Sie ihn auf dieselbe Weise bewerten:
- Bauen Sie beide Versionen des jeweiligen Services oder Endpunkts erstellen.
- Laden Sie sie unter der Konkurrenzlast, die Ihr tatsächlicher Traffic aufweist, einschließlich des Schreibwegs.
Haupterkenntnisse
- Für eine einfache in-Memory-JSON-API lieferten Node und Go auf dieser Hardware effektiv denselben Durchsatz sowie dieselbe Latenz.
- Go’s deutlichster und wiederholbarer Vorteil war ein etwa 2,6-mal geringerer Speicherverbrauch unter Last, was insbesondere für weit verbreitete Dienste wichtig ist.
- Die standardmäßige Mehrkern-Planung in Go im Vergleich zum Ein-Prozess-Modell von Node ist ein Verwirrungsfaktor; versuchen Sie zunächst Clustering, bevor Sie etwas umschreiben.
- Benchmark-Ergebnisse, insbesondere einzelne Spitzenwerte bei hoher Latenz, sind auf geteilten Rechnern häufig zu beobachten; stellen Sie die Ergebnisse erst nach Nachvollziehung fest.
- Migrieren Sie selektiv dort, wo der erzielte Vorteil real ist und die Kosten einer zweiten Sprache überwiegt.
Verwandte Artikel
- Node.js Streams erklärt: Behebung von Speicherfehlern bei Dateien — Erfahren Sie, warum das Laden ganzer Dateien in den Speicher zu Abstürzen der Node.js-Server führt und wie lesbare, schreibbare, duplexe sowie transformierende Streams dies durch Rückdruck beheben.
- One Weather CLI, fünf Toolchains: Rust, Go, Zig, Bun und Node.js — Wie derselbe kleine HTTP-plus-JSON CLI in Rust, Go, Zig, Bun und Node.js aussieht sowie was Binärgröße, Kompilierzeit und Einrichtungshürden für Ihre Wahl bedeuten.
- Warten gegen Berechnen: Konkurrenz und Parallelismus in Node.js und Go — Ausführbare Beispiele für Node.js und Go, die Konkurrenz von Parallelismus trennen, zeigen, wann Promises ausreichen, wann Worker-Threads benötigt werden und wie Goroutinen sich unterscheiden.