Startseite / Artikel / Leistung der Node.js-API: Ein optimierendes Framework mit priorisierter Reihenfolge

Leistung der Node.js-API: Ein optimierendes Framework mit priorisierter Reihenfolge

Erfahren Sie, wie Sie Performance-Probleme der Node.js-API in Kategorien nach Aufwand und Auswirkung einordnen, damit Sie zunächst Probleme wie Connection Pooling und N+1-Abfragen beheben, bevor Sie sich auf ausgefallene Optimierungen konzentrieren.

1300 Wörter

Die meisten Artikel zum Beschleunigen einer Node.js-API präsentieren einfach eine Liste von fünfzehn Tipps, wobei beispielsweise ein schnellerer JSON-Serializer neben der Poolsierung von Datenbankverbindungen steht – als würde jeder Punkt denselben Anteil an Aufmerksamkeit verdienen. Das ist irreführend. Einige Lösungen erfordern nur zehn Minuten und verkürzen die Antwortzeiten deutlich. Andere hingegen benötigen monatelange Arbeit für einen geringfügigen Vorteil. Es ist weitaus wichtiger, den Unterschied zu verstehen, als sich jeden Punkt der Liste einzuprägen.

Ebene 1: Machen Sie zuerst das (hoher Einfluss, geringe Anstrengung)

Diese Änderungen kosten fast nichts an Implementierungskosten. Sie erfordern wenig Code, bergen kaum Risiken und sind in der Praxis weitaus häufiger die eigentliche Ursache für Langsamkeiten als die exotischen Lösungen, nach denen man stattdessen greift.

Activieren Sie Keep-Alive für alle ausgehenden HTTP-Anfragen. Standardmäßig öffnet der HTTP-Client von Node für jeden Aufruf eine neue Verbindung, sodass jede Anfrage an einen externen Dienst erneut den vollen Aufwand eines TCP-Handshakes sowie der TLS-Verhandlung in Anspruch nimmt. Die Wiederverwendung derselben Verbindung bei mehreren Anfragen eliminiert diesen Aufwand für alle nachfolgenden Aufrufe an denselben Host.

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

Kommunizieren Sie stets über einen Verbindungspool mit Ihrer Datenbank, nicht über eine einzelne Verbindung. Unter echter gleichzeitiger Belastung wird eine einzige Verbindung faktisch zu einer Warteschlange, hinter der alles ansteht. Ein angemessen großer Pool ermöglicht es Ihrer API, den gleichzeitigen Datenverkehr so zu verarbeiten, wie er tatsächlich strukturiert ist – parallel.

Lassen Sie unabhängige asynchrone Aufrufe parallel statt nacheinander laufen. Wenn zwei await-Anweisungen nicht auf die Ausgabe der anderen angewiesen sind, gibt es keinen Grund, sie dazu zu zwingen, nacheinander zu warten.

// 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)]);

Fügen Sie Indizes für die Spalten hinzu, nach denen Ihre Abfragen tatsächlich filtern, verknüpfen oder sortieren. Von allem auf dieser Liste ist dies wohl der effektivste Schritt für jeden Endpunkt, der von einer stetig wachsenden Tabelle liest – und es handelt sich dabei oft um eine Migration, die in einer einzigen Zeile erledigt werden kann.

Beseitigen Sie N+1-Abframuster. Das Abrufen einer Liste und das Anfordern einer weiteren Abfrage pro Element innerhalb einer Schleife erscheint mit nur wenigen Testdatensätzen unbedenklich, wird aber zu einem ernsthaften Problem, sobald es um Tausende echter Datensätze geht. Ersetzen Sie die Schleife durch eine einzige, gebündelte Abfrage für die verwandten Daten.

Jede Abfrage, die sonst ein unbeschränktes Ergebnismenge zurückgeben könnte, sollte mit einem LIMIT begrenzt werden. Ein Endpunkt, der „alle Bestellungen, die dieser Kunde je aufgegeben hat“, ohne Obergrenze zurückgibt, funktioniert gut für ein völlig neues Konto, bricht jedoch bei einem Konto mit jahrelanger Bestellhistorie zusammen.

Ebene 2: Lohnt sich eine echte Investition (hoher Einfluss, erhebliche Anstrengung)

Die Maßnahmen auf dieser Ebene sind keine kleinen Anpassungen. Sie erfordern echte Entwurfsarbeit und oft neue Infrastruktur, lösen aber Problemkategorien, die keine Lösung der Ebene 1 bewältigen kann.

Führen Sie eine Caching-Schicht für teure und häufig durchgeführte Lesevorgänge ein. Die Einbindung von Redis vor einem langsamen Aggregatabfragen oder einer kostspieligen Anfrage an eine Drittanbieter-API kann eine Antwortzeit von 200 Millisekunden auf etwa 2 Millisekunden reduzieren. Der schwierige Teil besteht nicht darin, den Cache einzurichten, sondern darin, eine ausreichend solide Invalidation-Strategie zu entwerfen, damit der Cache niemals veraltete Ergebnisse ausliefert.

Nehmen Sie langsame, nicht kritische Aufgaben vollständig aus dem Request-Response-Zyklus heraus. Das Versenden einer Bestätigungs-E-Mail, das Erstellen eines Berichts oder die Aktualisierung von Analysedaten müssen nicht abgeschlossen sein, bevor Sie dem Kunden antworten. Die Kombination einer Warteschlange wie BullMQ oder SQS mit einem dedizierten Worker-Prozess kann eine zuvor 2 Sekunden dauernde Anfrage auf etwa 80 Millisekunden verkürzen.

Wechseln Sie von einer auf Offset basierenden Paginierung zu einer auf Cursor basierenden Paginierung für große oder tief strukturierte Ergebnismengen. Die auf OFFSET basierende Paginierung wird mit zunehmender Tiefe der Seiten immer langsamer, da die Datenbank weiterhin jede vorherige Zeile durchscannen muss. Ein auf Cursor basierender Ansatz kostet in etwa genauso viel Energie, egal ob man sich auf Seite 5 oder Seite 5.000 befindet.

Erweitern Sie die Kapazität horizontal mithilfe eines echten Load Balancers sowie zentralisierter Session- oder Cache-Strukturen. Egal wie gut Sie etwas optimiert haben, ein einzelner Node-Prozess erreicht letztendlich eine Grenze. Durch den Betrieb mehrerer Instanzen hinter einem Load Balancer, die jeweils einen gemeinsamen Redis-Cache und eine Verbindungs-Pool teilen, wird diese Grenze erhöht, ohne dass ein einzelner Prozess individuell schneller werden muss.

Profilen Sie zuerst, bevor Sie weiter optimieren. Sobald die offensichtlichen Probleme behoben sind, ist das Raten darüber, was langsam ist, keine zuverlässige Strategie mehr. Die Verwendung eines echten Profilers oder einer APM-Tool wie clinic.js, einem gehosteten APM-Produkt, oder sogar das Ausführen von EXPLAIN ANALYZE auf einer verdächtigen Abfrage zeigt Ihnen, wo tatsächlich Zeit verbraucht wird – anstatt dort, wo Sie annehmen, es müsste sein.

Ebene 3: In der Regel nicht prioritär wertvoll (geringer Einfluss, oft überbewertet)

Diese Aspekte tauchen ständig in Leistungsdiskussionen auf, haben jedoch selten einen bedeutenden Einfluss auf eine echte Produktions-API – hauptsächlich, weil sie Teile des Stack adressieren, die von Anfang an nie der eigentliche Engpass waren.

Detailanpassung der JSON-Serialisierung. Es existieren tatsächlich schnellere JSON-Bibliotheken, die helfen können, doch nur in einem Umfang, den die überwiegende Mehrheit der APIs niemals erreicht. Wenn Ihr echtes Problem eine 300-Millisekunden-dauernde Datenbankabfrage ist, dann löst das Weglassen einiger Millisekunden bei der Serialisierung das falsche Problem.

Austausch von Frameworks allein, um den Overhead der Frameworks zu verringern. Der Leistungsunterschied zwischen Express und einer angeblich schnelleren Alternative existiert zwar, ist aber im Vergleich zu den Kosten durch eine unindizierte Tabelle oder eine N+1-Abfrage gering. Die Wahl des Frameworks kann aus vielen anderen Gründen wichtig sein; reine Geschwindigkeit ist in der Regel nicht einer davon.

Zuerst das Clustering anwenden, bevor man sich um alles andere kümmert. Das Starten mehrerer Node-Prozesse, um zusätzliche CPU-Kerne zu nutzen, hilft tatsächlich bei arbeitsintensiven Aufgaben, die stark von der CPU abhängen. Es nützt jedoch nichts bei einem Endpunkt, der langsam ist, weil er auf eine nicht indizierte Abfrage wartet – schließlich bleibt Warten Warten, egal wie viele Prozesse untätig darauf warten.

Hot Code-Pfade in einer niedrigeren Programmiersprache umschreiben, rein aus Gründen der Leistung. Es gibt berechtigte Fälle dafür, wenn ein spezifischer, nachgewiesener CPU-Bottleneck dies rechtfertigt. Häufiger wird diese Methode jedoch bereits angewandt, bevor überhaupt bestätigt wurde, dass dort die Zeit verschwendet wird – wodurch eine echte Lösung zu vergeblichen Bemühungen mit dem falschen Fokus wird.

Wie man dies tatsächlich anwendet

Beseitigen Sie zunächst alle Probleme der Stufe 1, da sie kostengünstig, risikofrei sind und den Großteil der Probleme im praktischen Einsatz abdecken. Gehen Sie erst zur Stufe 2 über, wenn Profilanalysen zeigen, dass Stufe 1 bei bestimmten Endpunkten unzureichend ist – anstatt dies als allgemeine Überarbeitung zu betrachten, die überall angewandt wird. Lassen Sie Stufe 3 unberührt, bis Sie konkrete Belege von einem Profiler haben, statt nur auf Vermutungen zu setzen, dass eine dieser spezifischen Techniken tatsächlich das Engpassproblem darstellt. Die meisten langsamen APIs sind langsam aufgrund ungelöster Probleme der Stufe 1, nicht weil ihnen irgendeine selten verwendete Optimierung fehlt, die aus einem Blogbeitrag stammt.

Verwandte Artikel

  • Edge Isolates und Wasm gegen Node.js: Laufzeit-Abwägungen und Produktionspraktiken — Erklärt, wie V8-Isolaten und WebAssembly am Edge gegenüber containerbasiertem Node.js überlegen sind, und behandelt anschließend die Betriebspraktiken, die Node.js für den Produktivbetrieb bereit machen.
  • Wie process.nextTick() den Node.js Event Loop stillschweigend lahmlegt — Erklärt, warum rekursive Aufrufe von process.nextTick() die Poll-Phase von libuv vollständig blockieren, und wie man das Problem des Event-Loop-Lähmungszustands mit setImmediate() beheben kann.
  • 20 Node.js-Muster, die Produktionsserver-Abstürze verhindern — Lernen Sie 20 praktische Node.js-Muster – von Fehlerbehandlung über sanften Herunterfahren bis hin zu Connection Pooling –, die Abstürze verhindern, bevor ein Neustart notwendig wird.