---
title: "Skalierbarkeit: Der Engpass ist fast nie die CPU"
description: "Warum die CPU bei 30 Prozent liegt, während Ihr Dienst in Timeouts läuft: das Auslastungsknie, Connection Pools, ephemere Ports, Coordinated Omission im Lasttest und die Befehle, die den echten Engpass finden."
author: Aleksei Aleinikov
date: 2026-09-11
lang: de
tags: [skalierbarkeit, load-balancing, verteilte-systeme, connection-pool, backpressure, warteschlangentheorie]
canonical: https://www.alekseialeinikov.com/de/blog/topics/architecture/skalierbarkeit-der-engpass-ist-selten-die-cpu-2026
source: alekseialeinikov.com
---

# Skalierbarkeit: Der Engpass ist fast nie die CPU

Ihr Dashboard zeigt 28 Prozent CPU. Ihre p99-Latenz liegt bei vier Sekunden. Jemand schlägt vor, mehr Pods zu starten.

Das ist die häufigste Fehlabbiegung bei Skalierungsarbeit, und sie folgt aus einer nachvollziehbaren Annahme: dass ein langsames System ein ausgelastetes System sein müsse. Meistens ist es das nicht. Es ist ein System, in dem Anfragen auf etwas warten, das keine CPU ist — und zusätzliche Instanzen machen es oft schlimmer.

Hier steht, was Sie tatsächlich begrenzt, und die Rechnung, mit der Sie es finden.

![Skalierbarkeits-Engpässe: warum die CPU untätig ist, während Anfragen anderswo warten.](https://www.alekseialeinikov.com/blog/scalability-bottleneck-2026.webp)

## Eine Formel, die fast alles dimensioniert

Littles Gesetz ist das Nützlichste in der Kapazitätsplanung und passt in eine Zeile:

```
Nebenläufigkeit = Ankunftsrate x Latenz

L = λ × W
```

Treffen 500 Anfragen pro Sekunde ein und verbringt jede 40 ms in der Datenbank, brauchen Sie zu jedem Zeitpunkt:

```
500 Anf./s × 0,04 s = 20 gleichzeitige Datenbankverbindungen
```

Zwanzig. Nicht zweihundert.

Jetzt rückwärts — dort verdient die Formel ihr Geld. Sie haben einen Pool mit **20 Verbindungen**, jede Abfrage dauert **50 ms**:

```
20 / 0,05 s = 400 Anfragen pro Sekunde
```

**Das ist Ihre Decke.** Kein weiches Limit, ein hartes. Sie können vierzig Pods betreiben; der Pool lässt weiterhin 400 Anfragen pro Sekunde durch, alles andere wartet. Die CPU bleibt niedrig, weil die CPU nichts tut. Sie wartet.

Jede Warteschlange in Ihrem System folgt dem. Thread-Pools, Connection-Pools, Worker-Zahlen, In-Flight-Limits. Kennen Sie zwei der drei Größen, kennen Sie die dritte.

## Warum 80 Prozent Auslastung nicht zu 80 Prozent in Ordnung sind

Hier steht der Teil, der erklärt, warum Kapazitätsplanung unintuitiv wirkt.

Bei einer einfachen Warteschlange hängt die Antwortzeit so von der Auslastung ab:

```
Antwortzeit = Bedienzeit / (1 - Auslastung)

R = S / (1 - ρ)
```

Der Nenner ist die ganze Geschichte. Nähert sich die Auslastung der Eins, steigt die Antwortzeit nicht sanft an — sie geht gegen unendlich.

| Auslastung | Antwortzeit gegenüber Leerlauf |
| --- | --- |
| 50% | **2×** |
| 80% | **5×** |
| 90% | **10×** |
| 95% | **20×** |
| 99% | **100×** |

Der Schritt von 50 auf 80 Prozent Auslastung kostet Sie nicht 30 Prozent Ihres Latenzbudgets. Er kostet **150 Prozent** — die Antwortzeit springt vom Doppelten auf das Fünffache der Bedienzeit.

Deshalb halten erfahrene Teams scheinbar verschwenderische Reserven vor. Und deshalb fällt ein System, das gestern bei 70 Prozent stabil lief, heute bei 85 Prozent nach einem moderaten Lastanstieg um. Geändert hat sich nichts außer Ihrer Position auf einer Kurve, die senkrecht wird.

**Die ehrliche Einschränkung:** Diese Formel setzt einen einzelnen Server mit Poisson-Ankünften und exponentialverteilten Bedienzeiten voraus — eine M/M/1-Warteschlange. Reale Systeme haben mehrere Server und stößeren Verkehr, Ihre Konstanten werden also abweichen. Die *Form* aber ist universell: Wartezeit ist hyperbolisch in der Auslastung, nicht linear. Jede Ihrer Warteschlangen hat ein Knie, und es liegt weiter links, als die Intuition nahelegt.

Praktische Folge: **hören Sie auf, Autoscaling-Ziele auf 80 Prozent CPU zu setzen.** Dort sind Sie bereits tief in der Kurve, und die neue Instanz braucht Zeit, bis sie nützt.

## Die Decken, in der Reihenfolge ihres Auftretens

### 1. Datenbankverbindungen

Das häufigste echte Limit — und es hat eine Zahl, die Sie nachschlagen können.

**PostgreSQL erlaubt standardmäßig `max_connections = 100`**, davon sind **drei für Superuser reserviert**. Für Ihre Anwendung bleiben also rund 97.

Zählen Sie nun, was Sie anfordern. Zwölf Pods mit je zehn Verbindungen sind 120 gegen ein Limit von 97. Der zwölfte Pod bekommt Verbindungsfehler, und der Fehler sieht aus wie ein Datenbankproblem statt wie ein Rechenfehler.

Zwei Dinge verschärfen das:

- PostgreSQL **forkt einen Prozess pro Verbindung**, Verbindungen kosten also echten Speicher. Die Dokumentation sagt ausdrücklich, dass ein höheres `max_connections` auch die Shared-Memory-Zuteilung erhöht. Genau dafür gibt es PgBouncer.
- **Ein größerer Pool ist nicht mehr Durchsatz.** Jenseits des Punktes, an dem die Datenbank Abfragen wirklich parallel ausführen kann, bringen zusätzliche Verbindungen Lock-Contention und Context-Switches. Der Durchsatz flacht ab und fällt dann.

MySQL nutzt einen Thread pro Verbindung und ist hier günstiger — einer der [strukturellen Unterschiede zwischen Postgres und MySQL](https://www.alekseialeinikov.com/de/blog/topics/data/postgres-vs-mysql-2026-vergleich), den man vor der Wahl kennen sollte.

### 2. Dateideskriptoren

Jeder Socket, jede offene Datei, jede Verbindung ist ein Dateideskriptor. Das Limit pro Prozess liegt beim Soft-Limit oft bei **1024** — klingt großzügig, bis man merkt, dass es eingehende Verbindungen, ausgehende Verbindungen, Logdateien und Bibliotheks-Interna gleichzeitig abdeckt.

```bash
ulimit -n              # Soft-Limit dieser Shell
cat /proc/<pid>/limits # was der laufende Prozess wirklich hat
ls /proc/<pid>/fd | wc -l   # wie viele er gerade nutzt
```

Der Fehlermodus ist einprägsam: `EMFILE: too many open files`, meist genau unter der Last, bei der man am wenigsten Lust auf einen Stacktrace hat.

### 3. Ephemere Ports und TIME_WAIT

Dieser Punkt überrascht, weil nichts in der Anwendung ihn erwähnt.

Öffnet Ihr Dienst eine ausgehende Verbindung, wählt der Kernel einen Quellport aus dem ephemeren Bereich. Unter Linux:

```bash
cat /proc/sys/net/ipv4/ip_local_port_range
# typischerweise: 32768 60999
```

Das sind rund **28.000 Ports** je eindeutiger Kombination aus Zieladresse und Zielport.

Anschließend hält die schließende Seite den Socket **60 Sekunden im TIME_WAIT** — und unter Linux ist diese Dauer in den Kernel einkompiliert, kein sysctl, das Sie herunterdrehen können.

Rechnen Sie nach. 500 kurzlebige Verbindungen pro Sekunde zum selben nachgelagerten Dienst:

```
500 Verb./s × 60 s = 30.000 Sockets im TIME_WAIT
```

Der Bereich ist aufgebraucht. Neue Verbindungen scheitern, und der Fehler ist ein Connect-Timeout, der aussieht, als sei der andere Dienst ausgefallen.

**Die Lösung ist nicht, den Bereich zu vergrößern.** Sie ist, die Verbindungen gar nicht erst zu öffnen: HTTP-Keep-Alive, Connection-Pooling, HTTP/2-Multiplexing. Wiederverwendung schlägt Bereitstellung.

### 4. Connection Tracking

Läuft Ihr Verkehr über ein NAT-Gateway, eine Firewall oder einen Kubernetes-Node mit iptables, führt der Kernel für jeden Flow einen **conntrack**-Eintrag. Diese Tabelle ist endlich, und wenn sie voll ist, werden Pakete stillschweigend verworfen — kein Fehler, keine Logzeile in Ihrer Anwendung, nur Latenz und Retries.

```bash
sysctl net.netfilter.nf_conntrack_max
sysctl net.netfilter.nf_conntrack_count
```

Der Abstand zwischen diesen beiden Zahlen ist eine Alarmschwelle wert — und fast niemand exportiert sie.

### 5. Threads und Event Loops

Ein Server mit einem Thread pro Anfrage ist ein Pool unter anderem Namen, Littles Gesetz gilt unverändert. Ein Event-Loop-Server hat dieses Limit nicht — was seine eigene Gefahr ist, denn er nimmt bereitwillig zehntausend gleichzeitige Anfragen an und bedient alle langsam, statt einige schnell abzulehnen.

Ein einziger blockierender Aufruf im Event Loop legt jede Anfrage auf diesem Loop lahm. Der CPU-Graph bleibt dabei ruhig.

## Horizontale Skalierung und der gemeinsame Nenner

Zusätzliche Instanzen vervielfachen die Kapazität für den **unabhängigen** Teil einer Anfrage und ändern nichts am geteilten Teil.

Amdahls Gesetz deckelt den Gewinn aus Parallelisierung bei allem, was einen seriellen Anteil hat. Das **Universal Scalability Law** ergänzt den Teil, den die meisten übersehen — und es lohnt sich, ihn auszuschreiben, denn der zweite Term ist der gefährliche:

```
C(N) = N / (1 + σ(N-1) + κN(N-1))

  N  Anzahl der Worker
  σ  Contention  — serieller Anteil, Warten auf geteilte Ressourcen
  κ  Coherency   — Kosten, die Worker untereinander konsistent zu halten
```

Mit κ = 0 wird daraus Amdahls Gesetz: Der Durchsatz flacht ab, fällt aber nie. Mit **κ > 0** passiert etwas Schlimmeres — es gibt ein Maximum, und dahinter **sinkt** der Durchsatz:

```
Maximum bei N* = sqrt((1 - σ) / κ)
```

Der Coherency-Term erklärt, warum zusätzliche Kapazität ein System verlangsamen kann. Er umfasst Cache-Line-Bouncing zwischen Kernen, Lock-Übergaben, Cluster-Gossip, verteilten Konsens und regionsübergreifende Replikation — alles, dessen Kosten mit dem *Quadrat* der Teilnehmerzahl wachsen, weil jeder sich mit jedem abstimmen muss.

Konkret: Wenn jeder neue Pod weitere Verbindungen zur selben Primärdatenbank öffnet, skalieren Sie nicht. Sie verwandeln ein Verbindungslimit in eine Warteschlange und danach in einen Timeout. Wenn jeder neue Knoten mit jedem anderen gossipen muss, haben Sie sich einen κ-Term gekauft.

Fragen Sie vor jeder zusätzlichen Instanz:

1. Was berührt eine Anfrage, das **geteilt** ist?
2. Wie hoch ist das aktuelle Nebenläufigkeitslimit dieser geteilten Sache?
3. **Erhöht** eine weitere Instanz dieses Limit oder **konkurriert** sie darum?

Lautet die Antwort auf 3 „konkurriert", macht Skalieren es schlimmer.

## Load Balancing: was die Diagramme weglassen

Der Kasten mit der Aufschrift „LB" verbirgt drei Entscheidungen, die darüber bestimmen, ob Skalierung funktioniert.

**Health Checks, die lügen.** Eine Prüfung, die 200 zurückgibt, sobald der Prozess lebt, leitet Verkehr weiter in eine Instanz, deren Datenbank-Pool erschöpft und deren Warteschlange dreißig Sekunden tief ist. Eine nützliche Prüfung verifiziert die Abhängigkeiten, die eine Anfrage wirklich braucht — und trennt *Liveness* (starte mich neu) von *Readiness* (schick mir keinen Verkehr). Beides zu vermischen erzeugt Neustartschleifen unter Last, also genau dann, wenn Sie Stabilität brauchen.

**L4 gegen L7 unter HTTP/2.** L4 verteilt **Verbindungen**. Bei HTTP/2 oder gRPC trägt eine langlebige Verbindung viele gemultiplexte Anfragen — Verteilung auf Verbindungsebene kann einem Backend also den Großteil der Arbeit überlassen, während die übrigen nichts tun. Der Graph zeigt gleichmäßige Verbindungszahlen und völlig ungleiche CPU. Für HTTP/2 und gRPC wollen Sie **Verteilung auf Anfrageebene**.

**Sticky Sessions.** Einen Nutzer an eine Instanz zu binden hebelt die Verteilung absichtlich aus. Außerdem verliert ein Neustart den Zustand dieser Nutzer, und ein heißer Nutzer wird zu einer heißen Instanz, die Sie nicht verteilen können.

Dazu die Retry-Falle: Ist ein System bereits gesättigt, vervielfachen automatische Wiederholungen genau die Last, die es gesättigt hat. Retries brauchen [Rate Limiting und ein Budget](https://www.alekseialeinikov.com/de/blog/topics/architecture/rate-limiting-in-der-praxis-algorithmen-header-2026) sowie Jitter — sonst wird aus einem Brown-out ein Ausfall.

## Warteschlangen puffern Spitzen, nicht Überlast

Eine Warteschlange vor einem langsamen Konsumenten verschafft Ihnen Zeit über eine **Spitze** hinweg. Gegen eine dauerhaft über der Bedienrate liegende Ankunftsrate hilft sie nicht — sie verschiebt nur, wo das Scheitern sichtbar wird.

Übersteigen die Ankünfte die Bedienkapazität auf Dauer, wächst die Warteschlange unbegrenzt und die Latenz mit ihr. Aufrufer laufen in Timeouts. Sie wiederholen. Die Ankünfte steigen. Das ist eine Rückkopplung mit falschem Vorzeichen.

**Backpressure** ist die Antwort, und sie bedeutet, Nein sagen zu können:

- **Begrenzte Warteschlangen.** Eine unbegrenzte Warteschlange ist ein Out-of-Memory mit Zwischenschritten.
- **Schnell ablehnen.** Ein 429 mit `Retry-After` nach 5 ms ist deutlich freundlicher als ein Timeout nach 30 Sekunden — für den Aufrufer und für Sie.
- **Nach Wert verwerfen.** Unter Druck kippen Sie den Batch-Export, bevor Sie den Checkout kippen.
- **In-Flight-Arbeit deckeln.** Nebenläufigkeitslimits je Abhängigkeit verhindern, dass ein langsamer Downstream alle Worker verbraucht.

Der Reflex sagt, Anfragen abzulehnen sei Scheitern. Die Alternative ist, Anfragen anzunehmen, die Sie nicht bedienen können — das ist ebenfalls Scheitern, nur später und für alle gleichzeitig.

## Was Multi-Region leistet und was nicht

Multi-Region kauft Ihnen **Verfügbarkeit** beim Ausfall einer Region und **niedrigere Latenz** für Nutzer in der Nähe einer Region.

Es kauft Ihnen keinen Durchsatz für irgendetwas, das konsistent bleiben muss. Ein global konsistenter Schreibvorgang kostet mindestens einen regionsübergreifenden Roundtrip, und die Lichtgeschwindigkeit ist kein Konfigurationsparameter — grob 5 ms je 1.000 km in Glasfaser, bevor irgendein Gerät das Paket anfasst. Frankfurt nach Virginia hat eine physikalische Untergrenze von mehreren Dutzend Millisekunden pro Roundtrip.

Kreuzt Ihr Schreibpfad Regionen, haben Sie Schreibvorgänge nicht skaliert. Sie haben Latenz und einen Partitionsmodus zum Testen hinzugefügt.

## Den echten Engpass finden

Brendan Greggs **USE-Methode** ist der schnellste Weg. Messen Sie für jede Ressource drei Dinge:

| | Was es bedeutet | Warum es zählt |
| --- | --- | --- |
| **Utilisation** | Anteil der Zeit, in der die Ressource beschäftigt ist | Für sich genommen irreführend |
| **Saturation** | Wie viel Arbeit **wartet** | Meist das eigentliche Signal |
| **Errors** | Ablehnungen, Timeouts, Verwürfe | Sagt, was bereits kaputt ist |

Die meisten Teams exportieren Auslastung und hören dort auf. Aber ein Pool mit 100 Prozent Auslastung und leerer Warteschlange ist kerngesund, während ein Pool mit 60 Prozent und einer dreißig Einträge tiefen Warteschlange Ihr Ausfall ist. **Die Sättigung benennt den Engpass — und genau sie zeichnet fast niemand auf.**

Praktisch: Exportieren Sie für jeden Pool im System — Datenbankverbindungen, HTTP-Clients, Thread-Pools, Worker-Queues — **Warteschlangentiefe und Wartezeit**, nicht nur die Zahl der belegten Slots.

## Ihr Lasttest lügt Sie an

Dieser Punkt verdient einen eigenen Abschnitt, denn er entwertet genau die Messung, der die meisten Teams am meisten vertrauen.

Die meisten Lastgeneratoren arbeiten in einer **geschlossenen Schleife**: Anfrage senden, auf die Antwort warten, nächste senden. Klingt vernünftig. Liefert Latenzzahlen, die still und systematisch falsch sind.

Stellen Sie sich einen Generator mit 100 virtuellen Nutzern vor, der einen Dienst belastet, welcher zwei Sekunden hängt. Während dieses Hängers ist jeder Nutzer blockiert. Sie senden **nichts**. Löst sich der Stau, machen alle weiter und verbuchen eine normal aussehende Latenz.

Das Ergebnis: Die schlimmsten zwei Sekunden Ihres Tests haben fast **keine Messpunkte** beigesteuert. Ihr p99 wird über eine Menge berechnet, die genau den Zeitraum ausschließt, der Sie interessiert.

Das ist **Coordinated Omission**, benannt von Gil Tene. Der Lastgenerator hat sich unfreiwillig mit dem Testsystem abgestimmt und misst es nur dann, wenn es gesund ist.

Echte Nutzer verhalten sich nicht so. Sie kommen nach eigenem Fahrplan. Wer während Ihres Zwei-Sekunden-Hängers klickt, wartet die vollen zwei Sekunden — plus die Zeit, die die Warteschlange davor zum Abbau braucht.

**Woran Sie es erkennen:** Liegt Ihr p99 unter Last verdächtig nah am Median, während Nutzer von Einfrierern berichten, die Sie nicht reproduzieren können, haben Sie es mit ziemlicher Sicherheit.

**Die Gegenmittel:**

- **Gegen den geplanten Fahrplan messen, nicht gegen den tatsächlichen.** Wollten Sie bei t=0 senden und kamen blockiert erst bei t=1,8 s dazu, beginnt die Latenz dieser Anfrage bei t=0.
- **Ein korrigiertes Werkzeug nutzen.** `wrk2` wurde genau dafür geschrieben. HdrHistogram bietet `recordValueWithExpectedInterval`, um die fehlenden Messpunkte nachzutragen.
- **Offenes Modell bevorzugen.** Last mit fester Ankunftsrate erzeugen, unabhängig davon, ob vorherige Antworten zurückkamen — genau so verhält sich echter Verkehr.

Bis Sie das beheben, ist jede unter Sättigung erhobene Latenzzahl eine Untertreibung — und je schlimmer der Hänger, desto größer die Lüge.

## In der Praxis finden

Genug Theorie. Hier die tatsächliche Reihenfolge, wenn die Latenz oben ist und die CPU nicht.

**Bei der Datenbank anfangen, denn dort liegt es meistens.**

```sql
-- Worauf wartet gerade wer?
SELECT state, wait_event_type, wait_event, count(*)
FROM pg_stat_activity
GROUP BY 1, 2, 3
ORDER BY 4 DESC;
```

Viele Zeilen mit `Client / ClientRead` bedeuten, dass die Datenbank untätig ist und auf *Sie* wartet — der Engpass liegt woanders. Viele `Lock` oder `LWLock` bedeuten Contention innerhalb der Datenbank. Viele `idle in transaction` bedeuten, dass Ihre Anwendung Verbindungen offen hält, ohne sie zu nutzen — das entspricht einem kleineren Pool, als Sie denken.

```sql
-- Wie viel Verbindungsreserve ist übrig?
SELECT count(*) AS used,
       current_setting('max_connections')::int AS max_conn
FROM pg_stat_activity;
```

**Dann die Sockets.**

```bash
ss -s                                   # Summen, inklusive TIME_WAIT
ss -tan state time-wait | wc -l         # wie tief ist das TIME_WAIT-Loch
ss -tan state established | wc -l       # was wirklich offen ist
```

Liegt `TIME_WAIT` im Zehntausenderbereich, verwenden Sie Verbindungen nicht wieder. Das ist ein Client-Konfigurationsproblem, kein Kapazitätsproblem.

**Dann die Kernel-Tabellen.**

```bash
cat /proc/sys/net/ipv4/ip_local_port_range
sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
```

**Dann die Prozesslimits.**

```bash
cat /proc/<pid>/limits | grep 'open files'
ls /proc/<pid>/fd | wc -l
```

Die ganze Sequenz dauert etwa drei Minuten und benennt die bindende Randbedingung deutlich häufiger als ein weiteres Dashboard.

## Die Checkliste

| Symptom | Wahrscheinliche Ursache | Was zuerst prüfen |
| --- | --- | --- |
| Niedrige CPU, hohe Latenz | Warten an einem Pool | Wartezeit im Pool, Warteschlangentiefe |
| Fehler bei N Pods, nicht bei N-1 | `max_connections` überschritten | Pods × Poolgröße gegen DB-Limit |
| `too many open files` | FD-Limit | `ulimit -n`, `/proc/<pid>/limits` |
| Connect-Timeouts zu einem Downstream | Ephemere Ports / TIME_WAIT | Wiederverwendung, Keep-Alive |
| Stiller Paketverlust unter Last | conntrack-Tabelle voll | `nf_conntrack_count` gegen `max` |
| Ungleiche Backend-CPU, gleiche Verbindungen | L4 verteilt HTTP/2 | Auf Anfrageebene verteilen |
| Latenz wächst und erholt sich nie | Keine Backpressure | Warteschlange begrenzen, Last abwerfen |
| Mehr Pods machen es langsamer | Contention um geteilte Ressourcen | Worum konkurrieren alle Pods? |

## Das Fazit

CPU ist am leichtesten zu graphen — deshalb schaut man zuerst dorthin, und deshalb ist es meist das Falsche.

Anfragen warten auf Verbindungen, Deskriptoren, Ports, Locks und Threads, und jede dieser Warteschlangen folgt derselben Rechnung. Nebenläufigkeit gleich Ankunftsrate mal Latenz. Kennen Sie zwei Größen, kennen Sie die dritte — und können Ihre Decke ausrechnen, statt sie während eines Incidents zu entdecken.

Beantworten Sie vor jeder zusätzlichen Kapazität eine Frage: **Worauf wartet die Anfrage?** Können Sie es nicht benennen, behebt eine weitere Instanz nichts — sie verschafft derselben Warteschlange nur mehr Kundschaft.
