Startseite / Artikel / 20 Node.js-Muster, die Ausfälle von Produktivservern verhindern

20 Node.js-Muster, die Ausfälle von Produktivservern verhindern

Erlernen Sie 20 praktische Node.js-Muster – von Fehlerbehandlung über einen reibungslosen Herunterfahren bis hin zu Connection Pooling –, die Abstürze verhindern, bevor ein Neustart notwendig wird.

4280 Wörter

Der Ausfall eines Servers beginnt fast nie mit einem katastrophalen Ereignis.

In der Regel ist der Auslöser etwas Unauffälliges und Unbedeutendes.

Eine Verbindung zur Datenbank wird nie wieder in den Pool zurückgegeben.

Der Speicherverbrauch steigt allmählich an, bis der Prozess seine Obergrenze erreicht.

Eine Route, die früher in 200 Millisekunden reagierte, benötigt nun fünf Sekunden.

Eine Bereitstellung trennt Verbindungen, die noch aktiv waren.

Eine asynchrone Aufgabe fehlschlägt, ohne dass jemand den Fehler bemerkt.

Ein plötzlicher Anstieg des Traffic zwingt jede eingehende Anfrage dazu, eine weitere kostspielige Datenbankanfrage auszuführen.

Alles das sieht anfangs nicht beunruhigend aus.

Dann beginnen die Reaktionszeiten nachzulassen.

Dann starten die Anfragen zu timed out.

Dann geht der Prozess vollständig down.

Und genau dann beginnen die Fragen:

„Warum ist der Server down?“

Was das so frustrierend macht, ist, dass die meisten dieser Situationen vermieden werden hätten können.

In vielen Produktivsystemen tauchen immer wieder bestimmte Node.js-Lösungen auf, die als wirklich wirksam gelten – gerade weil sie die Ursache des Problems angehen und nicht nur den Server nach einem Ausfall wieder zum Laufen bringen.

In diesem Artikel werden die wichtigsten davon erläutert.

1. Verhindern, dass eine fehlerhafte Anfrage den gesamten Prozess lahmlegt

Eine der gefährlichsten Gewohnheiten in einer Node.js-Codebasis ist es, zulassen zu wollen, dass eine ausgelöste Ausnahme oder eine unverarbeitete Ablehnung einer Promise bis ins eigentliche Prozesssystem vordringt.

Nehmen wir folgendes Beispiel:

app.get("/users/:id", async (req, res) => {
  const user = await User.findByPk(req.params.id);

  res.json(user);
});

Auf den ersten Blick scheint alles in Ordnung zu sein.

Aber was passiert, wenn die Datenbankverbindung mitten in einer Anfrage abbricht?

Oder der ORM einen Fehler auslöst?

Oder ein unerwarteter Eingabewert die Logik innerhalb des Handlers stört?

Sollte es keine konsistente Fehlerbehandlung geben, kann der Prozess in einen fehlerhaften Zustand geraten, und je nach konkretem Fehler sowie der Konfiguration des Laufzeitumfelds kann er sogar vollständig abgeschaltet werden.

Ein widerstandsfähigerer Ansatz besteht darin, Fehler explizit zu leiten anstatt zuzulassen, dass sie unkontrolliert entkommen:

app.get("/users/:id", async (req, res, next) => {
  try {
    const user = await User.findByPk(req.params.id);

    res.json(user);
  } catch (error) {
    next(error);
  }
});

Von dort aus wird alles in einen zentralen Handler geleitet:

app.use((err, req, res, next) => {
  console.error(err);

  res.status(500).json({
    error: "Internal server error"
  });
});

Es geht hier nicht darum, Probleme zu verbergen.

Es geht darum, genau festzulegen, wohin Fehler gelangen.

Anstatt dass jede Route ihre eigene Methode zur Bewältigung von Fehlern entwickelt, folgt die gesamte Anwendung einem konsistenten, vorhersehbaren Ablauf.

2. Behandeln Sie Prozessneustarts nicht als Lösung

Wenn ein Node.js-Prozess abstürzt, ist die instinktive Lösung in der Regel einfach:

npm start

Oder für Teams, die PM2 verwenden:

pm2 restart app

So wird der Dienst schnell wieder online gebracht.

Es behebt nicht das Problem, das ursprünglich zum Ausfall geführt hat.

Falls Ihre Anwendung alle sechs Stunden aufgrund eines Speicherdurchlasses abstürzt, ist ein Neustart zu diesem Zeitplan kein stabiles System – es handelt sich dabei um eine vorübergehende Lösung.

Werkzeuge wie PM2, Docker, Kubernetes oder eine verwaltete Plattform können einen abgestürzten Prozess automatisch neu starten, was tatsächlich nützlich ist.

Mit PM2 zum Beispiel:

pm2 start app.js --name api
pm2 save

Doch ein automatischer Neustart sollte als Schadensbegrenzung verstanden werden, nicht als Lösung für das zugrunde liegende Problem.

Man benötigt weiterhin Antworten auf Fragen wie:

Why did the process crash?
What request triggered it?
How much memory was being used?
What error occurred?
Was there an unhandled rejection?
Was the database unavailable?

Ein Neustart stellt den Service für Ihre Nutzer wieder her.

Nur eine angemessene Überwachungsmöglichkeit zeigt Ihnen, warum der Service ursprünglich ausgefallen ist.

Sowohl das eine als auch das andere sind notwendig.

3. Beheben Sie Speicherdurchlässe, bevor sie zu Abstürzen aufgrund von Speicherengpässen führen

Node.js setzt auf die Garbage Collection, doch das garantiert nicht, dass der Speicher automatisch ordnungsgemäß bereinigt wird.

Es ist durchaus möglich, dass Ihr Code weiterhin Verweise auf Objekte besitzt, die eigentlich nicht mehr benötigt werden.

Hier ist eine einfache Veranschaulichung:

const cache = [];


app.get("/users", async (req, res) => {
  const users = await getUsers();


  cache.push(users);

  res.json(users);
});

Jeder eingehende Anfrage fügt weitere Daten zum cache hinzu.

Weil die Anwendung weiterhin Verweise auf diese Einträge besitzt, hat der Garbage Collector keine Möglichkeit, sie zurückzugewinnen.

Im Laufe der Zeit sieht das so aus:

Memory usage
100 MB
200 MB
400 MB
800 MB
1.2 GB
2 GB
💥

Die genauen Zahlen variieren je nach Ihrer Konfiguration und den festgelegten Grenzen, doch das zugrundeliegende Muster ist entscheidend.

Achten Sie auf Dinge wie:

  • Globale Arrays, die ständig wachsen
  • Caches ohne Größenbegrenzung
  • Große Objekte, die im Speicher bleiben
  • Event-Listener, die niemals entfernt werden
  • Timer, die niemals gelöscht werden
  • Offene Streams
  • Große Datenmengen, die in Schließfunktionen gespeichert bleiben
  • Warteschlangen ohne Obergrenze
  • Falls Ihre Anwendung Daten im Speicher cachet, benötigt dieser Cache eine explizite Richtlinie.

    Zum Beispiel:

    Maximum entries: 10,000
    TTL: 10 minutes
    Eviction: LRU
    

    Ein Cache ohne Grenzen funktioniert eigentlich nicht als Cache.

    Es handelt sich dabei um ein Gedächtnisproblem, das früher oder später auftritt.

    4. Zeitlimits für externe Anfragen setzen

    Die Aufrufung eines anderen Services ohne Zeitlimit ist einer der schnellsten Wege, Ihre API lahmzulegen.

    Bilden Sie sich diesen Aufruf vor:

    const response = await fetch("https://external-service.com/api");
    

    Was passiert, wenn dieser externe Service plötzlich sehr langsam antwortet?

    Ihre eigene Anfrage könnte viel länger warten müssen, als nötig wäre.

    Versetzen Sie sich nun vor, 1.000 gleichzeitige Benutzer rufen denselben Endpunkt auf.

    Sofort haben Sie Hunderte oder Tausende von Anfragen, die alle auf eine nicht antwortende Abhängigkeit warten.

    Diese Rückstände können letztendlich auch Ihren eigenen Server überlasten.

    Bauen Sie stets eine explizite Zeitüberschreitungsbearbeitung ein.

    Zum Beispiel mit AbortController:

    const controller = new AbortController();
    
    const timeout = setTimeout(() => {
      controller.abort();
    }, 5000);
    
    try {
      const response = await fetch(
        "https://external-service.com/api",
        {
          signal: controller.signal
        }
      );
    
      return await response.json();
    } finally {
      clearTimeout(timeout);
    }
    

    Nun wird Ihre Anwendung nicht endlos warten.

    Die Faustregel ist einfach:

    Lassen Sie niemals eine externe Abhängigkeit bestimmen, wie lange Ihr Server ohne Limit warten soll.

    Dies ist genauso wichtig, egal ob Sie mit einer Zahlungsgateway, einem E-Mail-Verteilungsdienst, einem allgemeinen HTTP-Endpunkt, Redis, Ihrer eigenen Datenbankschicht, einer Drittanbieter-API oder einem anderen internen Microservice in Ihrer Infrastruktur arbeiten.

    5. Behebung der Erschöpfung der Datenbankverbindungen

    Eine Node.js-API kann von selbst völlig in Ordnung erscheinen, während das eigentliche Engpassproblem bei der Schicht der Datenbankverbindungen liegt.

    Stellen Sie sich vor, Ihre Anwendung wird mit folgendem konfrontiert:

    500 concurrent requests
    

    und jede dieser Anfragen versucht, eine eigene neue Datenbankverbindung herzustellen.

    Irgendwann erreicht die Datenbank ihre konfigurierte Obergrenze an Verbindungen.

    Von da an versagen neue Anfragen vollständig.

    Die API kann mit Fehlern wie diesen antworten:

    Too many connections
    Connection refused
    Connection timeout
    

    Ein Verbindungspool existiert genau deshalb, um zu verhindern, dass jede eingehende Anfrage eigenständig eine völlig neue Verbindung erstellt.

    Visuell sieht das ungefähr so aus:

    ┌── Connection 1
                  ├── Connection 2
    API ── Pool ──┼── Connection 3 ── Database
                  ├── Connection 4
                  └── Connection 5
    

    Der Pool fungiert als Wächter und begrenzt die Anzahl der gleichzeitig geöffneten Verbindungen zur Datenbank.

    Doch die richtige Größenanpassung des Pools ist genauso wichtig wie seine Existenz selbst.

    Konfigurieren Sie den Pool bewusst.

    Setzen Sie ihn zu klein:

    Requests
       ↓
    Waiting for connections
       ↓
    High latency
    

    Setzen Sie ihn zu groß:

    Application
       ↓
    Too many connections
       ↓
    Database overloaded
    

    Die richtige Zahl hängt davon ab, wie viel Last Ihre Datenbank tatsächlich bewältigen kann, wie koncurrent Ihre Anwendung ist, wie Ihre Abfragen aussehen und wie Ihre Infrastruktur aufgebaut ist.

    Tun Sie auf keinen Fall Folgendes: Reagieren Sie auf Verbindungsfehler einfach dadurch, dass Sie die maximale Pool-Größe immer weiter erhöhen, ohne Grenzen zu setzen.

    6. Beseitigen Sie N+1-Abfragen

    Eine weitere häufige Ursache dafür, dass Teams versehentlich ihre Datenbank überlasten, ist das N+1-Abframuster.

    Stellen Sie sich einen Endpunkt vor, der eine Liste von Beiträgen zurückgibt:

    const posts = await Post.findAll();
    
    
    for (const post of posts) {
      post.author = await User.findByPk(post.authorId);
    }
    

    Bei 1.000 Beiträgen in der Tabelle sieht das so aus:

    1 query → retrieve posts
    
    1,000 queries → retrieve authors
    
    Total: 1,001 queries
    

    Bei zehn Beiträgen wirkt der Endpunkt sofortig, und niemand bemerkt etwas Falsches.

    Dann wächst das Datensatzvolumen.

    Und derselbe Endpunkt wird extrem langsam.

    Schließlich kann er genügend Datenbankkapazität verbrauchen, sodass auch unverwandte Endpunkte beeinträchtigt werden.

    Einige gängige Lösungen:

    JOINs

    SELECT
      posts.id,
      posts.title,
      users.name
    FROM posts
    JOIN users
      ON users.id = posts.author_id;
    

    Eager Loading bei ORMs

    Die meisten ORMs bieten eine Möglichkeit, verwandte Datensätze in einer einzigen, kontrollierten Operation vorab abzurufen, anstatt für jede Zeile eine eigene Abfrage auszuführen.

    Batch-Abfragen

    Anstatt die Anfragen nacheinander zu senden, wie zum Beispiel:

    Get user 1
    Get user 2
    Get user 3
    ...
    

    holen Sie alle benötigten Benutzer in einer einzigen kombinierten Abfrage ab.

    Beim Lasttest lohnt es sich, eine einfache diagnostische Frage zu stellen:

    Wie viele separate Datenbankabfragen wird dieser Endpunkt ausführen, wenn er 10.000 Datensätze zurückgibt?

    Falls die ehrliche Antwort lautet:

    "10.001."

    dann ist das ein deutliches Zeichen dafür, dass etwas repariert werden muss.

    7. Paginierung hinzufügen, bevor die Datenbank zu groß wird

    Dieser Codeausschnitt ist eine tickende Zeitbombe:

    const users = await User.findAll();
    
    res.json(users);
    

    Es funktioniert hervorragend in der Entwicklung, wobei die Tabelle Folgendes enthält:

    37 users
    

    Sechs Monate später enthält dieselbe Tabelle nun Folgendes:

    2,400,000 users
    

    Der einzig unveränderte Endpunkt kann nun Folgendes tun:

    • Millionen von Zeilen aus der Datenbank abrufen
    • Datenbankressourcen stark belasten
    • Den Arbeitsspeicher des Node.js-Prozesses aufbrauchen
    • Einen riesigen JSON-Inhalt serialisieren
    • Die Bandbreite stark ausnutzen
    • Die Antwortzeiten erheblich verlängern
    • Einen Timeout auslösen

    Die Lösung ist die Paginierung.

    Zum Beispiel sollte eine Anfrage in folgender Form akzeptiert werden:

    GET /users?page=1&limit=50
    

    und wie folgt verarbeitet werden:

    const limit = Math.min(
      Number(req.query.limit) || 50,
      100
    );
    
    const page = Number(req.query.page) || 1;
    
    const offset = (page - 1) * limit;
    

    Ebenso wichtig ist es, eine Obergrenze dafür festzulegen, was Clients anfordern dürfen.

    Lassen Sie nicht zu, dass ein Client Folgendes sendet:

    ?limit=100000000
    

    und zwingen Sie Ihre API dazu, eine Abfrage dieser Größe auszuführen.

    8. Hören Sie auf, unabhängige Operationen nacheinander auszuführen

    Nehmen wir an, ein Endpunkt muss drei Datensätze sammeln:

    User
    Settings
    Notifications
    

    Eine naive Implementierung könnte so aussehen:

    const user = await getUser();
    const settings = await getSettings();
    const notifications = await getNotifications();
    

    Falls jeder Aufruf 200 ms dauert, führt das Warten auf sie nacheinander in etwa zu:

    200 + 200 + 200 = 600 ms
    

    Da diese Aufrufe nicht voneinander abhängen, führen Sie sie lieber gleichzeitig aus:

    const [
      user,
      settings,
      notifications
    ] = await Promise.all([
      getUser(),
      getSettings(),
      getNotifications()
    ]);
    

    Nun erfolgen alle drei Anfragen gleichzeitig.

    Die Gesamtzeit sinkt auf etwa die Zeit des langsamsten einzelnen Aufrufs:

    max(200, 200, 200) ≈ 200 ms
    

    Es geht hier nicht nur darum, Millisekunden aus der Antwort zu sparen.

    In großem Maßstab bedeutet zusätzliche Latenz pro Anfrage, dass die Anfragen länger als nötig Serverressourcen belegen.

    Anfragen, die schneller abgeschlossen werden, befreien auch schneller Ressourcen.

    Trotzdem sollten Sie in jedem Fall nicht automatisch auf Promise.all zurückgreifen.

    Falls ein Aufruf tatsächlich das Ergebnis eines anderen Aufrufs benötigt, wie zum Beispiel:

    const user = await getUser();
    const orders = await getOrders(user.id);
    

    dann müssen diese Aufrufe von Natur aus sequenziell erfolgen.

    9. Sanften Herunterfahren hinzufügen

    Eine der einfachsten Möglichkeiten, während eines Deploys unnötige Ausfallzeiten zu verursachen, ist das plötzliche Beenden des Prozesses.

    Betrachten Sie diese Abfolge:

    User → POST /checkout
                  ↓
           Processing payment
                  ↓
             Deployment starts
                  ↓
              Process killed
    

    Der Benutzer in diesem Szenario könnte lediglich Folgendes sehen:

    Connection reset
    

    obwohl die zugrunde liegende Operation bereits teilweise abgeschlossen sein könnte.

    Ein sanftes Herunterfahren löst dieses Problem, indem der Server die Annahme neuer Verbindungen einstellt und gleichzeitig bereits laufende Anfragen ihren natürlichen Ablauf beenden lässt.

    Eine minimale Version sieht so aus:

    const server = app.listen(PORT);
    
    process.on("SIGTERM", () => {
      console.log("SIGTERM received");
    
    server.close(() => {
        console.log("HTTP server closed");
    
        process.exit(0);
      });
    });
    

    In einem Produktivsystem bedeutet ein sanftes Herunterfahren oft auch das Schließen von:

    • Datenbankverbindungs-Pools
    • Redis-Verbindungen
    • Message-Queue-Konsumenten
  • Hintergrundprozesse
  • Offene WebSocket-Verbindungen
  • Jegliche anderen genutzten Ressourcen
  • Das übergeordnete Ziel lässt sich wie folgt zusammenfassen:

    Stop receiving new work
            ↓
    Finish current work
            ↓
    Close resources
            ↓
    Exit
    

    Eine Bereitstellung sollte nicht so sein, als hätte jemand den Stromstecker aus der Wand gezogen.

    10. Verwendung von Gesundheits- und Bereitschaftsprüfungen

    Es ist durchaus möglich, dass ein Node.js-Prozess weiterhin aktiv ist, während die dahinterliegende Anwendung faktisch nicht mehr funktioniert.

    Bilden Sie sich diesen Zustand vor:

    Node.js process → Running
    Database → DOWN
    API → Practically unusable
    

    Falls das einzige Signal Ihres Load Balancers darin besteht, ob eine TCP-Port antwortet, könnte dieser weiterhin Traffic an eine Instanz leiten, die eigentlich nichts Nützliches liefern kann.

    Ein Bereitschafts-Endpunkt bietet ein echtes Signal zur Überprüfung:

    app.get("/ready", async (req, res) => {
      try {
        await db.query("SELECT 1");
    
       res.status(200).json({
          status: "ready"
        });
      } catch (error) {
        res.status(503).json({
          status: "not_ready"
        });
      }
    });
    

    Dadurch kann Ihre Infrastruktur zwei völlig unterschiedliche Zustände voneinander unterscheiden:

    Process is alive
    

    im Gegensatz zu:

    Process is ready to serve traffic
    

    Dieser Unterschied ist von großer Bedeutung, wenn Sie neuen Code bereitstellen oder sich von einem Fehler erholen.

    11. Den Server vor Traffic-Spitzen schützen

    Nehmen wir an, Ihr Service kann problemlos folgendes bewältigen:

    100 requests/second
    

    Dann wird Ihr Produkt an einem Ort geteilt, der eine große Zielgruppe hat.

    Auf einmal müssen Sie mit folgendem umgehen:

    2,000 requests/second
    

    Ohne Schutzmaßnahmen kann dieser Anstieg den API völlig überlasten.

    Einige gängige Methoden, um Spitzen abzufedern:

    • Einschränkung der Anzahl an Anfragen, die ein Client in einem bestimmten Zeitraum stellen kann
    • Einsatz eines Reverse-Proxy vor Ihren Anwendungsservern
    • Verteilung des eingehenden Traffics auf mehrere Instanzen
    • Caching von Antworten, damit dieselbe Arbeit nicht wiederholt wird
    • Lieferung von statischem Inhalt über ein CDN
    • Ausführung der Aufgaben in einer Warteschlange statt direkt im Betrieb
  • Begrenzung der Anzahl der gleichzeitigen Datenbankverbindungen
  • Automatische Erhöhung der Anzahl der Instanzen mit wachsendem Bedarf
  • Abschaltung von Anfragen, die zu lange zum Abschluss benötigen
  • Ihnen sind nicht unbedingt alle Punkte auf dieser Liste notwendig.

    Jedoch sollte jedes Produktionsystem eine klare Antwort auf folgende Frage haben:

    Was passiert, wenn der Traffic plötzlich um das Zehnfache zunimmt?

    12. Teure Aufgaben außerhalb der HTTP-Anfrage ausführen

    Nehmen wir einen Endpunkt, der einen Benutzer erstellt und gleichzeitig eine Willkommense-Mail versendet:

    await createUser();
    await sendWelcomeEmail();
    res.json({ success: true });
    

    Falls der E-Mail-Dienst drei Sekunden zum Antworten benötigt, verbringt der Benutzer drei Sekunden mit Warten.

    Noch schlimmer ist, dass ein Fehler bei der E-Mail-Versendung dazu führen kann, dass die gesamte Anfrage fehlschlägt, obwohl das Benutzerkonto erfolgreich erstellt wurde.

    Ein auf Warteschlangen basierendes Design vermeidet diese Kopplung:

    HTTP Request
         ↓
    Create User
         ↓
    Return response
         ↓
    Queue email job
         ↓
    Background Worker
         ↓
    Send Email
    

    Die API muss nicht länger warten, bis langsame Hintergrundarbeiten abgeschlossen sind.

    Dieses Muster ist insbesondere in folgenden Situationen nützlich:

    • Versand von Bestätigungs- oder Willkommense-Mails
    • Erstellung von Berichten auf Anfrage
    • Anpassung der Größe oder Umwandlung hochgeladener Bilder
    • Kodierung oder Transkodierung von Videos
    • Versand von Push-Benachrichtigungen
    • Erstellung von Massendatenexporten
    • Bearbeitung eingehender webhook-Payloads
    • Ausführung aufwändiger numerischer oder analytischer Berechnungen

    Im Allgemeinen sollte der HTTP-Anfragen-Handler nur die minimale Menge an Arbeit leisten, die zur Erstellung einer Antwort erforderlich ist.

    13. Sichere Wiederholung von Hintergrundaufgaben

    Durch das Einbringen der Arbeit in eine Warteschlange entsteht eine neue Frage:

    Was passiert, wenn der Worker selbst während der Ausführung fehlschlägt?

    Betrachten Sie diesen Ablauf:

    Worker receives payment notification
            ↓
    Starts processing
            ↓
    Crashes
    

    Das Warteschlangensystem wird voraussichtlich den Vorgang automatisch erneut versuchen.

    Das ist im Allgemeinen eine gute Sache.

    Aber wenn der Vorgang nicht idempotent ist, kann das erneute Versuchen dieselben Nebeneffekte ein zweites Mal auslösen.

    Zum Beispiel:

    Send payment
    Send payment again
    

    oder:

    Create order
    Create duplicate order
    

    Deshalb müssen Hintergrundaufgaben mit folgenden Elementen erstellt werden:

    • Erneute Versuche
    • Backoff-Strategien
    • Bearbeitung von „Dead-Letter“-Nachrichten
    • Idempotenz
    • Maximale Anzahl an Erneutversuchen
    • Überwachung

    Ohne Idempotenz kann ein Mechanismus zur Erneutversuche einen einzigen Fehler in mehrere unerwünschte Nebeneffekte verwandeln.

    14. Aufhören, sensible Informationen zu protokollieren

    Beim Beheben eines Fehlers in der Produktion ist es verlockend, alles zu protokollieren, was man in die Hände bekommt.

    Ein gängiges Beispiel:

    console.log(req.body);
    

    Der Anfragekörper könnte leicht Elemente wie folgende enthalten:

    Password
    Access token
    Credit-card information
    Personal information
    

    Sobald das geschieht, speichert Ihr Protokollierungssystem sensible Daten. Ein sichererer Ansatz ist es, nur die Felder zu protokollieren, die Sie tatsächlich benötigen:

    logger.info({
      userId,
      requestId,
      endpoint: req.path
    }, "Login attempt");
    

    Fallen Sie nicht in die Falle zu glauben:

    "Es befindet sich nur in den Protokollen."

    Protokolle bleiben oft wochen-, monatelang oder sogar länger bestehen und werden häufig an Orte wie folgende kopiert:

    • Cloud-basierte Protokollierungssysteme
    • Überwachungsplattformen
    • SIEM-Systeme
    • Backupspeicher
    • Entwickler-Dashboards

    Das Beheben eines Problems in der Produktion sollte nicht dazu führen, dass ein separates Sicherheitsproblem entsteht.

    15. Fügen Sie Request-IDs hinzu, bevor Sie sie benötigen

    Wenn in der Produktion etwas ausfällt, betrifft das Versagen oft mehrere Systeme:

    Client
      ↓
    Nginx
      ↓
    Node.js API
      ↓
    Redis
      ↓
    Database
      ↓
    Payment API
    

    Ohne eine Möglichkeit, diese Ereignisse miteinander zu verknüpfen, kann die Rekonstruktion dessen, was geschehen ist, ein langsamer und schwieriger Prozess sein.

    Die Lösung besteht darin, frühzeitig eine Anfrage-ID zu erzeugen:

    request_id = 7f4e9c21
    

    und sie anschließend jeder relevanten Protokollierung oder Antwort zuzuweisen:

    API log
    Database-related log
    Payment log
    Error response
    

    Zum Beispiel:

    {
      "requestId": "7f4e9c21",
      "method": "POST",
      "path": "/orders",
      "status": 500
    }
    

    Mit dieser Vorgehensweise kann man eine Anfrage verfolgen, während sie durch das gesamte System läuft.

    Sollte also ein Kunde melden:

    "Meine Bestellung ist fehlgeschlagen."

    kann man einfach nach seiner Anfrage-ID fragen, anstatt durch Millionen von Protokollierungseinträgen zu wühlen, um Zeitstempel abzugleichen.

    16. Verhindern, dass doppelte Anfragen doppelte Daten erzeugen

    Netzwerkprobleme kommen ständig vor, und Clients müssen damit umgehen können.

    Stellen Sie sich diese Abfolge vor:

    Client
      ↓
    POST /orders
      ↓
    Server creates order
      ↓
    Response lost
    

    Der Client hat keine Möglichkeit, herauszufinden, ob die Anfrage tatsächlich erfolgreich war.

    Deshalb versucht er natürlich, es erneut:

    POST /orders
      ↓
    Server creates another order
    

    Das Ergebnis:

    Order #1001 → original request
    Order #1002 → retry
    

    Genau dieses Szenario soll durch Idempotenzschlüssel gelöst werden.

    Der Client fügt einen Header wie folgt hinzu:

    Idempotency-Key: 5e7b2d91-...
    

    Der Server merkt sich das Ergebnis, das mit diesem Schlüssel verbunden ist. Wenn derselbe Schlüssel erneut auftaucht, gibt er einfach das ursprüngliche Ergebnis zurück, anstatt eine weitere Ressource zu erstellen.

    Dies ist besonders wichtig für Operationen wie:

    Payments
    Orders
    Subscriptions
    Account creation
    Resource provisioning
    

    Jede Produktions-API sollte unter der Annahme entworfen werden, dass Cliente Anfragen wiederholen, manchmal mehrmals.

    17. Verwenden Sie Transaktionen, wenn die Daten konsistent bleiben müssen

    Betrachten Sie eine solche Abfolge:

    Create order
    Decrease inventory
    Create payment record
    

    Stellen Sie sich nun vor, einer dieser Schritte bricht mitten im Ablauf ab:

    Create order → SUCCESS
    Decrease inventory → SUCCESS
    Create payment record → FAIL
    

    Ihre Datenbank enthält nun eine unvollendete Operation.

    Immer dann, wenn mehrere Schreibvorgänge in der Datenbank als Einheit erfolgreich sein müssen, sollten sie in eine Transaktion eingebettet werden.

    Die Idee sieht ungefähr so aus:

    await db.transaction(async (transaction) => {
      const order = await createOrder(transaction);
    
      await decreaseInventory(transaction);
    
      await createPaymentRecord(transaction);
    });
    

    Und wenn ein Schritt einen Fehler auslöst:

    Rollback
    

    wird alles rückgängig gemacht, anstatt inkonsistente Datensätze zurückzulassen.

    Transaktionen beheben nicht jedes Problem, dem man in einem verteilten System begegnet, aber innerhalb einer einzigen Datenbank sind sie eines der zuverlässigsten Werkzeuge, um die Datenkonsistenz aufrechtzuerhalten.

    18. Lassen Sie nicht zu, dass eine langsame Abfrage die Datenbank lahmlegt

    Eine schlecht geschriebene Abfrage kann einen unverhältnismäßig großen Teil der Datenbankkapazität in Anspruch nehmen.

    Nehmen Sie dieses Beispiel:

    SELECT *
    FROM orders
    WHERE customer_id = ?
    ORDER BY created_at DESC;
    

    Falls customer_id keinen Index hat und die Tabelle Millionen von Zeilen enthält, kann diese Abfrage dazu führen, dass die Datenbank weitaus mehr Arbeit leisten muss, als nötig wäre.

    Nutzen Sie

    EXPLAIN
    

    um wichtige Abfragen zu überprüfen.

    Achten Sie auf Warnsignale wie:

    Full table scan
    Huge number of rows examined
    Unexpected joins
    Poor index usage
    Expensive sorting
    

    Basieren Sie Ihre Optimierungen dann auf dem, was die tatsächlichen Erkenntnisse zeigen.

    Verlassen Sie sich nicht auf Vermutungen.

    Eine Abfrage, die lokal in 50 Millisekunden ausgeführt wird, kann bei der Verarbeitung von Daten im Produktionsumfeld mehrere Sekunden in Anspruch nehmen.

    19. Ressourcenlimits festlegen

    Ein Produktivserver benötigt Grenzen.

    Sonst kann eine einzige bösartige oder fehlerhafte Anfrage sowie ein außer Kontrolle geratener Prozess alle verfügbaren Ressourcen aufbrauchen.

    Überlegen Sie, Limits für Dinge wie folgende festzulegen:

    Request body size
    File upload size
    Database connections
    HTTP request duration
    Cache size
    Queue size
    Worker concurrency
    Pagination size
    Memory usage
    

    Zum Beispiel:

    app.use(express.json({
      limit: "1mb"
    }));
    

    und:

    const limit = Math.min(
      Number(req.query.limit) || 50,
      100
    );
    

    Es gibt keine universelle Zahl, die in jedem Fall passt.

    Aber das zugrundeliegende Prinzip gilt immer.

    Jeder Ressource sollte eine sinnvolle Obergrenze gesetzt werden.

    Etwas unbegrenzt zu lassen, ist in der Produktion selten eine gute Entscheidung.

    20. Überwachen Sie vor dem Incident, nicht danach

    Es wird Ihnen nicht möglich sein, jeden Ausfall zu verhindern, der jemals auftritt.

    Was Sie kontrollieren können, ist, wie schnell Sie einen Ausfall bemerken und wie schnell Sie wiederherstellen.

    Zumindest sollten Sie folgendes im Auge behalten:

    HTTP 5xx rate
    Request latency
    CPU
    Memory
    Database connections
    Database latency
    Event-loop health
    Queue depth
    External API failures
    Restart count
    

    Zum Beispiel:

    09:00 → 0.2% errors
    09:10 → 0.3%
    09:20 → 0.5%
    09:30 → 4.8% 🚨
    

    Falls niemand diese Werte überwacht, erfahren Sie erst von einem Problem, wenn die Nutzer anfangen zu klagen.

    Eine solide Alarmierung ändert die Situation von:

    "Die Kunden melden uns, dass die API nicht funktioniert.

    zu:

    "Unsere 5xx-Rate hat vor fünf Minuten die Schwellenwertgrenze überschritten."

    Dieser Unterschied im Antwortzeitraum macht wirklich einen Unterschied.

    Muster hinter den meisten dieser Lösungen

    Wenn man all diese Lösungen betrachtet, zeigt sich ein gemeinsames Muster.

    Toxische Node.js-Systeme funktionieren nicht unter der Annahme, dass alles reibungslos ablaufen wird.

    Sondern sie sind so konzipiert, dass man davon ausgeht, dass:

    Requests will fail.
    Databases will become slow.
    Networks will timeout.
    Clients will retry.
    Dependencies will go offline.
    Traffic will spike.
    Deployments will restart processes.
    Developers will make mistakes.
    

    und Schutzmaßnahmen werden für jede dieser Annahmen eingeführt.

    Die resultierende Architektur sieht in der Regel so aus:

                        ┌───────────────┐
                        │     Client    │
                        └───────┬───────┘
                                │
                                ▼
                        ┌───────────────┐
                        │ Reverse Proxy │
                        │ Rate Limiting │
                        └───────┬───────┘
                                │
                                ▼
                      ┌───────────────────┐
                      │    Node.js API    │
                      │                   │
                      │ Validation        │
                      │ Timeouts          │
                      │ Error Handling    │
                      │ Request IDs       │
                      └───────┬───────────┘
                              │
                 ┌────────────┼────────────┐
                 ▼            ▼            ▼
            ┌────────┐   ┌────────┐   ┌─────────┐
            │Database│   │ Redis  │   │ Queue   │
            └────────┘   └────────┘   └────┬────┘
                                          │
                                          ▼
                                   ┌──────────────┐
                                   │Worker / Jobs │
                                   └──────────────┘
    

    Jede Schicht hat eine klare Aufgabe.

    Jede externe Abhängigkeit verfügt über eine definierte Fehlerbehandlungsstrategie.

    Jede aufwändige Operation ist durch eine Grenze begrenzt.

    Jede sinnvolle Aktion kann beobachtet und gemessen werden.

    Genau diese Kombination verleiht einem System Widerstandsfähigkeit.

    Die wichtigste Lektion

    Die größten Verbesserungen in Bezug auf Zuverlässigkeit entstehen nicht immer durch den Zusatz weiterer Infrastrukturen.

    Mannchmal liegt die Lösung in einer einzigen geänderten Codezeile.

    Mannchmal besteht sie darin, einen fehlenden Index hinzuzufügen.

    Mannchmal geht es darum, einen Aufruf einer externen API mit einem Timeout zu umgeben.

    Mannchmal besteht sie darin, 1.001 Datenbankabfragen in zwei umzuwandeln.

    Mannchmal geht es darum, eine Reihe von Schreibvorgängen in einer Transaktion zu bündeln.

    Mannchmal reicht es aus, sicherzustellen, dass der Prozess sauber beendet wird.

    Was eine instabile API von einer zuverlässigen unterscheidet, liegt in der Regel in einer Reihe kleiner ingenieurtechnischer Entscheidungen.

    Niemand von ihnen allein wirkt besonders bemerkenswert.

    Aber zusammen verhindern sie, dass ein gewöhnlicher Betriebsablauf zu einer Notfallsituation wird.

    Deshalb sollten Sie beim nächsten Mal, wenn Ihre Node.js-API in der Entwicklung genau so funktioniert wie erwartet, nicht bei der Frage innehalten:

    "Funktioniert es?"

    Fragen Sie stattdessen:

    "Was passiert, wenn es nicht funktioniert?"

    Diese Frage markiert den wahren Ausgangspunkt für die Zuverlässigkeit in der Produktion.

    Und die beste Art von Ausfall ist eine, die Ihre Nutzer gar nicht bemerken, weil Sie den Fehler bevor er zu einem Ausfall wird bereits erkannt und behoben haben.