Von einem Entwicklungsskript zur Produktions-Node.js-API: Event Loop, Worker Threads, Logging
Wie der Event-Loop, blockierende Aufgaben, Worker-Threads, Zeitlimits, Konkurrenz, strukturierte Protokolle sowie der Datenbankzugriff eine Node.js-API prägen, die in der Produktion funktioniert.
Quellen: The Node.js Runtime Is the Easy Part
Es ist selten die Erstellung einer Node.js-API, die das Problem darstellt. Der Endpunkt gibt Daten zurück, die Frontend-Plattform rendernt sie, und die Testsuite zeigt grüne Ergebnisse. Die eigentlichen Schwierigkeiten beginnen in der Produktion, wenn plötzlich Tausende von Anfragen gleichzeitig eintreffen, eine Datenbank langsamer wird, eine API eines Drittanbieters nicht mehr antwortet, der Speicherverbrauch steigt oder eine Anfrage, die normalerweise 200 Millisekunden dauert, plötzlich 12 Sekunden benötigt. Es gibt auch eine etwas ruhigere Frage: Wird ein anderer Entwickler diesen Code in sechs Monaten noch verstehen?
Auf diese Fragen lassen sich Antworten durch das Verhalten der Laufzeit sowie durch einige Designpraktiken finden. Hier erfahren Sie, wo das Ausführungsmodell von Node.js hervorragt, wo es versagt, und welche Muster dafür sorgen, dass ein Service auch bei echtem Traffic weiterhin reaktiv bleibt: Übertragung der CPU-Arbeit auf Worker-Threads, Zeitlimits, parallele Netzwerkaufrufe, strukturiertes Logging sowie sorgfältige Abfragen.
Wofür die Laufzeit tatsächlich gut ist
Die gängige Darstellung besagt, dass Node.js es JavaScript ermöglicht, außerhalb des Browsers zu laufen. Das stimmt zwar, doch dabei wird der interessante Aspekt übersehen. Was Node.js tatsächlich bietet, ist ein ereignisgesteuerter Laufzeitumgebung mit asynchroner Eingabe/Ausgabe sowie einem sehr umfangreichen Ökosystem darum herum. Diese Architektur lohnt sich immer dann, wenn ein Server den größten Teil seiner Zeit damit verbringt, auf etwas anderes zu warten:
- Abfragen an eine Datenbank
- Ausgehende HTTP-Anfragen
- Lesen und Schreiben von Dateien
- Netzwerkverkehr im Allgemeinen
- Nachrichtenwarten
- Langlebige WebSocket-Verbindungen
Verfolgen Sie eine einzelne Anfrage durch einen typischen Backend:
Client
↓
API Server
↓
Database
↓
External API
↓
Response
Fast zu keinem Zeitpunkt in dieser Kette ist der Server damit beschäftigt, Berechnungen durchzuführen. Er wartet hauptsächlich auf die Datenbank und anschließend auf die externe API. Das Node.js-Modell ist so konzipiert, dass diese inaktive Zeit genutzt wird, um weitere Anfragen zu bearbeiten – dieses Verständnis ist wertvoller als jede einzelne API.
Wie der Event-Loop Ihr Denkmuster verändert
JavaScript in einer Node.js-Anwendung läuft normalerweise auf einem Hauptthread, während der Event-Loop die asynchronen Operationen darum herum koordiniert. Eine vereinfachte Darstellung des Lebenszyklus einer Anfrage:
Incoming Request
↓
JavaScript
↓
Start I/O operation
↓
Don't block execution
↓
Event Loop
↓
I/O completes
↓
Callback / Promise
↓
Continue execution
So sieht es in gewöhnlichem Code aus:
const user = await database.getUser(userId);
const orders = await database.getOrders(userId);
return {
user,
orders
};
await pausiert diese Funktion, nicht den gesamten Prozess. Während die Datenbank an der ersten Abfrage arbeitet, kann der Laufzeitumgebung frei andere Callbacks sowie weitere Anfragen bearbeiten. Deshalb kann ein einziger Node.js-Prozess eine große Anzahl von Anfragen verarbeiten, die meist im Netzwerk warten.
Eine Nuance, die beachtet werden sollte: Die beiden oben genannten Abfragen werden nacheinander ausgeführt, da das zweite await erst startet, nachdem die erste abgeschlossen ist. Darauf kommen wir im Abschnitt zur Konkurrenz zurück.
Asynchron bedeutet nicht schnell
Das Modell weist eine feste Grenze auf. Wenn Ihre eigene JavaScript-Logik eine zeitaufwändige Berechnung durchführt, kann auf diesem Thread nichts anderes ausgeführt werden, bis sie abgeschlossen ist. Eine solche Schleife hält den Thread während ihrer gesamten Dauer in Anspruch:
function expensiveOperation() {
let result = 0;
for (let i = 0; i < 10_000_000_000; i++) {
result += i;
}
return result;
}
Während es läuft, werden keine eingehenden Anfragen akzeptiert, Versprechen lösen sich nicht auf, und Timer feuern nicht zum vorgesehenen Zeitpunkt. Es gibt keinen Scheduler, der den Loop unterbricht, um anderen Anfragen eine Chance zu geben. Das ist der grundlegende Grund, warum es wichtiger ist, zu wissen, wie sich die Laufzeit verhält, als sich ihre APIs einzuprägen.
Das Problem mit blockierenden Handlern
Stellen Sie sich nun vor, diese Art von Arbeit wird in einen HTTP-Handler gelegt. Bildumwandlungen, große Datentransformationen oder aufwändige Berechnungen, die direkt im Anfragenpfad ausgeführt werden, erscheinen im Code unbedenklich:
app.post("/process", async (req, res) => {
const result = expensiveComputation(req.body);
res.json(result);
});
Das Problem tritt nur unter Last auf. Eine Anfrage startet die teure Funktion und monopolisiert den Event-Loop. Alle anderen Anfragen müssen hinterher warten. Die Latenz steigt insgesamt an – nicht nur für den ressourcenintensiven Endpunkt – und der Anwendungsprozess selbst wird zum Engpass. Das Markieren des Handlers mit async hilft nicht, da die darin stattfindende Berechnung weiterhin synchron ist.
Ein robustereres Design bringt arbeitsintensive Aufgaben, die vom CPU abhängen, vollständig außerhalb des Anfragenweges unter:
Client
↓
Node.js API
↓
Queue
↓
Worker
↓
Heavy computation
↓
Database / Storage
Die API nimmt den Auftrag entgegen, legt ihn in eine Warteschlange und antwortet schnell, während dedizierte Worker die Aufgaben in ihrem eigenen Tempo verarbeiten und die Ergebnisse in eine Datenbank oder ein Objektspeichersystem schreiben. Die API bleibt reaktiv, und Sie können die Worker unabhängig von der Web-Schicht skalieren. Dieser Wandel – von der Erstellung von Handlern zur Gestaltung des Arbeitsablaufs – ist ein wesentlicher Unterschied zwischen der bloßen Vertrautheit mit Node.js und dem Aufbau von Systemen damit.
Wann man keine Warteschlange verwenden sollte
Eine Warteschlange erfordert zusätzliche Infrastruktur sowie Berücksichtigung von Wiederholungsversuchen und endgültiger Konsistenz. Für kurze CPU-Aufgaben, die synchron ein Ergebnis zurückgeben müssen, halten Node.js’ Worker-Threads die Arbeit im Prozess, aber außerhalb des Hauptthreads. Bei Aufgaben, die nur Millisekunden dauern, reicht es oft aus, sie direkt auszuführen. Die Entscheidung hängt davon ab, wie lange die Arbeit dauert und ob der Client auf die Antwort warten muss.
Express ist ein Framework, nicht die Laufzeitumgebung
Viele Entwickler verschwimmen die Grenze zwischen Node.js und Express. Node.js ist die Laufzeitumgebung. Express ist ein Web-Framework, das darauf aufgebaut ist. Man kann HTTP ohne etwas anderes als das eingebaute http-Modul bereitstellen:
const http = require("http");
const server = http.createServer((req, res) => {
res.writeHead(200, {
"Content-Type": "application/json"
});
res.end(JSON.stringify({
message: "Hello"
}));
});
server.listen(3000);
Das funktioniert, aber mit wachsenden Anwendungen fallen Routing, Middleware, Parsing des Anfragekörpers, Validierung und Fehlerbehandlung in Ihren Verantwortungsbereich. Frameworks dienen dazu, diese Last von Ihnen zu nehmen. Die entsprechende Umsetzung in Express sieht so aus:
import express from "express";
const app = express();
app.use(express.json());
app.get("/users/:id", async (req, res) => {
const user = await getUser(req.params.id);
res.json(user);
});
app.listen(3000);
Express ersetzt nichts in Node.js; es bietet lediglich eine bequemere Abstraktion über dieselben Primitiven. Wenn man diese Unterscheidung klar behält, fällt das Debuggen leichter: Ein langsamer Endpunkt könnte ein Problem des Frameworks sein, ein Problem des Laufzeitumfelds oder keines von beidem.
Produktionscode dreht sich hauptsächlich um Grenzen
Ein kleiner Service kann in einer einzigen Datei untergebracht sein. Ein Produktionsystem hingegen nicht, da unterschiedliche Verantwortungsbereiche klare Trennlinien benötigen. Eine sinnvolle Struktur ist:
src/
├── api/
│ ├── routes/
│ ├── controllers/
│ └── middleware/
│
├── services/
│ └── userService.js
│
├── repositories/
│ └── userRepository.js
│
├── config/
│ └── environment.js
│
├── jobs/
│ └── emailWorker.js
│
└── app.js
Die genauen Ordnernamen sind nicht entscheidend. Wichtig ist, dass die Verantwortlichkeiten getrennt bleiben:
- Ein Controller sollte keine Hunderte von Zeilen an Geschäftsregeln sammeln.
- Ein Repository sollte nichts über die Gestaltung von HTTP-Antworten wissen.
- Ein Service sollte keine Express
reqoderresObjekte als Eingaben verwenden.
Genau diese Trennungen ermöglichen es, Code isoliert zu testen, Stück für Stück auszutauschen und für die nächste Person, die ihn liest, verständlich zu machen. Für eine ausführlichere Behandlung dieser Struktur siehe unsere Anleitung zum schichtweisen Node.js API-Design.
Design für Fehler im asynchronen Code
Jeder Node.js-Entwickler lernt diesen Satz frühzeitig:
const data = await fetchData();
Die Produktion stellt schwierigere Fragen dazu. Was passiert, wenn fetchData() 30 Sekunden dauert? Was passiert, wenn es Fehler auslöst? Was passiert, wenn es dreimal in Folge Fehler auslöst? Was passiert, wenn der übergeordnete Dienst völlig down ist? Was passiert, wenn eine Datenbankschreiboperation erfolgreich ist, aber der nächste Schritt fehlschlägt und das System daher nur teilweise aktualisiert wird?
Verlässlicher Backend-Code definiert das Verhalten für solche Fälle, anstatt nur den erfolgreichen Ablauf zu beschreiben. Ein kleines Beispiel, das Fehler explizit macht:
async function getUserProfile(userId) {
try {
const user = await userService.getUser(userId);
if (!user) {
throw new Error("User not found");
}
return user;
} catch (error) {
logger.error({
userId,
error: error.message
});
throw error;
}
}
Die Funktion wandelt einen fehlenden Benutzer in einen Fehler um, protokolliert Fehler zusammen mit dem notwendigen Kontext zur Untersuchung und wirft ihn erneut aus, damit der Aufrufer entscheiden kann, wie darauf reagiert werden soll. Das zugrundeliegende Prinzip ist, dass das Fehlermanagement eine Gestaltungsentscheidung ist und nicht etwas ist, das nach einem Vorfall hinzugefügt wird.
Eine praktische Einschränkung: Wenn jede Schicht protokolliert und Fehler erneut auswirft, erzeugt ein einziger Fehler mehrere Protokollierungszeilen. Entscheiden Sie, wo Fehler protokolliert werden sollen (oft einmal, an der Grenze, die sie verarbeitet), und lassen Sie die inneren Schichten stattdessen den Kontext hinzufügen.
Warten Sie niemals ewig: Zeitlimits
Hier ist ein Aufruf an einen anderen Dienst ohne jeglichen Schutz:
const response = await fetch("https://example.com/api");
Falls der entfernte Server die Verbindung akzeptiert, aber nie antwortet, kann dieses await weitaus länger blockieren, als ein Benutzer ertragen kann, wobei Ressourcen die ganze Zeit belegt bleiben. Ein Produktivsystem benötigt eine explizite Obergrenze. Mit dem standardmäßigen AbortController, der in modernen Node.js-Versionen verfügbar ist, kann eine solche Obergrenze eingeführt werden:
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 5000);
try {
const response = await fetch(
"https://example.com/api",
{
signal: controller.signal
}
);
return await response.json();
} finally {
clearTimeout(timeout);
}
Der Timer beendet die Anfrage nach fünf Sekunden, und der finally-Block löscht ihn, damit er nach einer erfolgreichen Antwort nicht ausgelöst wird. Neuere Laufzeiten bieten außerdem AbortSignal.timeout(ms), welches dieselbe Idee in einer einzigen Ausdrucksform darstellt.
Fünf Sekunden sind keine magische Zahl und könnten für einen an Benutzer gerichteten Endpunkt viel zu großzügig sein. Wählen Sie einen Wert aus dem Latenzbudget des Endpunkts. Unverhandelbar ist, dass ein unbegrenztes Warten keine akzeptable Fehlerbehandlungsmethode darstellt.
Konkurrenz ohne Parallelismus
Wenn zwei Operationen voneinander unabhängig sind, führt das Abwarten von einer nach der anderen Zeitverschwendung zur Folge:
const user = await getUser();
const orders = await getOrders();
Der zweite Aufruf beginnt erst, nachdem der erste abgeschlossen ist, sodass die Gesamtverzögerung die Summe beider beträgt. Es ist in der Regel besser, sie gleichzeitig zu starten und auf beide zu warten:
const [user, orders] = await Promise.all([
getUser(),
getOrders()
]);
Sowohl Anfragen werden derzeit gleichzeitig ausgelöst, und die Gesamtverzögerung entspricht ungefähr der des langsameren Vorgangs. Das ist Konkurrenzverarbeitung, keine parallele Berechnung: Ihr JavaScript läuft weiterhin auf einem Thread, doch die Wartezeiten überschneiden sich. Der Unterschied ist am wichtigsten bei Endpunkten, die auf mehrere nachgelagerte Dienste verweisen.
Zwei Randfälle, die man im Hinterkopf behalten sollte. Promise.all lehnt ab, sobald eine der Eingaben fehlschlägt, daher sollte man Promise.allSettled verwenden, wenn teilweise akzeptable Ergebnisse ausreichen. Zudem kann das gleichzeitige Ausführen von Hunderten von Aufrufen einen Datenbankpool oder eine auf Bandbreite beschränkte API überlasten, weshalb man die Konkurrenzverarbeitung bei großen Listen begrenzen sollte.
Die Leistung liegt in der Architektur
Es ist üblich, dass Teams über mikroverteilte Entscheidungen diskutieren, wie zum Beispiel eine klassische
for
Loop im Vergleich mit
forEach
Während derselbe Endpunkt fünf unnötige Datenbankabfragen auslöst. Oder um einen sorgfältig abgestimmten kleinen Hilfsmechanismus zu sehen, während die API enorme JSON-Datenmengen zurückgibt, oder während jede Anfrage 800 Millisekunden auf einer langsamen Abfrage wartet.
In echten Diensten werden die Antwortzeiten in der Regel durch Folgendes bestimmt:
- die Art und Weise, wie auf die Datenbank zugegriffen wird
- Netzwerkverzögerungen
- ineffiziente oder unindizierte Abfragen
- übermäßige Serialisierung
- Abhängigkeiten von externen APIs
- Aufgaben, die überhaupt nicht notwendig sind
- Arbeitsspeicherdruck
- fehlendes oder schlecht konzipiertes Caching
Die größten Verbesserungen ergeben sich in der Regel aus architektonischen Änderungen, nicht aus Syntax-Änderungen. Wenn Sie eine strukturierte Methode zur Priorisierung möchten, sehen Sie sich unseres priorisierungsorientierten Frameworks für die Leistung von Node.js-APIs an.
Beobachtbarkeit ist ein Bestandteil des Produkts
Ein Server, der nicht mitteilen kann, was er getan hat, ist sehr schwer zu bedienen. Das Protokollieren ist die erste Ebene, aber eine solche Zeile ist in der Produktion nahezu nutzlos:
console.log("Something went wrong");
Strukturiertes Logging protokolliert den Namen des Ereignisses sowie die Identifikatoren, die Sie zur Untersuchung benötigen:
logger.error({
event: "payment_failed",
orderId,
userId,
error: error.message
});
Weil jeder Eintrag ein Objekt und nicht freier Text ist, können Protokolle gefiltert, aggregiert und abgefragt werden – beispielsweise alle payment_failed-Ereignisse für einen bestimmten Benutzer.
Protokolle allein lassen jedoch Lücken. Produktivsysteme stützen sich in der Regel auf drei ergänzende Signale:
Logs
↓
What happened?
Metrics
↓
How often / how much?
Traces
↓
Where did the request spend its time?
Logs zeigen an, was passiert ist, Metriken geben an, wie oft und in welchem Umfang, und Spuren zeigen, wo eine Anfrage ihre Zeit in den verschiedenen Diensten verbracht hat. Wenn jemand meldet, „dass die Anwendung langsam ist“, ermöglichen Ihnen diese Anzeichen, mit Beweisen statt mit Vermutungen zu antworten.
Der Laufzeitumgebung wird der Datenzugriff nicht vorgegeben
Die Wahl von Node.js legt keine Datenbank fest. Es funktioniert gut mit PostgreSQL, MySQL, MongoDB, Redis, SQLite, Elasticsearch und vielen anderen. Weitaus wichtiger ist, wie der Code mit der von Ihnen gewählten Datenbank kommuniziert. Dieser Endpunkt erscheint völlig vernünftig:
app.get("/products", async (req, res) => {
const products = await db.query(
"SELECT * FROM products"
);
res.json(products);
});
In der Produktion wirft das sofort eine Reihe von Fragen auf:
- Wie viele Zeilen enthält
productsheute und wie viele im nächsten Jahr? - Gibt es eine Paginierung?
- Wird die Abfrage durch einen Index unterstützt?
- Könnte diese Antwort auf 50 MB anwachsen?
- Wie verhält sie sich unter gleichzeitigem Traffic?
- Ist der Connection Pool mit vernünftigen Grenzen dimensioniert?
- Könnte ein Rohfehler der Datenbank an den Client durchsickern?
Node.js wird Ihnen auf keine dieser Fragen eine Antwort geben. Diese Entscheidungen liegen bei demjenigen, der den Service entwirft.
Node.js als ein Komponente unter vielen
Node.js ist in Cloud-Umgebungen weit verbreitet, unter anderem weil sein leichtgewichtiges Prozessmodell gut zu Containern und serviceorientierten Designs passt. In solchen Systemen ist es in der Regel nur ein Bestandteil von mehreren Komponenten: eine API-Schicht zusammen mit Warteschlangen, Worker-Prozessen, Caches, Datenbanken und anderen Diensten, wobei jede ihre eigene Aufgabe hat. Es muss nicht das gesamte System sein, und ein echter Backend ist selten nur „eine Express-API“.
Wo Node.js Unterstützung benötigt
Kein Laufzeitumfeld eignet sich für jede Arbeitslast. Node.js ist am stärksten, wenn die Arbeit hauptsächlich aus I/O-Aufgaben besteht, während rechenintensive Aufgaben eine sorgfältigere Gestaltung erfordern. Beispiele:
- Skalierter Bildverarbeitung
- Video-Kodierung
- Intensive wissenschaftliche Berechnungen
- Einige Machine-Learning-Arbeitslasten
- Rechenintensive kryptografische Operationen
- Sehr umfangreiche Datenumwandlungen
Nichts davon ausschließt Node.js von solchen Systemen. Das bedeutet, dass der Prozess, der HTTP-Anfragen verarbeitet, nicht die aufwändige Arbeit übernehmen sollte. Zu den Möglichkeiten gehören:
- Arbeitsschichten innerhalb desselben Prozesses
- getrennte Hintergrundarbeitsprozesse
- Warteschlangen zwischen der API und den Arbeitsschichten
- spezielle Dienste für den aufwändigen Teil
- andere Sprachen oder Laufzeiten, die besser für die Berechnungen geeignet sind
Das Ziel ist es nicht, jedes Problem auf Node.js zu zwingen, sondern es dort einzusetzen, wo sein Ausführungsmodell am besten passt.
„Warum“ fragen, nicht nur „wie“
Zu Beginn lautet die Frage, wie man eine API mit Node.js erstellt. Mit Erfahrung wird es zur Frage, warum die API auf eine bestimmte Weise erstellt werden sollte, was wiederum zu einer anderen Reihe von Fragen führt:
- Soll diese Operation synchron ablaufen oder über eine Warteschlange?
- Soll die Antwort im Cache gespeichert werden?
- Behält dieses Service diese Daten für sich?
- Soll dieser Endpunkt von drei anderen Services abhängen, und was passiert, wenn einer versagt?
- Wo sollten Wiederholungsversuche stattfinden?
- Was sollte protokolliert und was gemessen werden?
- Wie wird sich dies unter Last verhalten?
Nichts davon ist spezifisch für Node.js. Es handelt sich um Fragen der Softwareentwicklung, und Node.js stellt die Werkzeuge zur Umsetzung der Antworten bereit.
Die Produktions-Checkliste im Überblick
- Durch den Event-Loop ist Node.js hervorragend darin, Wartezeiten im Netzwerk und auf der Festplatte zu überlappen, doch jede synchrone CPU-Arbeit blockiert jeden Anfrageverarbeitungsvorgang auf diesem Thread.
- Verlagern Sie aufwändige Berechnungen außerhalb des Anfragepfades mithilfe von Worker-Threads, Queues oder separaten Services.
- Betrachten Sie Fehlerfälle und explizite Zeitlimits als Teil des Designs; eine unbegrenzte Wartezeit ist ein Fehler.
- Führen Sie unabhängige Nachverarbeitungsrufe gleichzeitig mit
Promise.allaus und begrenzen Sie die Konkurrenz, wenn Sie über viele Kanäle verteilen. - Leistungsprobleme liegen in der Regel in Abfragen, der Größe der Datenlast und den Netzwerkübertragungen, nicht in der Schleifensyntax.
- Gestrukturierte Logs, Metriken und Spuren verwandeln Produktionsprobleme in nachvollziehbare Beweise.
- Frameworks und Laufzeiten sind nur Werkzeuge; Architektur, Grenzen und Datenzugriff bleiben in der Verantwortung des Entwicklers.