Wenn der Nachbar langsam ist: Timeout, Retry, Circuit Breaker
Kurz gesagt: Ein Timeout beendet das Warten auf einen langsamen Service, ein Retry mit exponentiellem Backoff und Jitter wiederholt nur vorübergehende Fehler, und ein Circuit Breaker hält Anfragen zurück, bis sich der Service erholt hat.
Problem
Dreißig Sekunden, die den Shop stillgelegt haben
Am Morgen des Black Friday antwortet der Zahlungsanbieter nach 30 Sekunden statt nach 300 ms. Er fällt nicht aus: Er antwortet, nur langsam. Eine Minute später zeigt der Shop nicht einmal mehr den Katalog an, und der Katalog ruft den Anbieter gar nicht auf.
Hier ist der Grund, im Kleinen. Der Shop hat einen Pool aus 4 Threads (die Lektion über Thread-Pools). In jedem Tick kommen eine Zahlung und eine Katalog-Anfrage an. Der Anbieter antwortet nach 30 Ticks:
# Pool mit 4 Threads; in jedem Tick kommen eine Zahlung und eine Katalog-Anfragedef run(title, timeout): busy_until = [0] * 4 # bis zu welchem Tick jeder Thread belegt ist refused = 0 for t in range(8): free = free_thread(busy_until, t) # die Zahlung hält den Thread, bis der Anbieter antwortet if free is not None: busy_until[free] = t + min(30, timeout) if free_thread(busy_until, t) is None: refused += 1 # der Katalog hat keinen freien Thread busy = sum(1 for u in busy_until if u > 7) print(f"{title}: belegte Threads {busy} von 4, Katalog lehnt ab: {refused} Anfragen")
def free_thread(busy_until, t): return next((i for i, u in enumerate(busy_until) if u <= t), None)run("ohne Timeout", math.inf)ohne Timeout: belegte Threads 4 von 4, Katalog lehnt ab: 5 AnfragenNach vier Ticks warten alle vier Threads auf den Anbieter. Der Katalog lehnt danach alles ab, obwohl er mit Zahlungen nichts zu tun hat. Ein langsamer Nachbar ist gefährlicher als ein toter: Der tote antwortet wenigstens sofort mit einem Fehler.
Die Idee
Timeout: nicht ewig warten
Ein Timeout ist die längste Zeit, die du auf eine Antwort wartest. Danach hörst du auf, gibst den Thread frei und sagst dem Kunden etwas Klares: „Die Zahlung ist nicht durchgegangen, die Bestellung ist gespeichert, versuch es gleich noch einmal.“ Derselbe Shop mit einem Timeout von 2 Ticks:
run("Timeout von 2 Ticks", 2)Timeout von 2 Ticks: belegte Threads 2 von 4, Katalog lehnt ab: 0 AnfragenDie Zahlungen scheitern trotzdem, weil der Anbieter langsam ist. Aber jede hält einen Thread höchstens 2 Ticks lang, es gibt immer freie Threads, und der Katalog funktioniert.
Wie lang sollte das Timeout sein? Es ergibt sich aus dem Latenzbudget: Wenn die Zahlung für das p95 450 ms hat, schützt ein Timeout von 30 Sekunden nichts. Meist liegt es knapp über dem p99 des Nachbarn, gemessen, nicht geraten. Und das Timeout der unteren Schicht muss kürzer sein als das der oberen: Sonst hat die obere Schicht längst aufgegeben, während die untere noch für niemanden arbeitet.
Die Idee
Retry: noch einmal versuchen, aber klug
Viele Fehler sind vorübergehend: ein verlorenes Paket, ein Server beim Neustart, eine Antwort 503 („ich bin beschäftigt“). Ein zweiter Versuch etwas später gelingt oft. Das ist ein Retry. Drei Regeln machen ihn sicher:
- Nur vorübergehende Fehler. Timeout und 503 werden wiederholt. „Die Karte wurde abgelehnt“ oder 400 (fehlerhafte Anfrage) bleibt beim zweiten Versuch dasselbe.
- Eine Wartezeit, die wächst: 1, 2, 4, 8 Ticks. Das ist exponentielles Backoff: Ein Nachbar, der gerade untergeht, bekommt immer mehr Luft.
- Eine Grenze für die Versuche. Danach gibst du auf und lieferst den letzten Fehler zurück.
# nur vorübergehende Fehler: der Anbieter kann sich erholendef retryable(answer): return answer in ("timeout", "503")def with_retry(answers, max_attempts): tick = 0 for attempt in range(1, max_attempts + 1): answer = answers[attempt - 1] # aufgezeichnete Antwort auf Versuch Nummer attempt print(f"Tick {tick}, Versuch {attempt}: {answer}") if not retryable(answer) or attempt == max_attempts: return answer tick += 2 ** (attempt - 1) # Wartezeit 1, 2, 4, 8… TicksDer Anbieter antwortet nach einem aufgezeichneten Ablauf: Timeout, dann 503, dann ok.
print("Ergebnis:", with_retry(["timeout", "503", "ok"], 4))Tick 0, Versuch 1: timeoutTick 1, Versuch 2: 503Tick 3, Versuch 3: okErgebnis: okVorsicht: Eine wiederholte Zahlung kann das Geld doppelt abbuchen, wenn die erste durchgegangen ist und nur die Antwort verloren ging. Deshalb wird die Zahlung nur mit einem Idempotency-Key wiederholt: Der Anbieter erkennt die wiederholte Anfrage und liefert das erste Ergebnis zurück.
Probier es aus
Sag die Ausgabe voraus
Der Anbieter antwortet dreimal mit Timeout und beim vierten Versuch mit ok. Die Grenze liegt bei 3 Versuchen. Was gibt das Programm aus?
print("Ergebnis:", with_retry(["timeout", "timeout", "timeout", "ok"], 3))Die Versuche liegen bei den Ticks 0, 1 und 3 (Wartezeit 1, dann 2). Der dritte ist der letzte erlaubte, also gibt die Funktion Timeout zurück, ohne das ok beim vierten zu erreichen.
Probier es aus
Finde den Fehler
Ein Kunde mit abgelehnter Karte wartet volle 7 Ticks, und die Bank schickt vier Ablehnungsmitteilungen. In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
Alles außer ok gilt als vorübergehend, auch die abgelehnte Karte. Warten behebt das nicht. Nur Timeout und 503 werden wiederholt; der Rest wird sofort zurückgegeben.
Die Idee
Jitter: damit nicht alle gleichzeitig starten
Wenn der Anbieter ausfällt, scheitern Tausende Zahlungen auf einmal. Mit exponentiellem Backoff versuchen es alle wieder in Tick 1, dann in 3, dann in 7: gemeinsam, genau dann, wenn der Anbieter versucht, sich zu erholen. Das ist die Thundering Herd (die donnernde Herde).
Jitter bringt Zufall in die Wartezeit: Jeder Client wartet eine zufällige Zahl von Ticks, von 1 bis zum Doppelten der Wartezeit. Das Programm benutzt einen kleinen Generator mit Seed, damit er in jeder Sprache und bei jedem Lauf dieselben Zahlen liefert:
seed = 7
# Generator mit Seed: in jeder Sprache dieselben Zahlendef random(bound): global seed seed = (seed * 75 + 74) % 65537 return seed % bound
def retry_ticks(jitter): ticks = [] tick = 0 for attempt in range(4): wait = 2 ** attempt # 1, 2, 4, 8 if jitter: wait = 1 + random(2 * wait) # von 1 bis 2 · wait tick += wait ticks.append(str(tick)) return " ".join(ticks)for client in ["A", "B", "C"]: print(f"{client} ohne Jitter: {retry_ticks(False)}")for client in ["A", "B", "C"]: print(f"{client} mit Jitter : {retry_ticks(True)}")A ohne Jitter: 1 3 7 15B ohne Jitter: 1 3 7 15C ohne Jitter: 1 3 7 15A mit Jitter : 2 6 11 13B mit Jitter : 2 5 12 19C mit Jitter : 1 5 8 9Ohne Jitter treffen die drei den Anbieter in genau denselben Ticks. Mit Jitter verteilen sie sich. Im echten Code setzt du keinen Seed: Du benutzt den Generator der Sprache, und in Tests gibst du deinen eigenen mit, damit die Ausgabe vorhersehbar ist.
Die Idee
Circuit Breaker: nicht mehr anrufen
Ein Retry hilft bei einem kurzen Problem. Wenn der Anbieter seit zehn Minuten ausgefallen ist, ist jede Wiederholung eine Anfrage mehr an jemanden, der untergeht, und ein Thread mehr, der wartet. Dann brauchst du einen Circuit Breaker, wie die Sicherung im Stromkasten. Er hat drei Zustände:
- geschlossen (closed): Anfragen gehen durch. Der Breaker zählt die Fehler in Folge;
- offen (open): Nach 3 Fehlern in Folge ruft er nicht mehr an. Jede Anfrage wird sofort abgelehnt, ohne Warten;
- halboffen (half-open): Nach 4 Ticks lässt er einen Probeaufruf durch. Gelingt er, schließt der Breaker. Scheitert er, öffnet der Breaker wieder.
THRESHOLD = 3 # Fehler in Folge bis zum ÖffnenCOOLDOWN = 4 # Ticks in OPEN vor einem Probeaufruf
class State(Enum): CLOSED = 1 OPEN = 2 HALF_OPEN = 3
class CircuitBreaker: def __init__(self): self.state = State.CLOSED self.failures = 0 self.opened_at = 0 self.calls = 0 # wie oft der Anbieter aufgerufen wurde
def call(self, tick, provider_up): if self.state == State.OPEN: if tick - self.opened_at < COOLDOWN: return "sofort abgelehnt" # ein Probeaufruf self.state = State.HALF_OPEN self.calls += 1 if provider_up: self.state = State.CLOSED self.failures = 0 return "ok" self.failures += 1 too_many = self.failures >= THRESHOLD if self.state == State.HALF_OPEN or too_many: self.state = State.OPEN self.opened_at = tick return "Fehler"Sieh es dir an
Der Breaker über 12 Ticks
def call(self, tick, provider_up): if self.state == State.OPEN: if tick - self.opened_at < COOLDOWN: return "sofort abgelehnt" # ein Probeaufruf self.state = State.HALF_OPEN self.calls += 1 if provider_up: self.state = State.CLOSED self.failures = 0 return "ok" self.failures += 1 too_many = self.failures >= THRESHOLD if self.state == State.HALF_OPEN or too_many: self.state = State.OPEN self.opened_at = tick return "Fehler"Schritt für Schritt, in Worten
- CLOSED: Jeder Aufruf geht an den Zahlungsanbieter. Er antwortet nicht: Tick 0 und Tick 1 sind Fehler, der Zähler steht bei 2 von 3.
- Tick 2: der dritte Fehler in Folge. Der Breaker öffnet sich (OPEN) und merkt sich, wann.
- Die Ticks 3, 4 und 5 werden sofort abgelehnt. Sie erreichen den Anbieter nicht und halten keinen Thread. Der Shop kann sofort „später bezahlen“ anbieten.
- Tick 6: 4 Ticks sind vergangen. HALF_OPEN: Ein Probeaufruf geht durch. Der Anbieter ist noch ausgefallen, also öffnet sich der Breaker wieder.
- Ab Tick 7 funktioniert der Anbieter wieder, aber der Breaker weiß es noch nicht. Die Ticks 7 bis 9 werden abgelehnt: der Preis des Wartens.
- Tick 10: Der Probeaufruf gelingt, der Breaker schließt (CLOSED) und der Zähler wird zurückgesetzt. Der Anbieter wurde bei 6 von 12 Ticks aufgerufen.
Der Anbieter antwortet bis Tick 7 nicht und funktioniert danach. Der aufgezeichnete Ablauf ist tick >= 7:
breaker = CircuitBreaker()for tick in range(12): up = tick >= 7 # aufgezeichnet: der Anbieter ist ab Tick 7 wieder da result = breaker.call(tick, up) print(f"Tick {tick}: {result} → {breaker.state.name}")print(f"Aufrufe beim Anbieter: {breaker.calls} von 12")Tick 0: Fehler → CLOSEDTick 1: Fehler → CLOSEDTick 2: Fehler → OPENTick 3: sofort abgelehnt → OPENTick 4: sofort abgelehnt → OPENTick 5: sofort abgelehnt → OPENTick 6: Fehler → OPENTick 7: sofort abgelehnt → OPENTick 8: sofort abgelehnt → OPENTick 9: sofort abgelehnt → OPENTick 10: ok → CLOSEDTick 11: ok → CLOSEDAufrufe beim Anbieter: 6 von 12Von 12 Anfragen erreichen nur 6 den Anbieter. Die anderen werden sofort abgelehnt: Sie halten keinen Thread und belasten niemanden, der ohnehin nicht antwortet. Der Preis zeigt sich in den Ticks 7 bis 9: Der Anbieter funktioniert schon, und der Breaker lehnt noch ab. Ein kürzerer Cooldown bringt die Arbeit früher zurück, trifft aber den Anbieter öfter.
State(Enum) gibt den Zuständen Namen, und .name gibt sie aus: CLOSED, OPEN, HALF_OPEN.Probier es aus
Fülle die Lücke
Wie viele Ticks wartet der offene Breaker, bevor er einen Probeaufruf durchlässt?
if self.state == State.OPEN:
if tick - self.opened_at < :
return "sofort abgelehnt"
self.state = State.HALF_OPENDen Cooldown. Solange er nicht vorbei ist, wird jede Anfrage sofort abgelehnt. Danach wird der Breaker halboffen und lässt einen Probeaufruf durch.
Die Idee
Bulkhead und die Antwort bei Ablehnung
Ein Bulkhead (wie die Schotten im Rumpf eines Schiffs) ist ein eigener Thread-Pool für jeden Nachbarn: Die Zahlung hat ihre eigenen 4 Threads und der Katalog seine eigenen. Dann frisst ein langsamer Anbieter nur seine eigenen.
Der Breaker und das Timeout sagen „jetzt nicht“, und das System muss wissen, was es dann tut. Das ist der Fallback: Die Bestellung wird als „wartet auf Zahlung“ gespeichert, und der Kunde zahlt später; die Seite zeigt den Preis aus dem Cache; die Zahlung läuft über einen zweiten Anbieter. Welche Antwort akzeptabel ist, entscheidet das Business, wie bei den Trade-offs.
Die Idee
Wenn es wehtut
- Retries in jeder Schicht. Das Gateway wiederholt 3-mal, die Bestellungen 3-mal, die Zahlung 3-mal: bis zu 27 Anfragen an den Anbieter für einen Klick. Wiederhole an einer Stelle.
- Eine Operation wiederholen, die nicht idempotent ist. Eine doppelte Zahlung, zwei E-Mails, zwei Pakete. Ohne Idempotency-Key wiederholst du nicht.
- Ein Timeout, das länger ist als das des Aufrufers. Die obere Schicht hat dem Kunden längst „Fehler“ gesagt, und die untere wartet und wiederholt noch.
- Ein Breaker für den ganzen Service wegen einer langsamen Adresse. Wenn nur die Suche langsam ist, halte nicht auch die Zahlung an.
- Zahlen geraten statt gemessen. Ein zu niedriger Schwellenwert öffnet den Breaker bei jedem bisschen Rauschen; ein zu langer Cooldown hält ihn offen, wenn der Nachbar längst wieder arbeitet.
Dieselben Mechanismen bietet oft auch die Infrastruktur (ein Service Mesh wie Istio oder Linkerd, ein API-Gateway). Das lernst du im DevOps-Kurs ZeroToOps. Hier geht es um den Teil im Code: was du tust, wenn der Nachbar nicht antwortet.
Genauer, für Neugierige
Die Bibliotheken. In einem echten Projekt schreibst du keinen Breaker selbst: Java hat Resilience4j (Spring Cloud Circuit Breaker nutzt es); C# hat Polly und Microsoft.Extensions.Http.Resilience für HttpClient; Python hat tenacity für Retries und pybreaker; Node hat cockatiel und opossum. Hystrix von Netflix ist das historische Vorbild und wird nicht mehr weiterentwickelt.
Die Standard-Timeouts. In .NET wartet HttpClient standardmäßig 100 Sekunden. Im HttpClient von Java und in requests für Python gibt es kein Timeout, bis du eines setzt. In Node wartet fetch (undici) bis zu 300 Sekunden auf die Header. Die Netzwerk-Clients kennst du aus der Lektion „Netzwerkprogrammierung“.
Die Deadline reist mit. Wenn der Client insgesamt 2 Sekunden hat und das Gateway schon 0,5 verbraucht hat, bekommt der Service darunter 1,5, nicht seine eigenen 2. gRPC macht das mit einer Deadline, die mit der Anfrage reist.
Retry-After und ein Retry-Budget. Die Antworten 503 und 429 können sagen, wann man es versuchen soll (Retry-After). Viele Systeme begrenzen Retries auch insgesamt, zum Beispiel auf 10 % der Anfragen, damit sie die Last nicht gerade dann verdoppeln, wenn es am schlimmsten ist.
Arten von Jitter. Hier ist es „Full“-Jitter: zufällig von 1 bis zum Doppelten. Es gibt auch „Equal“-Jitter (halb fest, halb zufällig) und „Decorrelated“-Jitter. Das Ziel ist dasselbe: Die Clients dürfen sich nicht synchronisieren.
global seed lässt die Funktion die Variable des Moduls ändern. In einer Klasse oder in einem Objekt aus random.Random(7) ist der Zustand aufgeräumter.Übung
Retries mit einer Deadline
Löse die Aufgabe in deinem eigenen Editor.
Der Kunde wartet höchstens 5 Ticks auf die Zahlung. Schreibe with_deadline, das wie with_retry wiederholt, aber ohne Grenze für die Versuche: Es hört auf, wenn der nächste Versuch nach der Deadline käme, und sagt, warum. Der Anbieter antwortet dreimal mit Timeout, dann mit ok.
Tick 0, Versuch 1: timeoutTick 1, Versuch 2: timeoutTick 3, Versuch 3: timeoutich höre auf: Versuch 4 käme bei Tick 7, nach der Frist 5Ergebnis: timeoutWeitere Aufgaben
- Ein Breaker und Retries zusammen. Setze den Breaker vor
with_retry. Ist eine Ablehnung „sofort“ ein vorübergehender Fehler? - Ein Bulkhead. Teile den Pool aus dem Problem in zwei: 2 Threads für Zahlungen und 2 für den Katalog. Wie viele Katalog-Anfragen werden jetzt ohne Timeout abgelehnt?
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Netzwerkprogrammierung · Thread-Pool und Aufgaben · API-Design: Vertrag, Versionen, wiederholte Anfragen · Nichtfunktionale Anforderungen, die du messen kannst
- Geht weiter in: Eine Transaktion über Services hinweg (Saga) · Alte und fremde Systeme anbinden · Architektur-Review · Deine erste Architekturaufgabe
- Aufgaben zum Thema: Der Architekt bei der Arbeit