Deine Webanwendung antwortet bei zehn gleichzeitigen Nutzern schnell, doch nach dem nächsten Wachstumsschub häufen sich Timeouts. Mein Verdacht wäre nicht zuerst ein zu kleiner Server: Bei typischen CRUD-Anwendungen bricht oft der Weg zur Datenbank früher als die Rechenleistung der App, weil zusätzliche Instanzen auch zusätzliche Verbindungen und konkurrierende Abfragen erzeugen. Wer dann nur weitere Instanzen startet, kann den Engpass verschärfen.
Mehr App-Instanzen können die Datenbank zuerst überlasten
Stell dir einen Dienst mit vier Node.js-Instanzen vor. Jede hält über einen PostgreSQL-Client-Pool höchstens 20 Verbindungen bereit. Nach einem erfolgreichen Launch erhöht das Team die Zahl der Instanzen auf zwölf. Aus theoretisch 80 möglichen Datenbankverbindungen werden damit 240. PostgreSQL nennt für max_connections einen Standardwert von 100; das ist ein vom Projekt dokumentierter Ausgangswert und kein sinnvoller Zielwert für jede Installation. Schon bevor alle Pools voll sind, brauchen auch Migrationen, Administrationszugriffe und andere Dienste Platz.
Die erste sichtbare Störung ist deshalb häufig kein Absturz. Einzelne Requests warten zunächst auf einen freien Platz im App-Pool. Andere erhalten erst später eine Datenbankverbindung und konkurrieren dort um dieselben Tabellen oder Sperren. Ein höherer Pool-Grenzwert hilft in dieser Lage nicht automatisch, weil er die Zahl gleichzeitig ausgeführter Abfragen erhöht, ohne deren Arbeit zu verkürzen. Ob genau das bei deinem Dienst passiert, prüfst du, statt es aus einem Timeout abzuleiten.
Sieh dir zuerst die Zahl aktiver und wartender Requests, die Pool-Auslastung sowie PostgreSQLs pg_stat_activity an. Steigt die Wartezeit auf eine Verbindung, während die CPU der App ruhig bleibt, spricht das gegen die Annahme, dass mehr App-Rechenleistung das Problem löst. pg_stat_statements hilft danach, Abfragen mit hoher Gesamtlaufzeit zu finden; eine Abfrage, die einzeln harmlos wirkt, kann unter hoher Aufruffrequenz den größten Anteil der Datenbankzeit verbrauchen. Für eine verdächtige Abfrage zeigt EXPLAIN (ANALYZE, BUFFERS) tatsächliche Ausführungszeiten und gelesene Blöcke. Führe diese Analyse bei teuren Statements zunächst in einer geeigneten Testumgebung aus, weil ANALYZE die Abfrage wirklich ausführt.
Ich lese Moderne Webentwicklung fuer skalierbare Softwareprojekte gern als Anlass für eine unbequeme Ergänzung: Eine App ist nicht schon deshalb skalierbar, weil sich ihre Instanzen vervielfachen lassen. Jede Instanz nutzt gemeinsame Ressourcen, und genau dort kann der nächste Grenzwert liegen. Für dich als Junior ist das eine nützliche Änderung der Fragestellung: Nicht „Wie viele Replikate brauchen wir?“, sondern „Welche Ressource beansprucht ein zusätzliches Replikat?“
Ein schneller Durchschnitt verdeckt den wartenden Request
Wenn im Dashboard eine durchschnittliche Antwortzeit von 120 Millisekunden steht, können trotzdem Nutzer auf langsame Ausreißer treffen. Der Durchschnitt verrechnet kurze Antworten mit langen Wartezeiten und verrät dir nicht, wie groß die langsame Gruppe ist. Beobachte daher zusätzlich das 95. Perzentil, also p95, und die Fehlerrate. Ein p95 von 400 Millisekunden ist hier ein zu prüfender Schwellenwert, kein allgemeines Versprechen für jede Anwendung.
Trenne die Wartezeit nach Möglichkeit in Stationen. OpenTelemetry kann Spans für den eingehenden HTTP-Request und für Datenbankaufrufe erzeugen; W3C Trace Context trägt den Trace-Zusammenhang über Service-Grenzen. Du musst dafür nicht sofort jeden Dienst instrumentieren. Beginne mit einem Request-Pfad, der unter Last auffällt, und vergleiche seinen gesamten Span mit dem Datenbank-Span. Ist die Differenz groß, untersuche auch Pool-Wartezeit, externe Aufrufe und Arbeit im Prozess, statt die Datenbank allein verantwortlich zu machen.
Prometheus kann Latenzen aus einem HTTP-Histogramm auswerten. Achte dabei auf die Buckets: Liegen die für dich relevanten Antwortzeiten zwischen weit auseinanderliegenden Bucket-Grenzen, ist das daraus berechnete p95 nur grob. Schau zusätzlich auf die absolute Zahl von Fehlern und Requests. Eine Fehlerrate von einem Prozent hat bei wenigen Testaufrufen eine andere Aussagekraft als bei einem langen Lauf mit gleichmäßigem Verkehr, weil einzelne Ausreißer kleine Stichproben stark verändern.
Ein kurzer Lasttest macht die Diskussion konkreter. Speichere das folgende Skript als smoke.js und starte es mit k6 run -e URL=http://localhost:3000/api/items smoke.js, nachdem du die URL auf einen vorhandenen, lesenden Endpunkt gesetzt hast. Die zehn virtuellen Nutzer und 30 Sekunden sind bewusst kleine Testvorgaben: Sie zeigen offensichtliche Probleme, beweisen aber keine Produktionskapazität.
import http from 'k6/http';
import { check } from 'k6';
export const options = {
vus: 10,
duration: '30s',
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<400'],
},
};
export default function () {
const res = http.get(__ENV.URL);
check(res, { 'kein Serverfehler': r => r.status < 500 });
}
Prüfe bei der Auswertung beide Signale: Die k6-Metrik http_req_failed bewertet fehlgeschlagene HTTP-Anfragen, während der zusätzliche Check ausdrücklich Antworten ab Status 500 markiert. Ein HTTP 404 ist für einen falsch gewählten Testpfad ebenfalls ein Fehler deines Tests, auch wenn dieser Check ihn nicht als Serverfehler bezeichnet. Übernimm die Schwellenwerte erst in eine Pipeline, wenn Endpunkt, Testdaten und Lastprofil feststehen; sonst automatisierst du eine Zahl ohne Bezug zum Nutzerverhalten.
Wiederholungsversuche verwandeln kurze Störungen in längere
Angenommen, eine Anfrage wartet auf eine Datenbankverbindung und läuft in ein Timeout. Der Client versucht es erneut, während die ursprüngliche Last noch nicht abgearbeitet ist. Wiederholen mehrere Clients dasselbe sofort, treffen zusätzliche Requests auf den bereits belasteten Dienst. Der erste Fehler kann damit eine Kette weiterer Fehler auslösen, selbst wenn der ursprüngliche Engpass nur kurz gedauert hätte.
Unterscheide deshalb zwischen einem sicheren Leseaufruf und einer Aktion mit Nebenwirkung. Bei einem GET kann ein begrenzter Retry nach einem Verbindungsfehler sinnvoll sein, sofern der Client zwischen Versuchen wartet. Bei einem POST, der eine Bestellung anlegt, riskierst du dagegen doppelte Ausführung, wenn die Antwort verloren ging, der Server die Aktion aber abgeschlossen hat. Ein Idempotency-Key kann den zweiten Fall absichern, wenn der Server den Schlüssel samt Ergebnis zuverlässig speichert und gleiche Schlüssel nicht erneut verarbeitet. Der HTTP-Status 429, definiert in RFC 6585, signalisiert Überlast durch zu viele Anfragen; ein sinnvoll gesetzter Retry-After-Header gibt Clients einen Zeitpunkt für den nächsten Versuch.
Ich würde nicht pauschal drei sofortige Retries für jeden fehlgeschlagenen Request einschalten, weil genau diese zusätzlichen Versuche einen ausgelasteten Pool weiter füllen und Schreiboperationen doppelt auslösen können. Lege stattdessen pro Aufruf fest, ob ein Retry sicher ist, welche Fehler ihn rechtfertigen und wie viele Versuche das gesamte Zeitbudget des Nutzers zulässt. Zufällig variierte Wartezeiten vermeiden, dass viele Clients nach exakt derselben Pause wieder gleichzeitig anfragen.
Setze außerdem ein Limit dafür, wie lange ein Request überhaupt auf eine Ressource warten darf. Ein begrenzter Pool mit kurzen, beobachtbaren Wartezeiten ist oft ehrlicher als eine lange interne Warteschlange: Die Warteschlange verbirgt Überlast zunächst vor dem Aufrufer, verlängert aber die Antwortzeit bis zum Timeout. Diese Grenze ist keine Zahl zum Abschreiben. Sie hängt davon ab, wie lange der aufrufende Client wartet und ob nach Ablauf noch sinnvolle Arbeit geleistet werden kann.
Ein Connection-Pooler gewinnt nur, wenn du seine Kosten akzeptierst
Für den Engpass bei Datenbankverbindungen gibt es zwei konkrete Optionen: direkte PostgreSQL-Verbindungen aus den App-Pools oder PgBouncer mit pool_mode=transaction. Direkte Verbindungen gewinnen bei wenigen App-Instanzen und Verbindungszahlen unterhalb des Datenbanklimits, weil das Betriebsmodell einfacher bleibt und eine App-Sitzung ihre Datenbanksitzung behält. Ihr Preis ist die steigende Zahl möglicher Verbindungen, sobald jede neue Instanz ihren eigenen Pool mitbringt.
PgBouncer im Transaction-Pooling gewinnt, wenn viele kurzlebige Transaktionen über relativ wenige Datenbankverbindungen laufen können. Sein Preis sind ein weiterer zu betreibender Dienst und veränderte Sitzungssemantik: Nach einer Transaktion kann die nächste auf einer anderen Serververbindung landen. Code, der sich über Transaktionsgrenzen hinweg auf sitzungsgebundenen Zustand verlässt, muss dann geprüft oder geändert werden. PgBouncer ist deshalb keine Reparatur für eine langsame SQL-Abfrage; auch über weniger Verbindungen bleibt dieselbe Abfrage langsam.
Oft ist die günstigere erste Änderung noch kleiner. Zeigt pg_stat_statements eine oft ausgeführte Abfrage und bestätigt EXPLAIN (ANALYZE, BUFFERS) unnötig viele gelesene Zeilen, kann ein passender Index die Datenbankzeit stärker senken als ein zusätzlicher Dienst. Ein Index kostet allerdings Speicher und Arbeit bei Schreibvorgängen, weshalb du ihn anhand des tatsächlichen Abfrageplans auswählst und danach erneut misst. Ebenso kann eine N+1-Abfrage im ORM aus einem Seitenaufruf viele Datenbankaufrufe machen; eine gezielte Sammelabfrage reduziert dann die Zahl der Pool-Belegungen, ohne das Pool-Limit zu erhöhen.
Ich widerspreche Skalierung von IT-Start-ups: Herausforderungen und Lösungen nicht beim Ziel, Wachstum technisch tragfähig zu machen. Ich würde nur die Reihenfolge strenger wählen: erst den Engpass einem Request zuordnen, dann die kleinste Änderung daran testen und erst danach neue Infrastruktur betreiben. So kannst du im Review begründen, warum ein Index, ein kleinerer App-Pool oder PgBouncer zu genau diesem Lastbild passt.
Dein nächster Schritt ist ein einzelner belastbarer Request-Pfad
Nimm morgen einen langsamen, häufig genutzten Endpunkt und notiere seine p95-Latenz, Fehlerrate sowie die Zahl offener Datenbankverbindungen während eines kurzen Lasttests. Vergleiche anschließend einen Trace mit pg_stat_activity: Wartet der Request vor der Abfrage, während der Abfrage oder erst danach? Ändere genau eine Sache und wiederhole denselben Test. Diese kleine Messreihe gibt dir für die nächste Skalierungsentscheidung mehr Halt als ein weiteres Replikat auf Verdacht.


