stateDiagram-v2
[*] --> closed
closed --> open: schwelle Fehler in Folge
open --> half_open: wartezeit abgelaufen
half_open --> closed: Probe erfolgreich
half_open --> open: Probe fehlgeschlagen
Resilienz Teil 2: Circuit Breaker, Rate Limiting, Bulkhead
Track Konzepte · Verteilte Systeme und Betrieb · ca. 40 Min.
Worum es geht
Teil 1 hat gezeigt, wie ein langsamer Downstream ein System lahmlegt und wie du Retries zähmst. Hier kommen die Bausteine, die den Schaden begrenzen, wenn ein Downstream dauerhaft kaputt oder überlastet ist: der Circuit Breaker (hört auf, einen kaputten Service aufzurufen), das Rate Limiting mit dem Token Bucket (begrenzt die Last pro Zeit) sowie Bulkhead und Load Shedding (Isolation und gezieltes Abweisen).
Am Ende hast du Circuit Breaker und Token Bucket selbst gebaut und kannst für einen Fall entscheiden, ob Isolation oder Entlastung fehlt. Wie in Teil 1 läuft alles als Simulation mit Ticks als Zeit.
Was du aus Teil 1 brauchst: den Dominoeffekt durch belegte Slots und dass ein Timeout Slots begrenzt hält. Außerdem, dass Retries nur mit Backoff, Jitter und Budget sinnvoll sind, denn der Breaker ergänzt sie, ersetzt sie aber nicht.
Plane ehrlich 40 Minuten ein: etwa 10 Minuten Lesen, etwa 30 Minuten für drei Übungen.
Von JS/TS her gedacht
Auch hier kennst du die Idee aus dem Frontend: Ein Button, der nach drei Fehlern eine Weile deaktiviert wird, ist ein kleiner Circuit Breaker. Ein debounce oder throttle ist verwandt mit Rate Limiting. Zwischen Backend-Services passiert dasselbe, nur mit Zustand pro Downstream.
| Konzept | JS/TS | Python (hier) |
|---|---|---|
| Circuit Breaker | Bibliotheken wie opossum (bitte prüfen) | selbst gebaut als Klasse mit Zustand |
| Rate Limiting | Middleware (z. B. im Express-Stack) | Token Bucket als Klasse |
| Bulkhead | getrennte Pools oder Queues pro Aufgabe | ein eigener Pool pro Downstream |
| Load Shedding | früh mit HTTP 503 antworten | im Simulator: Anfrage abweisen |
Der Zustandsautomat des Circuit Breakers ist in jeder Sprache gleich: ein Zustand (closed, open, half_open), ein Fehlerzähler und der Zeitpunkt des Öffnens. Genau diese Klasse baust du in Übung 1.
Konzept
Schritt 4: Circuit Breaker
Ein Circuit Breaker (Schutzschalter wie die Sicherung im Haus) hört auf, einen kaputten Downstream aufzurufen, statt jeden Aufruf einzeln in den Timeout laufen zu lassen. Er ist ein Zustandsautomat (state machine) mit drei Zuständen:
- closed (geschlossen, Normalbetrieb): Aufrufe laufen durch. Der Breaker zählt Fehler in Folge. Ein Erfolg setzt den Zähler auf 0.
- open (offen): Aufrufe werden sofort abgelehnt, ohne den Downstream zu belasten. Das gibt B Zeit, sich zu erholen. Der Breaker merkt sich, wann er geöffnet hat.
- half-open (halb offen): Nach der Wartezeit lässt der Breaker genau einen Probe-Aufruf durch. Klappt er, geht es zurück zu closed. Scheitert er, geht es zurück zu open, und die Wartezeit beginnt von vorn.
Ein durchgerechnetes Beispiel (aus der Referenzlösung berechnet): schwelle = 2, wartezeit = 3. Der Downstream ist von Tick 2 bis 7 kaputt und danach wieder gesund. Pro Tick kommt ein Aufruf.
| Tick | Zustand vorher | Aufruf erlaubt? | Ergebnis | Zustand danach |
|---|---|---|---|---|
| 0 | closed | ja | ok | closed |
| 1 | closed | ja | ok | closed |
| 2 | closed | ja | Fehler | closed (1 Fehler) |
| 3 | closed | ja | Fehler | open (2 Fehler in Folge) |
| 4 | open | nein | nicht aufgerufen | open |
| 5 | open | nein | nicht aufgerufen | open |
| 6 | open | ja (Probe, 6 minus 3 ist 3) | Fehler | open (Wartezeit startet bei 6 neu) |
| 7 | open | nein | nicht aufgerufen | open |
| 8 | open | nein | nicht aufgerufen | open |
| 9 | open | ja (Probe, 9 minus 6 ist 3) | ok | closed |
In den Ticks 4, 5, 7 und 8 wurde B nicht belastet, und die Clients haben sofort eine Antwort bekommen (einen Fehler oder einen Fallback) statt zu warten. Das ist der Gewinn.
Schritt 5: Rate Limiting mit dem Token Bucket
Rate Limiting begrenzt, wie viele Anfragen ein Client (oder alle zusammen) pro Zeit stellen darf. Der verbreitete Mechanismus ist der Token Bucket (Eimer mit Marken): Ein Eimer fasst höchstens kapazitaet Marken (tokens). Pro Tick kommen rate Marken dazu, aber nie über die Kapazität. Jede Anfrage braucht eine ganze Marke. Ist keine da, wird die Anfrage abgelehnt. Der Eimer startet voll. Das erlaubt kurze Bursts (Spitzen bis kapazitaet) und begrenzt die Dauerrate auf rate.
Durchgerechnet mit kapazitaet = 3, rate = 0.5 (aus der Referenzlösung berechnet):
| Zeit | Marken vor der Anfrage | Anfrage erlaubt? | Marken danach |
|---|---|---|---|
| 0 | 3 | ja | 2 |
| 0 | 2 | ja | 1 |
| 0 | 1 | ja | 0 |
| 0 | 0 | nein | 0 |
| 1 | 0,5 | nein | 0,5 |
| 2 | 1 | ja | 0 |
| 4 | 1 (zwei Ticks mal 0,5) | ja | 0 |
| 100 | 3 (96 Ticks mal 0,5 wären 48, gedeckelt bei 3) | ja | 2 |
Die Marken werden nicht nach jedem Aufruf nachgefüllt, sondern aus der vergangenen Zeit berechnet: marken = min(kapazitaet, marken + (jetzt - letzter) * rate). Dafür muss der Bucket sich den Zeitpunkt der letzten Berechnung merken. Das ist in Python wie in TypeScript dasselbe, nur ohne Hintergrund-Timer.
Schritt 6: Bulkhead und Load Shedding
Zwei weitere Bausteine aus der Quelle, die du in Übung 3 unterscheiden musst:
- Bulkhead (Schott im Schiffsrumpf): Ressourcen werden getrennt, z. B. ein eigener Thread-Pool pro Downstream oder pro Aufgabe. Hängt ein Teil, läuft nur sein Schott voll, der Rest bleibt trocken. Bulkhead begrenzt den Schaden, schafft aber keine zusätzliche Kapazität.
- Load Shedding (Last abwerfen): Ist das System überlastet, werden Anfragen bewusst früh abgewiesen, am besten die unwichtigsten zuerst (z. B. mit HTTP 503), damit die wichtigen schnell bleiben. Besser ein paar schnelle Fehler als für alle langsame Antworten.
Weitere Begriffe aus der Quelle, nur zum Einordnen (allgemeines Fachwissen):
| Begriff | Idee |
|---|---|
| Hedged Requests | Dauert eine Leseanfrage ungewöhnlich lange, schickst du sie zusätzlich an ein zweites Replikat und nimmst die schnellere Antwort. Kostet Last, nur für idempotente Anfragen. |
| Graceful Degradation | Statt eines Fehlers liefert das System eine abgespeckte Antwort (z. B. gecachte oder Standard-Empfehlungen). |
| Retry Storm / metastabile Ausfälle | Retries halten ein System im schlechten Zustand, obwohl der Auslöser weg ist (Schritt 2 in Teil 1). |
Falle
- Circuit Breaker ohne Fallback. Der Breaker spart B Last, aber der Client bekommt trotzdem einen Fehler. Überlege, was er stattdessen anzeigen kann (Graceful Degradation).
- Bulkhead als Kapazitätsgewinn missverstehen. Getrennte Pools verteilen dieselben Ressourcen anders, sie schaffen keine neuen. Fehlt Gesamtkapazität, hilft nur Last abwerfen oder skalieren.
- Load Shedding ohne Priorität. Wer wahllos abweist, wirft auch die wichtigen Anfragen weg. Lege vorher fest, was zuerst geht (allgemeines Fachwissen).
Übungen
Übung 1: Circuit Breaker als Zustandsautomat (ca. 15 Min.)
Schreibe die Klasse CircuitBreaker(schwelle, wartezeit). Zeit ist eine ganze Zahl jetzt (Tick). Das Attribut zustand ist einer der Strings "closed", "open", "half_open". Der Breaker startet in "closed". Er hat drei Methoden:
| Methode | Verhalten |
|---|---|
erlaubt(jetzt) |
closed: True. open: False, außer jetzt - geoeffnet_um >= wartezeit: dann wechselt er zu half_open und gibt True zurück (die Probe). half_open: False (die Probe läuft schon). |
erfolg() |
Setzt den Zähler der Fehler in Folge auf 0. Aus half_open wird closed. |
fehler(jetzt) |
closed: Fehler in Folge um 1 erhöhen, bei schwelle Fehlern öffnet er und merkt sich jetzt. half_open: sofort wieder open, Wartezeit startet bei jetzt neu. open: nichts. |
Beispielsequenz mit schwelle=3, wartezeit=5, die der Check unter anderem prüft:
| Schritt | erwartet |
|---|---|
fehler(0), fehler(1), erfolg(), fehler(2), fehler(3) |
zustand ist noch "closed" (der Erfolg hat den Zähler zurückgesetzt) |
fehler(4) |
"open" (3 Fehler in Folge) |
erlaubt(8) |
False (8 minus 4 ist 4, kleiner als 5) |
erlaubt(9) |
True, zustand ist "half_open" |
erlaubt(9) |
False (nur eine Probe) |
fehler(9) |
"open", Wartezeit startet bei 9 neu |
erlaubt(13), erlaubt(14) |
False, dann True |
erfolg() |
"closed" |
Du brauchst vier Dinge als Zustand: den Zustandsnamen, die Fehler in Folge, den Zeitpunkt des Öffnens und die Schwelle samt Wartezeit. Jede Methode beginnt mit der Frage: In welchem Zustand bin ich gerade?
class CircuitBreaker:
def __init__(self, schwelle, wartezeit):
self.schwelle = schwelle
self.wartezeit = wartezeit
self.zustand = "closed"
self.fehler_in_folge = 0
self.geoeffnet_um = None
def erlaubt(self, jetzt):
if self.zustand == "closed":
return True
if self.zustand == "open" and jetzt - self.geoeffnet_um >= self.wartezeit:
self.zustand = "half_open"
return True
return False
def erfolg(self):
self.fehler_in_folge = 0
if self.zustand == "half_open":
self.zustand = "closed"
def fehler(self, jetzt):
if self.zustand == "half_open":
self.zustand = "open"
self.geoeffnet_um = jetzt
elif self.zustand == "closed":
self.fehler_in_folge += 1
if self.fehler_in_folge >= self.schwelle:
self.zustand = "open"
self.geoeffnet_um = jetzt
CircuitBreakerDie Probe in half_open ist genau ein Aufruf, weil erlaubt im Zustand half_open False liefert. Scheitert die Probe, startet die Wartezeit bei jetzt neu, sonst würde der Breaker sofort wieder probieren.
Übung 2: Token Bucket als Rate Limiter (ca. 8 Min.)
Schreibe TokenBucket(kapazitaet, rate) mit der Methode erlaubt(jetzt). Regeln (siehe Schritt 5):
- Der Bucket startet voll (
kapazitaetMarken) beim Zeitpunkt 0. - Bei jedem Aufruf werden zuerst Marken aus der vergangenen Zeit nachgefüllt:
(jetzt - letzter) * rate, aber nie überkapazitaet. - Ist danach mindestens 1 Marke da, wird eine verbraucht und
Truezurückgegeben. SonstFalse, und es wird nichts verbraucht. - Die Zeit wird nie zurückgedreht (
jetztist nicht kleiner als beim vorigen Aufruf).
Beispiel TokenBucket(2, 1): Aufrufe bei Zeit 0, 0, 0 liefern True, True, False. Bei Zeit 1 kommt eine Marke dazu: True, dann False.
Du musst dir zwei Dinge merken: wie viele Marken da sind und wann du zuletzt nachgefüllt hast. Wann setzt du den Zeitpunkt neu, auch wenn die Anfrage abgelehnt wird?
class TokenBucket:
def __init__(self, kapazitaet, rate):
self.kapazitaet = kapazitaet
self.rate = rate
self.tokens = kapazitaet
self.letzter = 0
def erlaubt(self, jetzt):
self.tokens = min(self.kapazitaet, self.tokens + (jetzt - self.letzter) * self.rate)
self.letzter = jetzt
if self.tokens >= 1:
self.tokens -= 1
return True
return False
TokenBucketNachfüllen passiert vor der Prüfung und immer mit der vergangenen Zeit, nicht pro Aufruf. Der Zeitpunkt wird auch bei abgelehnten Anfragen aktualisiert, sonst würde dieselbe Zeit doppelt gutgeschrieben.
Übung 3: Bulkhead oder Load Shedding? (Trade-off, ca. 6 Min.)
Zwei unabhängige Fälle. Trage ein Tupel mit zwei Buchstaben ein, z. B. ("A", "B").
Fall 1. Ein Shop-Backend hat einen gemeinsamen Pool von 20 Worker-Threads für alle Endpunkte. Der Endpunkt “Report-Export” ruft ein langsames Reporting-System auf. Wenn das hängt, belegt es alle 20 Threads. Danach antwortet auch “Checkout” nicht mehr, obwohl dessen Datenbank gesund ist. Der Export darf langsam sein, der Checkout nicht. Was löst das am direktesten?
- A Den gemeinsamen Pool auf 200 Threads vergrößern, damit er bei einem hängenden Export seltener ganz voll wird.
- B Fehler im Export sofort und mehrfach wiederholen, bis das Reporting-System wieder antwortet und der Export durchläuft.
- C Ein Rate Limit pro Client am Eingang einführen, damit weniger Exporte pro Minute gestartet werden.
- D Feste, getrennte Pools: z. B. 5 Threads nur für den Export und 15 für den Checkout.
Fall 2. Ein Service schafft 100 Anfragen pro Sekunde. Wegen einer Marketing-Aktion kommen 150 pro Sekunde, von echten neuen Nutzern, nicht durch Retries. Neue Server brauchen 10 Minuten. Es gibt drei Prioritäten: Bezahlen (wichtig), Suche, Empfehlungen (verzichtbar). Die Warteschlange wächst, alle Antworten werden langsam und laufen in Timeouts. Was tust du in den nächsten Minuten?
- A Anfragen der niedrigsten Priorität (Empfehlungen, dann Suche) sofort mit Fehler 503 abweisen, bis die Last unter 100 liegt.
- B Die Warteschlange vor dem Service vergrößern, damit keine Anfrage verloren geht und alle irgendwann drankommen.
- C Die Timeouts der Clients auf 60 Sekunden erhöhen, damit weniger Anfragen in einen Timeout laufen.
- D Pro Endpunkt einen eigenen Thread-Pool anlegen, damit Bezahlen, Suche und Empfehlungen sich nicht gegenseitig die Threads wegnehmen.
Fall 1: Was fehlt, damit ein hängender Teil die anderen nicht mitreißt? Fall 2: Fehlt hier Isolation oder fehlt Kapazität, und was tust du, solange Kapazität fehlt?
antwort = ("D", "A")
antwortFall 1, D (Bulkhead). Getrennte Pools isolieren den Schaden: Hängt der Export, sind seine 5 Threads belegt, die 15 des Checkouts bleiben frei. Ein größerer gemeinsamer Pool (A) verschiebt nur den Zeitpunkt, Retries (B) halten Threads noch länger fest, ein Rate Limit (C) zählt Anfragen pro Zeit und nicht belegte Threads.
Fall 2, A (Load Shedding). Das Problem ist fehlende Gesamtkapazität, nicht fehlende Isolation. Solange keine neuen Server da sind, weist du die unwichtigsten Anfragen früh und schnell ab, damit Bezahlen schnell bleibt. Eine längere Schlange (B) und längere Timeouts (C) machen alle Antworten nur langsamer, getrennte Pools (D) ändern nichts an 150 gegen 100.
Merksatz
Öffne bei Dauerfehlern den Circuit Breaker (closed, open, half-open), begrenze die Last mit dem Token Bucket, trenne Ressourcen mit Bulkheads und wirf bei fehlender Gesamtkapazität die unwichtige Last ab (Load Shedding).
Prüfstein
- Ein Downstream-Service antwortet langsam. Was passiert im Gesamtsystem, und welche Mechanismen verhindern den Dominoeffekt?
- Wann nimmst du einen Bulkhead und wann Load Shedding?
Quelle: quellen/konzeptuebersicht-software-grundlagen.md, Abschnitt “7. Verteilte Systeme und Betrieb” (Resilienz: Circuit Breaker, Rate Limiting, Prüfstein zum langsamen Downstream); quellen/konzeptuebersicht-software-fortgeschritten.docx, Schicht 7 “Resilienz” (Bulkheads, Load Shedding, Hedged Requests, Graceful Degradation).
Über die Quelle hinaus (allgemeines Fachwissen): die Zustände und Übergänge des Circuit Breakers, die Funktionsweise des Token Buckets, die Beschreibung von Hedged Requests und Graceful Degradation, HTTP 503 für Load Shedding, Opossum als JS-Bibliothek (bitte prüfen), die Falle zu Bulkhead und Priorität beim Load Shedding. Alle Zahlen im Text stammen aus dem Ausführen des Codes dieser Lektion, nicht aus Messungen realer Systeme.
Zurück zu Teil 1: Timeouts, Retries, Backoff und Budget.