Resilienz Teil 1: Timeouts, Retries, Backoff und Budget

Track Konzepte · Verteilte Systeme und Betrieb · ca. 50 Min.

Worum es geht

Ein Service B antwortet nicht mehr in 50 ms, sondern in 6 Sekunden. Er ist nicht tot, nur langsam. Genau das ist gefährlicher als ein Totalausfall: Dein Service A wartet auf B, hält dabei Threads und Verbindungen fest und ist nach kurzer Zeit selbst nicht mehr erreichbar. Dann fällt Service C aus, der A aufruft, und so weiter (Dominoeffekt, cascading failure). Schlimmer noch: Die Clients versuchen es sofort noch einmal, die Last steigt, und der Service, der eigentlich schon wieder gesund wäre, geht im Retry-Sturm (retry storm) unter.

In diesem ersten Teil verstehst du, wie ein langsamer Downstream ein System lahmlegt, wie ein Timeout das begrenzt und wie ein Retry-Sturm entsteht. Dann baust du die Gegenmittel für Retries selbst: exponentielles Backoff mit Jitter und ein Retry-Budget. Teil 2 (Circuit Breaker, Rate Limiting, Bulkhead) baut darauf auf.

Du siehst hier keine echten Netzwerke. Wir simulieren Zeit als Zähler (ein Tick ist ein Zeitschritt), einen Server mit begrenzter Kapazität und Clients mit Timeouts. So ist jeder Ausfall bei jedem Lauf gleich und du kannst die Zahlen nachrechnen.

Plane ehrlich 50 Minuten ein: etwa 15 Minuten Lesen, etwa 35 Minuten für drei Übungen. Eine gute Pause ist nach Übung 2 (Backoff und Budget).

Von JS/TS her gedacht

Du kennst das aus dem Frontend: fetch ohne Timeout hängt für immer, und eine Retry-Schleife ohne Wartezeit hämmert auf einen kaputten Server ein. Dieselben Fehler passieren zwischen Backend-Services, nur mit tausend Clients gleichzeitig.

Konzept JS/TS Python (hier)
Timeout AbortController plus setTimeout requests.get(url, timeout=3), Zähler im Simulator
Retry mit Wartezeit await new Promise(r => setTimeout(r, ms)) time.sleep(ms), im Simulator eine Zahl von Ticks
Zufall für Jitter Math.random() random.Random(seed)
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

Backoff mit Jitter in TypeScript. Zufallsfolge fest gesetzt, damit die Ausgabe reproduzierbar ist:

// Full Jitter: zufällig zwischen 0 und min(deckel, basis * 2^versuch)
function backoff(versuch: number, basis: number, deckel: number,
                 zufall: () => number = Math.random): number {
  const obergrenze = Math.min(deckel, basis * 2 ** versuch);
  return Math.floor(zufall() * (obergrenze + 1));
}

Ausgeführt mit Node (die Funktion ohne Typannotationen als JavaScript, basis = 2, deckel = 16, als zufall die feste Folge 0.1, 0.05, 0.6, 0.2, 0.4, 0.02, also ein Wert pro Retry 0 bis 5):

Retry 0: Wartezeit 0 (Obergrenze 2)
Retry 1: Wartezeit 0 (Obergrenze 4)
Retry 2: Wartezeit 5 (Obergrenze 8)
Retry 3: Wartezeit 3 (Obergrenze 16)
Retry 4: Wartezeit 6 (Obergrenze 16)
Retry 5: Wartezeit 0 (Obergrenze 16)

Die Obergrenze wächst exponentiell (2, 4, 8, dann gedeckelt bei 16). Die tatsächliche Wartezeit ist zufällig darunter. Genau diese Funktion baust du in Übung 2 in Python.

Konzept

Schritt 1: Langsam ist gefährlicher als kaputt

Service A hat eine feste Zahl Slots (Worker-Threads oder Verbindungen im Pool). Jeder Request an A belegt einen Slot, bis B geantwortet hat. Ist B tot, bekommt A sofort einen Verbindungsfehler und der Slot ist gleich wieder frei. Ist B nur langsam, bleibt der Slot lange belegt. Kommt pro Tick eine Anfrage und ein Slot ist 5 Ticks belegt, brauchst du im Schnitt 5 Slots. Hat A nur 3, sind bald alle Slots belegt, und jede weitere Anfrage wird abgewiesen, auch Anfragen, die B gar nicht brauchen.

Ein Timeout begrenzt, wie lange ein Slot höchstens festgehalten wird. Der Simulator unten rechnet das nach. Pro Tick kommt eine Anfrage. belegt merkt sich, wann jeder Slot wieder frei wird.

Ausgabe:

B gesund, Antwort nach 1 Tick
  belegte Slots pro Tick: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
  (abgewiesen, timeouts, spitze) = (0, 0, 1)
B langsam, 5 Ticks, kein Timeout
  belegte Slots pro Tick: [1, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
  (abgewiesen, timeouts, spitze) = (4, 0, 3)
B langsam, 5 Ticks, Timeout 2
  belegte Slots pro Tick: [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]
  (abgewiesen, timeouts, spitze) = (0, 12, 2)

Rechne die mittlere Zeile mit: Tick 0, 1, 2 füllen die drei Slots (frei bei Tick 5, 6, 7). Tick 3 und 4 finden keinen freien Slot und werden abgewiesen. Bei Tick 5 wird der erste Slot frei und sofort wieder belegt (frei bei Tick 10), bei Tick 6 und 7 genauso. Tick 8 und 9 werden wieder abgewiesen. Macht 4 Abweisungen in 12 Ticks.

Mit Timeout 2 hält kein Slot länger als 2 Ticks. Du hast nie mehr als 2 Slots belegt und weist nichts ab. Dafür bekommen alle Clients einen Timeout-Fehler. Das ist der Handel: Du verlierst diese Anfragen, aber A bleibt ansprechbar für alles andere (fail fast, schnell scheitern statt lange hängen).

flowchart LR
    B["Service B antwortet langsam"] --> S["Slots in A bleiben belegt"]
    S --> V["A weist neue Anfragen ab"]
    V --> C["Service C, der A aufruft, hängt ebenfalls"]
    C --> D["Dominoeffekt im ganzen System"]

Schritt 2: Der Retry-Sturm, durchgerechnet

Jetzt der zweite Fall. Ein Server schafft 11 Anfragen pro Tick (kapazitaet). Normale Last: 8 neue Anfragen pro Tick. Er ist also gesund, mit etwas Luft. Von Tick 10 bis 13 fällt er aus (Kapazität 0, z. B. ein kurzer Datenbank-Hänger). Jeder Client hat einen Timeout von 3 Ticks: Wird seine Anfrage nicht innerhalb von 3 Ticks beantwortet, gibt er auf und versucht es noch einmal (höchstens 4 Versuche pro Anfrage).

Zwei Dinge sind wichtig und kommen im echten Leben genauso vor:

  1. Der Server arbeitet seine Warteschlange der Reihe nach ab (FIFO) und prüft nicht, ob der Client noch wartet. Eine Anfrage, deren Client schon aufgegeben hat, kostet trotzdem Kapazität. Der Simulator zählt sie als nutzlos.
  2. Eine Retry-Politik entscheidet, wie lange ein Client wartet, bevor er es wieder versucht. Sie hat zwei Methoden: neu() wird bei jeder neuen Anfrage aufgerufen, wartezeit(retry_nr) bei jedem Timeout. Gibt sie eine Zahl zurück, ist das die Wartezeit in Ticks (0 = sofort). Gibt sie None zurück, gibt der Client auf. retry_nr zählt ab 0: Der erste Retry hat retry_nr = 0.

Hier ist der Simulator. Du musst ihn nicht auswendig kennen, aber lies die drei Phasen pro Tick (a), (b), (c):

Ausgabe:

Last pro Tick: [8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 16, 16, 16, 24, 24, 24, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 24, 24, 24, 16, 16, 16, 8, 8, 8, 0]
Erfolge: 80 von 240 | nutzlos bearbeitet: 286 | Warteschlange am Ende: 354

Die Last steigt von 8 auf 32 pro Tick, das Dreifache der Kapazität, obwohl nie mehr als 8 neue Nutzer pro Tick kommen. Hier die ersten Ticks nach dem Ausfall, Zeile für Zeile:

Tick neue Retries Last gesamt Kapazität Warteschlange am Ende
9 8 0 8 11 0
10 8 0 8 0 8
11 8 0 8 0 16
12 8 0 8 0 24
13 8 8 16 0 40
14 8 8 16 11 45
15 8 8 16 11 50
16 8 16 24 11 63
17 8 16 24 11 76
18 8 16 24 11 89
19 8 24 32 11 110

So liest du die Tabelle: Die 8 Anfragen von Tick 10 laufen bei Tick 13 in den Timeout und kommen als 8 Retries zurück (Tick 13). Ab Tick 14 ist der Server wieder gesund, aber er schafft nur 11 pro Tick und es kommen 16. Die Warteschlange wächst, die Wartezeit steigt über 3 Ticks, also laufen auch die neuen Anfragen in den Timeout, werden wiederholt, und so weiter. Der Server arbeitet fleißig, aber fast nur nutzlose Anfragen ab. Selbst als ab Tick 30 keine neuen Nutzer mehr kommen, stehen am Ende noch 354 Anfragen in der Schlange.

flowchart LR
    A["Server langsam oder kurz ausgefallen"] --> B["Anfragen laufen in Timeouts"]
    B --> C["Clients wiederholen sofort"]
    C --> D["Mehr Last als vorher"]
    D --> E["Warteschlange wächst, noch mehr Timeouts"]
    E --> B

Einen solchen Zustand nennt man metastabil (metastable failure): Der Auslöser (der kurze Ausfall) ist längst weg, aber die Rückkopplung aus Retries hält das System im schlechten Zustand. Ein Neustart des Servers hilft nur kurz, weil die Clients sofort wieder anrennen.

Schritt 3: Exponentielles Backoff, Jitter und Retry-Budget

Drei Bausteine gegen den Sturm:

  1. Exponentielles Backoff (exponential backoff): Vor jedem weiteren Versuch wird länger gewartet. Obergrenze der Wartezeit: basis * 2^retry_nr, gedeckelt bei einem deckel (sonst wartest du irgendwann Stunden). Beispiel mit basis = 2, deckel = 16: 2, 4, 8, 16, 16, …
  2. Jitter (zufällige Streuung): Wenn alle Clients beim selben Timeout starten und alle genau 4 Ticks warten, kommen sie gleichzeitig wieder, nur verschoben. Mit Full Jitter wartet jeder eine zufällige Zeit zwischen 0 und der Obergrenze. Die Wiederholungen verteilen sich.
  3. Retry-Budget: Eine globale Obergrenze, z. B. “Retries dürfen höchstens 10 % der neuen Anfragen ausmachen”. Ist das Budget aufgebraucht, werden weitere Retries gar nicht erst gesendet (der Client gibt auf, fail fast).

Warum reicht Backoff allein nicht? Weil es die Retries nur verschiebt, nicht begrenzt. Jede fehlgeschlagene Anfrage wird irgendwann doch noch dreimal wiederholt. Gemessen im Simulator (feste Zufallsfolge Random(0), Referenzlösung aus Übung 2):

Politik Spitzenlast pro Tick Erfolge von 240 aufgegeben Warteschlange am Ende
sofort wiederholen 32 80 144 354
Backoff mit Jitter, ohne Budget 35 80 92 305
Backoff mit Jitter und Budget 10 % 14 166 74 0
gar nicht wiederholen 8 196 44 0

Zwei Dinge fallen auf. Erstens: Backoff ohne Budget rettet in dieser Simulation nichts, die Schlange bleibt voll. Erst das Budget begrenzt die Last. Zweitens: “gar nicht wiederholen” gewinnt hier sogar, weil der Server 4 Ticks lang komplett weg ist und jeder Retry in dieser Zeit sinnlos ist. Retries lohnen sich bei einzelnen verlorenen Anfragen (kurzer Netzwerkfehler), nicht bei einem überlasteten Server. Das Budget sorgt dafür, dass sie im Ernstfall nur einen kleinen Teil der Last ausmachen (allgemeines Fachwissen).

Den Rahmen, in dem du in Übung 2 arbeitest, siehst du hier. Politik verbindet deine zwei Bausteine mit dem Simulator:

class Politik:
    def __init__(self, backoff_fn, budget, rng, basis=2, deckel=16):
        self.backoff_fn, self.budget, self.rng = backoff_fn, budget, rng
        self.basis, self.deckel = basis, deckel
    def neu(self):                       # jede neue Anfrage zählt fürs Budget
        self.budget.neu()
    def wartezeit(self, retry_nr):       # bei jedem Timeout
        if not self.budget.erlaubt():    # Budget aufgebraucht: aufgeben
            return None
        return self.backoff_fn(retry_nr, self.basis, self.deckel, self.rng)

Falle

  1. Kein Timeout. Standardmäßig warten viele HTTP-Clients sehr lange oder ewig. Dann bestimmt der langsamste Downstream, wie lange deine Slots belegt sind.
  2. Sofort und unbegrenzt wiederholen. Jede Schicht eines Aufrufbaums, die selbst bis zu 3 Versuche macht, multipliziert: Bei 3 Schichten mit je 3 Versuchen werden aus 1 Anfrage bis zu 27 am untersten Service (3 mal 3 mal 3, allgemeines Fachwissen). Darum Budget und Retries nur an einer Stelle.
  3. Backoff ohne Jitter. Alle Clients springen gleichzeitig wieder an, nur später (Thundering Herd).
  4. Retries von nicht idempotenten Operationen. Ein Timeout sagt dir nicht, ob die Operation lief. Ohne Idempotency Key (siehe konzepte/01) wird doppelt gebucht.

Übungen

Übung 1: Dominoeffekt vorhersagen (ca. 6 Min.)

Du hast den Simulator domino in Schritt 1 gesehen. Hier ein neuer Fall: Service A hat 4 Slots. Pro Tick kommt eine Anfrage, 20 Ticks lang. Service B antwortet nach 6 Ticks.

Trage ein Tupel (a, b, c) ein:

  • a: Wie viele Anfragen werden abgewiesen, wenn A keinen Timeout hat?
  • b: Wie viele werden abgewiesen, wenn A einen Timeout von 3 Ticks hat?
  • c: Wie viele Slots sind mit diesem Timeout höchstens gleichzeitig belegt?

Ohne Timeout: Wann wird der erste Slot wieder frei, und wie viele Anfragen kommen bis dahin an? Mit Timeout: Wie lange hält ein Slot höchstens, und wie viele Anfragen kommen in dieser Zeit an?

antwort = (6, 0, 3)
antwort

Ohne Timeout: Tick 0 bis 3 füllen die vier Slots (frei bei 6, 7, 8, 9). Tick 4 und 5 werden abgewiesen. Ab Tick 6 gibt es pro Tick genau einen freien Slot, bis Tick 9. Danach sind wieder alle vier belegt (frei bei 12 bis 15), Tick 10 und 11 werden abgewiesen. Später wiederholt sich das Muster. Insgesamt 6 Abweisungen in 20 Ticks.

Mit Timeout 3: Jeder Slot ist höchstens 3 Ticks belegt, bei einer Anfrage pro Tick sind also höchstens 3 Slots gleichzeitig belegt. Es gibt immer einen freien Slot, nichts wird abgewiesen. Alle 20 Clients bekommen aber einen Timeout-Fehler.

Übung 2: Backoff, Jitter und Retry-Budget selbst schreiben (ca. 20 Min.)

Jetzt baust du die beiden Bausteine aus Schritt 3. Der Simulator aus Schritt 2 (Server 11 pro Tick, Ausfall Tick 10 bis 13, Timeout 3) ist schon geladen, ebenso Politik und simuliere. Du schreibst:

backoff(versuch, basis, deckel, rng) gibt die Wartezeit in ganzen Ticks (int) zurück:

  • Obergrenze: min(deckel, basis * 2 ** versuch). versuch zählt ab 0.
  • Die Wartezeit ist zufällig zwischen 0 und der Obergrenze (beide eingeschlossen, Full Jitter). Nimm rng.randint(a, b). Das Zufallsobjekt rng bekommst du übergeben, benutze nicht random.random() direkt (der Check muss die Folge fest vorgeben können).
  • Beispiel basis=2, deckel=16: Obergrenze 2, 4, 8, 16, 16, …

RetryBudget(anteil) begrenzt die Retries:

  • neu() meldet eine neue Anfrage.
  • erlaubt() gibt True zurück und zählt einen Retry, wenn danach die Zahl der Retries höchstens anteil * Zahl der neuen Anfragen ist. Sonst False, und es wird nichts gezählt.
  • Beispiel RetryBudget(0.5) nach 4 mal neu(): erlaubt() liefert True, True, dann False.

Danach läuft der Check den Simulator mit deiner Politik (Budget 10 %) über 8 Zufallsfolgen. Du bestehst, wenn die Spitzenlast höchstens 20 pro Tick bleibt (naiv: 32), die Warteschlange am Ende leer ist (naiv: 354) und mindestens 120 Anfragen erfolgreich sind (naiv: 80).

Backoff: Erst die Obergrenze ausrechnen, dann zufällig darunter wählen. Budget: Du brauchst zwei Zähler (neue Anfragen, bisher erlaubte Retries). Wann darfst du den Retry-Zähler erhöhen, und wann darf er sich auf keinen Fall ändern?

def backoff(versuch, basis, deckel, rng):
    obergrenze = min(deckel, basis * 2 ** versuch)
    return rng.randint(0, obergrenze)

class RetryBudget:
    def __init__(self, anteil):
        self.anteil = anteil
        self.neue = 0
        self.retries = 0
    def neu(self):
        self.neue += 1
    def erlaubt(self):
        if self.retries + 1 <= self.anteil * self.neue:
            self.retries += 1
            return True
        return False

(backoff, RetryBudget)

rng.randint(0, obergrenze) ist Full Jitter: Die Wartezeit liegt irgendwo zwischen sofort und der Obergrenze. Das Budget zählt nur tatsächlich erlaubte Retries. Ein abgelehnter Retry darf den Zähler nicht verändern.

Übung 3: Wie viel Last kommt unten an? (ca. 6 Min.)

Du hast in der Falle gesehen, dass Retries über mehrere Schichten multiplizieren. Jetzt schreibst du die Rechnung selbst. Ein Aufrufbaum hat mehrere Schichten (App, Gateway, Dienst, …). Für jede Schicht kennst du, wie oft sie wiederholt, nachdem der erste Versuch gescheitert ist. Ein Versuch ist also immer 1 plus die Wiederholungen der Schicht.

Schreibe verstaerkung(wiederholungen): Die Liste enthält pro Schicht die Zahl der Wiederholungen. Die Funktion liefert zurück, wie viele Anfragen im schlimmsten Fall am untersten Service für eine Anfrage von oben ankommen, wenn jeder Versuch einer oberen Schicht bis unten durchläuft und überall scheitert.

Beispiel: verstaerkung([1, 1]) ist 4 (jede der zwei Schichten macht 2 Versuche, 2 mal 2). verstaerkung([]) ist 1: ohne Schichten kommt eine Anfrage an.

Jeder Versuch einer oberen Schicht löst eine komplette Folge von Versuchen in der Schicht darunter aus. Verknüpft das die Zahlen durch Addition oder durch eine andere Rechenart? Und was ist ein Versuch, wenn eine Schicht gar nicht wiederholt?

def verstaerkung(wiederholungen):
    ergebnis = 1
    for w in wiederholungen:
        ergebnis *= 1 + w
    return ergebnis

verstaerkung

Pro Schicht gibt es 1 + w Versuche, und jeder davon ruft die nächste Schicht komplett auf. Darum werden die Versuche multipliziert, nicht addiert. Eine Schicht ohne Wiederholungen (w = 0) ändert nichts (Faktor 1).

Merksatz

Ein langsamer Service ist gefährlicher als ein toter: Setze Timeouts, und wiederhole nur mit Backoff, Jitter und Budget an einer einzigen Stelle des Aufrufbaums, sonst wird aus einem kurzen Ausfall ein Retry-Sturm.

Prüfstein

  1. Ein Retry-Sturm legt einen Service lahm, der eigentlich wieder gesund wäre. Wie kam es dazu, und wie verhinderst du es?
  2. Warum reicht exponentielles Backoff allein nicht, und was ergänzt du?

Weiter geht es in Teil 2: Circuit Breaker, Rate Limiting, Bulkhead.


Quelle: quellen/konzeptuebersicht-software-grundlagen.md, Abschnitt “7. Verteilte Systeme und Betrieb” (Resilienz: Timeouts, Retries mit Backoff); quellen/konzeptuebersicht-software-fortgeschritten.docx, Schicht 7 “Resilienz” (Retry Storms, metastabile Ausfälle, Prüfstein zum Retry-Sturm).

Über die Quelle hinaus (allgemeines Fachwissen): die Erklärung der Slot-Erschöpfung, Full Jitter und die Formel für die Obergrenze, die Definition des Retry-Budgets, die Multiplikation von Retries über mehrere Schichten (Übung 3). Alle Zahlen im Text stammen aus dem Ausführen des Codes dieser Lektion (Simulator mit festen Parametern und fester Zufallsfolge), nicht aus Messungen realer Systeme.