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.
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.allbietet 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
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
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-mapein, um die Parallelisierung einzuschränken - Führen Sie
Bottleneckein, 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.