Startseite / Artikel

Node.js-Konkurrenz in den Griff bekommen: API-Ausfälle mit p-map und Engpässen vermeiden

Unbegrenzter Use von Promise.all kann APIs überlasten. Kombinieren Sie p-map und Bottleneck in Node.js, um die Konkurrenz zu begrenzen, die Rate-Limiting-Vorgaben einzuhalten und die nachgelagerten Systeme stabil zu halten.

Verlag: REACTAPP.TOP5 Min. Lesezeit1240 Wörter

Quellen: Stop Melting Your APIs: Smart Concurrency Control in Node.js with p-map & Bottleneck

Die meisten Node.js-Anwendungen kollabieren nicht, weil die Logik falsch ist. Sie kollabieren, weil sie zu aggressiv und gleichzeitig versuchen, Aufgaben auszuführen.

Stellen Sie sich folgendes Szenario vor: Ein Team bringt eine Funktion heraus, die innerhalb weniger Sekunden etwa 10.000 API-Aufrufe tätigt. Auf einem lokalen Rechner läuft alles reibungslos und schnell, und die Ergebnisse wirken beeindruckend.

Sobald derselbe Code in die Produktion kommt, ändert sich das Bild:

  • Überall tauchen 429-Fehler auf
  • Die externe API beginnt, Anfragen abzulehnen
  • Der Speicherverbrauch steigt stark an
  • Die Wiederholungslogik verschlimmert die Situation statt sie zu verbessern

Das eigentliche Problem lag nie bei der externen API selbst. Es war die Konkurrenz ohne jegliche Beschränkungen.

Die Falle, die sich in Promise.all verbirgt

Viele Entwickler greifen zuerst zu diesem Muster:

await Promise.all(urls.map(fetchData));

Es liest sich klar und scheint leistungsstark zu sein.

Aber schauen Sie sich genauer an, was tatsächlich ausgelöst wird:

  • Jeder Anfrage wird gleichzeitig ausgeführt
  • Es gibt keine Obergrenze dafür, wie viele gleichzeitig laufen
  • Es gibt kein Mechanismus zur Rückschaltung des Durchflusses
  • Von der API festgelegte Ratelimits werden völlig ignoriert

In der Praxis verhält sich dieses Muster wie ein Generator plötzlicher Traffic-Wellen.

Promise.all bietet keine Kontrolle über die Konkurrenz. Es führt vielmehr zu einem Konkurrenzzusammenbruch.

Schritt eins: Konkurrenz mit p-map begrenzen

Falls Ihr Ziel darin besteht, zu beschränken, wie viele Operationen parallel ausgeführt werden, ist p-map das dafür geeignete Werkzeug.

import pMap from 'p-map';

const result = await pMap(
  urls,
  async (url) => {
    const res = await fetch(url);
    return res.json();
  },
  { concurrency: 5 }
);

Was dadurch behoben wird

  • Immer nur fünf Anfragen laufen zu jedem Zeitpunkt parallel
  • Die übrigen warten in einer Warteschlange, bis ein Platz frei wird
  • Der CPU- und Speicherverbrauch bleibt konstant
  • Die zugrundeliegende Idee

    Konkurrenzverarbeitung geht nicht wirklich darum, mehr Geschwindigkeit herauszuholen. Es geht vielmehr darum, den Druck auf das System unter Kontrolle zu halten.

    Warum das allein nicht ausreicht

    Die Einstellung concurrency: 5 garantiert nicht, dass Sie Rate Limits vermeiden können.

    APIs überwachen nicht nur, wie viele Anfragen gleichzeitig eintreffen, sondern auch, wie viele innerhalb eines bestimmten Zeitraums eingehen.

    Das bedeutet, Sie müssen nicht nur die Anzahl der parallelen Aufrufe steuern, sondern auch die Zeit.

    Schritt zwei: Rate Limits mit Bottleneck durchsetzen

    import Bottleneck from "bottleneck";
    
    const limiter = new Bottleneck({
      maxConcurrent: 5,
      minTime: 200 // ~5 requests per second
    });
    const fetchWithLimit = limiter.wrap(async (url) => {
      const res = await fetch(url);
      return res.json();
    });
    const results = await Promise.all(urls.map(fetchWithLimit));
    

    Was damit erreicht wird

    • Die Konkurrenzverarbeitung bleibt begrenzt
    • Anfragen werden über die Zeit verteilt
    • Ausbrüche von hohen Anfragen werden verhindert

    Anstatt die API auf einmal mit vielen Anfragen zu überlasten, liefern Sie ihr nun Anfragen in einem gleichmäßigen, vorhersehbaren Tempo.

    p-map gegen Engpässe

    Jedes Tool löst ein anderes Problem:

    • p-map kümmert sich um den Fall, dass zu viele Versprechen gleichzeitig ausgeführt werden
    • Bottleneck kümmert sich um den Fall, dass die API-Grenze zu 429-Fehlern führt
    • Falls Sie beide Schutzmechanismen benötigen, kombinieren Sie die beiden Bibliotheken

    Ein Muster, das in der Produktion nützlich ist

    import pMap from 'p-map';
    import Bottleneck from 'bottleneck';
    
    const limiter = new Bottleneck({
      minTime: 200
    });
    const task = limiter.wrap(async (url) => {
      const res = await fetch(url);
      return res.json();
    });
    await pMap(urls, task, { concurrency: 5 });
    

    Diese Kombination bietet:

    • Stabile, begrenzte Konkurrenz
    • Schutz vor Überschreitung von Rate Limits
    • Ein Lastprofil, das im Voraus vorhergesagt werden kann

    Umgang mit Fehlern: Wiederholte Versuche mit Verzögerung

    Auch ein sorgfältig gesteuertes System stößt gelegentlich auf Fehler. Was ein widerstandsfähiges System von einem anfälligen unterscheidet, ist die Art und Weise, wie es auf diese Fehler reagiert.

    async function fetchWithRetry(url, retries = 3) {
      try {
        const res = await fetch(url);
        if (!res.ok) throw new Error('Failed');
        return res.json();
      } catch (err) {
        if (retries === 0) throw err;
    
    // exponential backoff
        const delay = 2 ** (3 - retries) * 100;
        await new Promise(r => setTimeout(r, delay));
        return fetchWithRetry(url, retries - 1);
      }
    }
    

    Warum Verzögerung hier wichtig ist

    • Wiederholte Versuche ohne Verzögerung führen oft zu Wiederholungsstürmen
  • Durch erneute Versuche mit Zeitverzögerung kann das System stattdessen sanft wiederhergestellt werden
  • Der Kompromiss zwischen Durchsatz und Latenz

    Genau hier kommen echte Entscheidungen bei der Systemarchitektur ins Spiel.

    • Niedrigere Konkurrenz führt zu einem sichereren, aber langsameren Ablauf
    • Höhere Konkurrenz beschleunigt die Prozesse, birgt aber mehr Risiken

    Zum Beispiel:

    • Eine Konkurrenz von 2 hält die Prozesse stabil, ist jedoch langsam
    • Eine Konkurrenz von 20 beschleunigt die Ausführung, kann aber die Ratelimits überschreiten

    Es gibt keine universell ideale Einstellung. Sie müssen den Wert anpassen, je nachdem, was Ihr System und die Ziel-API tatsächlich bewältigen können.

    Beobachtbarkeit: Der Schritt, den die meisten Teams überspringen

    Setzen Sie diese Konfiguration nicht einfach ein und ignorieren Sie sie in der Produktion.

    Achten Sie auf:

    • wie groß die Warteschlange wird
    • die durchschnittliche Wartezeit der Anfragen
    • das Verhältnis von Erfolgen zu Fehlern

    Ohne Einblicke in das, was Ihr System tatsächlich tut, wird eine korrekte Anpassung zu einem Ratenspiel.

    Wo passen Warteschlangen wie BullMQ oder Kafka hin?

    Das ist eine berechtigte Frage.

    • Messaging-Warteschlangen bieten zuverlässige, asynchrone Verarbeitung
    • p-map und Bottleneck bieten Prozessinterne Steuerung innerhalb eines einzigen Services

    wenden Sie eine Warteschlange an, wenn:

    • Aufgaben auch nach einem Absturz oder Neustart weiterlaufen müssen
    • die Arbeitslast groß angelegt ist oder sich über mehrere Services erstreckt

    wenden Sie den Ansatz mit p-map und Bottleneck an, wenn:

    • Sie eine schnelle, kontrollierte Ausführung benötigen, die innerhalb eines Services bleibt

    Messbarer Einfluss nach Anwendung davon

    Nach der gleichzeitigen Einführung von Konkurrenzbeschränkungen und Geschwindigkeitsbegrenzungen zeigte ein Produktivsystem folgende Verbesserungen:

    • Die Fehlerrate 429 sank von etwa 18 % auf unter 1 %
    • Eine deutlich verbesserte Stabilität bei der Kommunikation mit der externen API
    • Ein vollständiges Ende von Wiederholungsversuchen

    Die Anwendungslogik blieb unverändert. Was sich änderte, war die Disziplin bei der Ausführung.

    Fazit

    Asynchronen Code zu schreiben ist eine grundlegende Fähigkeit für Node.js-Entwickler. Zu wissen, wie man die Ausführungsgeschwindigkeit dieses Codes steuert, unterscheidet starke Backend-Entwickler.

    Falls Ihr Service mit folgenden Komponenten kommuniziert:

    • externen APIs
    • Datenbanken
    • anderen Microservices

    dann wird die Konkurrenzsteuerung nicht mehr nur von Vorteil, sondern zu einem zentralen Bestandteil Ihrer Architektur.

    Zusammenfassung

    Falls Ihr Produktionscode weiterhin ohne jegliche Beschränkungen auf Promise.all angewiesen ist, sind Sie nur einem plötzlichen Anstieg der Nutzerzahlen von einem Ausfall entfernt.

    Ein praktischer Ausgangspunkt sieht so aus:

    • Führen Sie p-map ein, um die Parallelisierung einzuschränken
    • Führen Sie Bottleneck ein, um Datentransferraten zu begrenzen
    • Fügen Sie eine Wiederholungslogik hinzu, um Belastbarkeit zu schaffen

    Dies ist der Weg von Code, der lediglich funktioniert, zu Code, der tatsächlich skaliert.