Be Software Architecture Wise
Be Wise Apps

Read in English · Leer en español · Lire en français

Abschnitt 9.4 von 9.8 · Fortgeschritten · 32 Min. Lesezeit

Timeouts, Wiederholungen, Circuit Breaker, Bulkheads und Graceful Degradation

Videolektion

5 Min. · auf Englisch gesprochen, mit Untertiteln
Nur anhören (Audio)
Transkript lesen

Willkommen zu Abschnitt neun Punkt vier. In einem verteilten System ist immer irgendeine Abhängigkeit langsam oder fällt aus. Die Frage ist nicht, wie man Ausfälle vermeidet, sondern wie man verhindert, dass ein lokaler Ausfall zu einem globalen wird.

Stellen Sie sich vor, Sie rufen einen Lieferanten an, der nie abnimmt. Wenn Sie den ganzen Tag in der Leitung bleiben, erledigen Sie nichts anderes. Wenn alle Kollegen dasselbe tun, steht das ganze Büro still. Vernünftige Büros legen nach ein paar Klingeltönen auf. Das ist ein Timeout. Sie versuchen es etwas später erneut, aber nicht endlos. Das ist eine Wiederholung mit Pause. Sie rufen einen Lieferanten, der offensichtlich geschlossen hat, nicht mehr an und prüfen in einer Stunde erneut. Das ist ein Circuit Breaker. Sie sorgen dafür, dass ein Lieferant nicht alle Telefone belegen kann. Das ist ein Bulkhead. Und sie haben einen Plan B, etwa die Preisliste von gestern. Das ist ein Fallback. Softwareservices brauchen genau dieselben Gewohnheiten.

Darum ist das wichtig. Der Bestellservice der Essenslieferplattform hat zwölfhundert Threads auf sechs Instanzen. Zur Mittagszeit sind laut Littles Gesetz nur fünfzig belegt: fünfunddreißig für Checkouts, die den Zahlungsanbieter aufrufen, und fünfzehn für alles andere. Um zwölf Uhr einunddreißig antwortet der Zahlungsanbieter nicht mehr, hält die Verbindungen aber offen. Der Zahlungsclient hat keinen Timeout, also hält jeder Checkout seinen Thread unbegrenzt. Jede Sekunde hängen hundert weitere Threads fest. Innerhalb von etwa zwölf Sekunden wartet jeder Thread auf den Zahlungsanbieter. Jetzt scheitern auch Bestellhistorie, Warenkorb und Sendungsverfolgung, obwohl sie Zahlungen nie berühren. Ein Ausfall in fünfundzwanzig Prozent des Verkehrs hat hundert Prozent lahmgelegt. Mit einem Timeout von zwei Sekunden belegt der Checkout höchstens zweihundert Threads, sodass etwa neunhundertfünfundachtzig für alles andere bleiben. Nur der Checkout scheitert, und zwar eindeutig. Ein Bulkhead geht weiter. Die Begrenzung der Zahlungsaufrufe auf vierzig Threads pro Instanz garantiert mindestens neunhundertsechzig Threads für alles andere, unabhängig von den Timeouts.

Timeouts sollten entlang einer Aufrufkette kleiner werden. Eine Deadline macht das automatisch. Die mobile App setzt für die ganze Anfrage eine Deadline von tausend Millisekunden. Das Gateway verbraucht zwanzig Millisekunden und gibt die Restzeit weiter: neunhundertachtzig. Der Bestellservice verbraucht fünfzig, dann dreihundertzwanzig für andere Aufrufe, und gibt sechshundertzehn an die Preisgestaltung weiter. Die Preisgestaltung braucht etwa achthundert Millisekunden und schlägt daher sofort fehl, statt Arbeit zu leisten, auf die niemand wartet.

Wiederholungen verwandeln kurze Störungen in Erfolge. Aber Wiederholungen sind auch Last. Wiederholen Sie nur vorübergehende Fehler, etwa Timeouts oder ein fünf null drei, und nur idempotente Operationen, denn eine Anfrage mit Timeout kann erfolgreich gewesen sein. Wiederholen Sie zwei- oder dreimal und warten Sie jedes Mal länger: exponentielles Backoff, bis zu einer Obergrenze. Fügen Sie Jitter hinzu, eine zufällige Wartezeit. Sonst wiederholen tausend Clients, die gemeinsam gescheitert sind, auch gemeinsam, in Wellen, die die Abhängigkeit unten halten. Und Wiederholungen multiplizieren sich über Schichten. Drei Versuche auf drei Schichten ergeben siebenundzwanzig Aufrufe. Wiederholen Sie also nur auf einer Schicht und begrenzen Sie Wiederholungen auf etwa zehn Prozent der Anfragen.

Ein Circuit Breaker umhüllt Aufrufe an eine Abhängigkeit. Solange er geschlossen ist, gehen Aufrufe durch, und Fehler werden gezählt. Überschreiten die Fehler eine Schwelle, zum Beispiel die Hälfte der letzten zwanzig Aufrufe, öffnet der Breaker. Jetzt schlägt jeder Aufruf schnell fehl, ohne das Netzwerk zu berühren. Das schützt die Threads des Aufrufers und gibt der Abhängigkeit Raum zur Erholung. Nach einer Wartezeit, zum Beispiel dreißig Sekunden, wird er halboffen und lässt einige Testaufrufe durch. Gelingen sie, schließt er wieder. Scheitert ein Testaufruf, öffnet er sich für einen weiteren Zeitraum.

Ein Bulkhead ist nach den wasserdichten Abteilungen eines Schiffs benannt. Hier bekommt jede Abhängigkeit ihren eigenen begrenzten Thread-Pool. Der Zahlungsanbieter ist langsam, also füllt sich sein Pool, und weitere Zahlungsaufrufe werden sofort abgelehnt. Aber Restaurant- und Kurieraufrufe laufen normal weiter. Ein Timeout begrenzt, wie lange ein Aufruf Ressourcen halten kann. Ein Bulkhead begrenzt, wie viele gleichzeitig gehalten werden können.

Scheitert ein Aufruf, muss der Aufrufer trotzdem antworten. Ein Fallback kann die zuletzt bekannte Speisekarte sein, eine geschätzte Lieferzeit, beliebte Gerichte statt persönlicher Empfehlungen oder ein klarer, schneller Fehler. Das ist Graceful Degradation: Die Kernabläufe funktionieren weiter, während Extras zurücktreten. Ein Fallback muss einfach, unabhängig vom ausgefallenen Teil und regelmäßig getestet sein. Sicherheit ist die Ausnahme. Ist der Autorisierungsservice nicht erreichbar, ist der sichere Fallback, zu verweigern. Prüfen Sie schließlich, ob eine Operation idempotent ist, bevor Sie sie wiederholen. Eine Retry-Policy für einen Zahlungsaufruf ohne Idempotenzschlüssel ist kein Resilienzmuster. Sie ist ein Generator für Doppelabbuchungen.

Der vollständige Abschnitt ergänzt Hedged Requests und eine Checkliste für jeden Remote-Aufruf. Als Nächstes betrachten wir in Abschnitt neun Punkt fünf, wie gut gut genug ist. Bis gleich.

Was Sie wissen müssen

Ausfälle sind normal; ihre Ausbreitung ist es nicht

In einem verteilten System ist immer irgendeine Abhängigkeit langsam oder fällt aus: Ein Zahlungsanbieter hat eine schlechte Minute, eine Datenbank macht ein Failover, eine Netzwerkverbindung verliert Pakete, ein Deployment geht schief. Abschnitt 6.1 hat gezeigt, warum sich solche Teilausfälle nicht verhindern lassen und warum ein Aufrufer oft nicht einmal erkennen kann, was passiert ist. Die architektonische Frage lautet daher nicht, wie man Ausfälle vermeidet, sondern wie man verhindert, dass ein lokaler Ausfall zu einem globalen wird. Ein kaskadierender Ausfall ist die typische Art, wie Systeme sterben: Eine langsame Abhängigkeit macht ihre Aufrufer langsam, deren Ressourcen füllen sich mit wartenden Anfragen, sie werden ihrerseits für ihre eigenen Aufrufer langsam, und binnen Sekunden hat ein Problem in einer unbedeutenden Komponente die ganze Plattform lahmgelegt.

Michael Nygard hat die Muster, die solche Ketten unterbrechen, in Release It! (2007; zweite Auflage 2018) katalogisiert und sich dabei auf Ausfälle gestützt, die er untersucht hatte. Seine zentrale Beobachtung ist, dass Integrationspunkte, also jede Stelle, an der ein System ein anderes aufruft, die Hauptquelle von Instabilität sind und dass jeder einzelne gezielten Schutz braucht. Die Muster in diesem Abschnitt (Timeouts, Deadlines, Wiederholungen mit Backoff, Circuit Breaker, Bulkheads, Fallbacks, Idempotenz und Hedging) sind der Standardwerkzeugkasten. Sie sind Taktiken im Sinne von Abschnitt 2.5: Jede ist eine einzelne Entscheidung, die auf Verfügbarkeit zielt, und jede hat ihren Preis.

Timeouts: das erste und wichtigste Muster

Ein Timeout begrenzt, wie lange ein Aufrufer wartet. Ohne Timeout hält ein Aufruf an eine Abhängigkeit, die nie antwortet, einen Thread, eine Verbindung und Speicher so lange, wie das Betriebssystem die Verbindung offen hält, und das können viele Minuten sein. Viele Client-Bibliotheken haben standardmäßig keinen Timeout (die weit verbreitete Python-Bibliothek requests wartet unbegrenzt, wenn man nichts anderes angibt); das Fehlen eines Timeouts ist also eine Entwurfsentscheidung durch Unterlassen. Das ausgearbeitete Beispiel unten zeigt die Folgen.

Meist kommt es auf zwei Timeouts an. Der Connect-Timeout begrenzt den Verbindungsaufbau und kann kurz sein, innerhalb eines Rechenzentrums wenige hundert Millisekunden, weil ein gesunder Server Verbindungen fast sofort annimmt. Der Request-Timeout begrenzt den gesamten Austausch und sollte aus Messungen abgeleitet werden: knapp über der hohen Perzentil-Latenz der Abhängigkeit (p99 oder p99.9) unter normaler Last, sodass er krankhafte Wartezeiten abschneidet, ohne gewöhnliche langsame Anfragen scheitern zu lassen. Er muss außerdem in das eigene Budget des Aufrufers passen. Gibt das Gateway nach 2 Sekunden auf, wartet der Bestellservice aber 10 Sekunden auf die Preisgestaltung, arbeitet der Bestellservice 8 Sekunden lang an einer Anfrage, auf die niemand wartet. Timeouts sollten daher entlang einer Aufrufkette kleiner werden, und das ist die Idee hinter Deadlines.

Deadlines und ihre Weitergabe

Eine Deadline ist ein absoluter Zeitpunkt, bis zu dem die gesamte Anfrage abgeschlossen sein muss. Deadline-Weitergabe gibt die verbleibende Zeit an jeden nachgelagerten Aufruf weiter, sodass jeder Service weiß, wie viel Budget noch übrig ist, statt einen eigenen festen Timeout anzuwenden. gRPC hat das eingebaut: Ein Client setzt eine Deadline, die verbleibende Zeit reist mit dem Aufruf mit, und ein Server kann prüfen, ob die Deadline überschritten ist, und die Arbeit einstellen. Über HTTP geben Teams einen Header mit den verbleibenden Millisekunden oder der absoluten Deadline weiter, idealerweise über dieselbe Middleware, die auch den Tracing-Kontext weitergibt (Abschnitt 9.6).

Die Weitergabe bringt zwei Vorteile. Ein Service kann schnell fehlschlagen, wenn die verbleibende Zeit offensichtlich zu kurz für die Arbeit ist, statt eine Datenbankabfrage zu starten, die nicht rechtzeitig fertig werden kann. Und wenn der Benutzer aufgibt, stoppt auch die Arbeit darunter, statt weiter Kapazität zu verbrauchen, und das genau während der Überlast, die die Verzögerung verursacht hat. Das ist Load Shedding toter Anfragen, einer der wirksamsten Schutzmechanismen, die ein System unter Stress haben kann.

Eine Deadline, die durch eine Aufrufkette weitergegeben wird

Eine Anfrage beginnt mit einer Deadline von 1000 ms; jeder Service gibt die Restzeit weiter, und ein Service, der in der Restzeit nicht fertig wird, schlägt sofort fehl, statt anzufangenMobile AppDeadline 1000 msAPI-Gatewayverbleibend 980Bestellserviceverbleibend 930Preiseverbleibend 610sendetsendetsendet20 ms genutzt50 ms genutztdann 320 ms fürweitere Callsbraucht etwa 800 ms:Fail fast sofortJeder Hop sendet die Restzeit, keinen eigenen neuen Timeout
Jeder Hop gibt die verbleibende Zeit weiter; ein Service, der in der Restzeit nicht fertig werden kann, schlägt sofort fehl, statt Arbeit zu leisten, auf die niemand wartet.

Ausgearbeitetes Beispiel

Ausgearbeitetes Beispiel: wie ein fehlender Timeout zum Totalausfall wird

Der Bestellservice der Essenslieferplattform läuft mit 6 Instanzen zu je 200 Worker-Threads: 1200 Threads. Zur Mittagszeit erhält er 400 Anfragen pro Sekunde: 100 Checkouts, die den Zahlungsanbieter aufrufen (normalerweise 300 ms, sodass ein Checkout seinen Thread insgesamt etwa 350 ms hält), und 300 andere Anfragen (Bestellhistorie, Warenkorb, Sendungsverfolgung), die 50 ms dauern. Für den Zahlungsclient ist kein Timeout konfiguriert. Um 12:31 antwortet der Zahlungsanbieter nicht mehr, hält die Verbindungen aber offen.

Normal belegte Threads (Littles Gesetz)Checkout 100 × 0,35 s = 35; übriger Verkehr 300 × 0,05 s = 15; insgesamt 50 von 1200 (etwa 4 %)
Nach 12:31Jeder Checkout hält seinen Thread nun unbegrenzt: Jede Sekunde hängen 100 weitere Threads fest
Zeit bis zur Erschöpfung(1200 − 15 − 35) ÷ 100 ≈ 11,5 Sekunden: Innerhalb von etwa 12 Sekunden wartet jeder Thread auf den Zahlungsanbieter
FolgeBestellhistorie, Warenkorb und Sendungsverfolgung, die Zahlungen nie aufrufen, warten jetzt auf Threads und laufen am Gateway in den Timeout. Ein Ausfall in 25 % des Verkehrs hat 100 % lahmgelegt
Und weiter obenDie Verbindungen des Gateways zum Bestellservice stauen sich auf dieselbe Weise; Health Checks laufen in den Timeout, Instanzen werden als ungesund markiert, und wenn die Liveness-Probe betroffen ist, werden sie neu gestartet, kalt, in dieselben Bedingungen hinein
Mit einem Timeout von 2 SekundenBelegte Checkout-Threads: 100 × 2 s = 200 über die ganze Flotte (Littles Gesetz). Etwa 985 Threads bleiben für den übrigen Verkehr, also scheitert nur der Checkout, und zwar mit einem klaren Fehler nach 2 s
Ein Circuit Breaker kommt hinzuSobald etwa die Hälfte der letzten 20 Aufrufe gescheitert ist, öffnet der Breaker: Checkouts scheitern in Millisekunden und belegen fast keine Threads; ein Testaufruf alle 30 s erkennt die Erholung
Ein Bulkhead kommt hinzuDie Begrenzung der Zahlungsaufrufe auf 40 Threads pro Instanz (240 insgesamt) garantiert mindestens 960 Threads für alles andere, unabhängig von den gesetzten Timeouts
Ein Fallback kommt hinzuDer Checkout bietet dem Kunden eine andere Zahlungsart an oder erklärt, dass Kartenzahlungen verzögert sind, statt eines allgemeinen Fehlers

Die Rechnung ist wieder Littles Gesetz: belegte Threads = Anfragerate × Zeit, die jede Anfrage einen Thread hält. Ohne Timeout ist die Haltezeit unbegrenzt, also auch die Zahl der benötigten Threads.

Timeouts begrenzen, wie lange; Bulkheads begrenzen, wie viele; Circuit Breaker beenden die Aufrufe ganz. Jedes Muster schließt eine andere Lücke, deshalb werden sie zusammen eingesetzt.

Nygards Release It! beschreibt echte Ausfälle, die genau diesem Muster folgten, oft ausgelöst durch einen Integrationspunkt, den niemand für kritisch hielt.

Erschöpfung des Thread-Pools

Ein langsamer Zahlungsanbieter hält jeden Worker-Thread des Bestellservice, sodass auch Anfragen, die keine Zahlungen brauchen, und Aufrufe einer gesunden Datenbank scheiternCheckoutBestellhistorie,TrackingBestellservicealle 200 Threads wartenauf ZahlungscallsZahlungsanbieterantwortet nicht;sonst 0,3 sDatenbankgesund, aber keinThread frei für Aufrufbeide Fehlerkein Thread frei
Ohne Timeout hält ein langsamer Zahlungsanbieter jeden Thread des Bestellservice, sodass selbst Anfragen scheitern, die Zahlungen nie berühren.

Wiederholungen mit exponentiellem Backoff und Jitter

Viele Fehler sind vorübergehend: ein verlorenes Paket, ein Verbindungsabbruch während eines Failovers, ein kurzes 503 von einer überlasteten Instanz. Eine Wiederholung (Retry) verwandelt sie in Erfolge, die der Benutzer nie bemerkt. Doch Wiederholungen sind auch Last, und schlecht entworfene Wiederholungen gehören zu den häufigsten Ursachen von Ausfällen. Vier Regeln machen sie sicher. Wiederholen Sie nur vorübergehende Fehler (Timeouts, Verbindungsfehler, 502, 503, 504 und 429 nach der angegebenen Wartezeit), niemals Fehler, die erneut auftreten werden, etwa 400 oder 404. Wiederholen Sie nur idempotente Operationen oder solche mit einem Idempotenzschlüssel, denn eine Anfrage mit Timeout kann trotzdem erfolgreich gewesen sein. Wiederholen Sie nur wenige Male, typischerweise zwei- oder dreimal. Und warten Sie zwischen den Versuchen, mit exponentiellem Backoff: einer Wartezeit, die sich mit jedem Versuch verdoppelt, bis zu einer Obergrenze.

Backoff allein reicht nicht. Scheitern tausend Clients im selben Moment, wie es bei einem kurzen Aussetzer einer Abhängigkeit geschieht, wiederholen sie alle nach 100 ms, dann alle nach 200 ms und treffen in synchronisierten Wellen ein, die die Abhängigkeit unten halten können. Marc Brookers Artikel Exponential Backoff And Jitter (AWS Architecture Blog, 2015) hat das simuliert und gezeigt, dass Zufall, Jitter, sowohl die Gesamtarbeit als auch die Zeit bis zum Abschluss stark verringert. Sein empfohlenes Full Jitter wartet eine zufällige Zeit zwischen null und der exponentiellen Schranke: sleep = random(0, min(cap, base × 2attempt)). Jitter gehört zu allem, was viele Clients gleichzeitig tun: Wiederholungen, Neuverbindungen, Ablaufzeiten von Cache-Einträgen und geplante Jobs.

import random, time

class TransientError(Exception):
    """Timeouts, connection resets, 502, 503 and 504 responses."""

def call_with_retries(fn, attempts=3, base=0.1, cap=2.0):
    for attempt in range(attempts):
        try:
            return fn()
        except TransientError:
            if attempt == attempts - 1:
                raise                      # give up: let the caller decide
            ceiling = min(cap, base * 2 ** attempt)
            time.sleep(random.uniform(0, ceiling))   # full jitter
Wiederholung eines vorübergehenden Fehlers mit begrenztem exponentiellem Backoff und Full Jitter (Brooker, 2015). Nur idempotente Operationen oder solche mit einem Idempotenzschlüssel sollten wiederholt werden.

Retry-Stürme und Retry-Budgets

Wiederholungen multiplizieren sich über Schichten. Angenommen, ein Tippen in der mobilen App läuft durch das API-Gateway, den Bestellservice und den Zahlungsclient, und jede der drei Schichten unternimmt bis zu drei Versuche. Ist der Zahlungsanbieter ausgefallen, erzeugt eine Benutzeraktion 3 × 3 × 3 = 27 Aufrufe an ihn, und ein Ausfall, der eine gleichmäßige Fehlerrate hätte bedeuten sollen, wird zu einem Retry-Sturm mit der 27-fachen normalen Last, der den Anbieter auch dann noch unten hält, wenn die ursprüngliche Ursache behoben ist. Benutzer, die erneut tippen, fügen eine vierte Schicht hinzu.

Drei Abwehrmaßnahmen wirken zusammen. Erstens: Wiederholen Sie nur auf einer Schicht, meist der, die der fehlschlagenden Abhängigkeit am nächsten ist, und lassen Sie die anderen Schichten den Fehler nach oben weiterreichen; Googles SRE-Buch empfiehlt, dass ein überlasteter Server das ausdrücklich meldet, damit Aufrufer weiter oben nicht wiederholen. Zweitens: Nutzen Sie ein Retry-Budget: Das SRE-Buch beschreibt, Wiederholungen nur zuzulassen, solange sie unter 10 % der Anfragen eines Clients liegen, was die zusätzliche Last durch Wiederholungen auf etwa das 1,1-Fache des Normalen begrenzt statt auf bis zum Dreifachen. AWS-SDKs implementieren ein ähnliches Kontingent an Retry-Tokens, und die Retry-Policy von gRPC enthält Throttling zum selben Zweck. Drittens: Kombinieren Sie Wiederholungen mit einem Circuit Breaker, damit eine Abhängigkeit, von der bekannt ist, dass sie ausgefallen ist, gar nicht erst erneut aufgerufen wird.

Circuit Breaker

Ein Circuit Breaker, von Nygard nach dem elektrischen Schutzschalter benannt, umhüllt Aufrufe an eine Abhängigkeit und beobachtet deren Ergebnisse. Solange die Abhängigkeit gesund ist, ist der Breaker geschlossen und Aufrufe gehen durch, während Fehler und Timeouts gezählt werden. Überschreiten die Fehler eine Schwelle, zum Beispiel die Hälfte der letzten 20 Aufrufe, öffnet der Breaker: Für einen festgelegten Zeitraum scheitert jeder Aufruf sofort, ohne das Netzwerk zu berühren, was die Threads des Aufrufers schützt und der Abhängigkeit Raum zur Erholung gibt. Läuft der Zeitraum ab, wird der Breaker halboffen und lässt einige Testaufrufe durch. Gelingen sie, schließt er; scheitert einer, öffnet er erneut für einen weiteren Zeitraum. Martin Fowlers Beschreibung von 2014 machte das Muster weithin bekannt, und Netflix’ Bibliothek Hystrix (heute im Wartungsmodus) machte es bei Microservices populär; Resilience4j für Java und Polly für .NET sind heute die gängigen Bibliotheken, und Service Meshes bieten verwandte Schutzmechanismen wie Nebenläufigkeitslimits und Outlier Detection (in Envoy heißen sie Circuit Breaking, obwohl sie anders funktionieren).

Der Zustandsautomat des Circuit Breaker

Zustände des Circuit Breaker: Closed lässt Aufrufe durch und zählt Fehler; zu viele Fehler öffnen ihn, Aufrufe schlagen schnell fehl; nach einer Wartezeit wird er halboffen, lässt Testaufrufe durch, schließt bei Erfolg, öffnet bei FehlschlagClosedAufrufe gehen durch;Fehler gezähltOpenFail fast;kein Remote-CallHalf-openeinige TestaufrufedurchgelassenFehlerrate über Schwelle(z. B. 50 % der letzten 20 Aufrufe)nach Wartezeit(z. B. 30 s)Testaufruf scheitertTestaufrufegelingen
Closed zählt Fehler, Open schlägt schnell fehl, und Half-open prüft die Erholung mit einigen Testaufrufen.

Die Einstellungen verdienen Überlegung. Was zählt als Fehler? Timeouts und 5xx-Antworten sollten zählen; 4xx-Antworten meist nicht, weil sie die Anfrage des Aufrufers widerspiegeln, nicht die Gesundheit der Abhängigkeit. Auch langsame Erfolge können zählen, sodass eine Abhängigkeit, die in 5 Sekunden antwortet, als fehlerhaft behandelt wird. Das Fenster muss groß genug sein, um nicht bei ein paar unglücklichen Aufrufen auszulösen (Bibliotheken verlangen eine Mindestzahl an Aufrufen), und der offene Zeitraum lang genug für die Erholung, aber kurz genug, um einen Ausfall nicht zu verlängern. Breaker sollten pro Abhängigkeit, manchmal pro Endpoint, eingerichtet werden, damit ein fehlerhafter Reporting-Endpoint nicht das Bestellen abschneidet. Wiederholungen sollten einen offenen Breaker als endgültige Antwort behandeln, nicht als etwas, das man wiederholt. Und jeder Zustandswechsel sollte geloggt und grafisch dargestellt werden, denn ein offener Breaker ist ein Symptom, das jemand sehen muss.

import time

class CircuitOpen(Exception):
    pass

class CircuitBreaker:
    def __init__(self, failure_threshold=5, reset_after=30.0):
        self.failure_threshold = failure_threshold  # failures in a row that trip it
        self.reset_after = reset_after              # seconds to stay open
        self.failures, self.state, self.opened_at = 0, "closed", 0.0

    def call(self, fn, *args, **kwargs):
        if self.state == "open":
            if time.monotonic() - self.opened_at < self.reset_after:
                raise CircuitOpen("failing fast")   # no remote call at all
            self.state = "half-open"                # let a trial call through
        try:
            result = fn(*args, **kwargs)
        except Exception:
            self.failures += 1
            if self.state == "half-open" or self.failures >= self.failure_threshold:
                self.state, self.opened_at = "open", time.monotonic()
            raise
        self.failures, self.state = 0, "closed"    # success closes the circuit
        return result

payments = CircuitBreaker()
# payments.call(provider.authorise, order_id, amount, timeout=2.0)
Ein minimaler Circuit Breaker. Produktionsbibliotheken wie Resilience4j (Java) und Polly (.NET) ergänzen Fehlerratenfenster, Erkennung langsamer Aufrufe, Limits für gleichzeitige Testaufrufe, Thread-Sicherheit und Metriken.

Bulkheads

Der Rumpf eines Schiffs ist durch Schotten (Bulkheads) in wasserdichte Abteilungen geteilt, sodass ein Leck eine Abteilung flutet, statt das Schiff zu versenken. In Software partitioniert ein Bulkhead Ressourcen, sodass ein fehlerhafter oder überlasteter Teil nicht alles verbrauchen kann. Die häufigste Form gibt jeder Abhängigkeit einen eigenen begrenzten Thread-Pool, Connection-Pool oder Semaphor: Zahlungen dürfen höchstens 40 gleichzeitige Aufrufe pro Instanz nutzen, Restaurantabfragen 40, Kurierzuweisung 40. Werden Zahlungen langsam, füllt sich ihr Pool, und weitere Zahlungsaufrufe werden sofort abgelehnt, aber Restaurant- und Kurieraufrufe laufen weiter. Ein Timeout begrenzt, wie lange jeder Aufruf Ressourcen halten kann; ein Bulkhead begrenzt, wie viele gleichzeitig gehalten werden können, und schützt daher selbst dann, wenn Timeouts zu lang gesetzt sind.

Bulkheads wirken auch im größeren Maßstab. Getrennte Deployments desselben Service für verschiedene Aufrufer (ein Pool von Bestellservice-Instanzen für die Kunden-App, ein anderer für die Restaurantpartner-API) verhindern, dass eine fehlerhafte Partnerintegration die Kunden aushungert. Zellbasierte Architekturen, die Z-Achse des Scale Cube, machen jede Zelle zu einem Bulkhead für einen Teil der Kunden, und Shuffle Sharding, von Fachleuten bei AWS beschrieben, weist jedem Kunden eine zufällige kleine Kombination von Instanzen zu, sodass der giftige Verkehr eines Kunden nur sehr wenige andere trifft. Der Preis jedes Bulkheads ist Auslastung: Für eine Abteilung reservierte Kapazität kann nicht an eine andere verliehen werden, daher müssen Pools anhand des gemessenen Bedarfs dimensioniert werden.

Bulkheads innerhalb eines Service

Bulkheads: Der Bestellservice gibt jeder Abhängigkeit einen eigenen kleinen Thread-Pool, sodass ein langsamer Zahlungsanbieter nur den Zahlungspool füllt; Restaurant- und Kurieraufrufe laufen weiterAnfragenBestellserviceZahlungspool: vollRestaurantpoolKurierpoolZahlungenträgeRestaurantsgesundKurieregesund
Mit einem eigenen Pool pro Abhängigkeit füllt ein langsamer Zahlungsanbieter nur den Zahlungspool; Restaurant- und Kurieraufrufe laufen normal weiter.

Fallbacks und Graceful Degradation

Scheitert ein Aufruf oder ist ein Breaker offen, muss der Aufrufer trotzdem antworten. Ein Fallback legt fest, was er zurückgibt. Die Optionen, von der besten zur schlechtesten für den Benutzer, sind: ein zwischengespeicherter oder veralteter Wert (die zuletzt bekannte Speisekarte, mit Preisen, die im Bezahlvorgang bestätigt werden); ein sinnvoller Standardwert (eine geschätzte Lieferzeit von 30 bis 45 Minuten statt einer Live-Schätzung); eine eingeschränkte Funktion (beliebte Gerichte statt personalisierter Empfehlungen); die Arbeit aufschieben (eine Bewertung annehmen und veröffentlichen, wenn sich der Bewertungsservice erholt hat); und ein klarer, schneller Fehler, der dem Benutzer sagt, was zu tun ist. Diese pro Feature gemeinsam mit dem Product Owner zu entwerfen, ist das, was Graceful Degradation in der Praxis bedeutet: Die Kernabläufe (stöbern, bestellen, bezahlen, verfolgen) funktionieren weiter, während nicht wesentliche Features zurücktreten. Dasselbe Denken leitet Load Shedding (Abschnitt 9.3): Wissen Sie im Voraus, welche Features Sie zuerst abschalten.

Sicherheitsentscheidungen sind die Ausnahme: Ist der Autorisierungsservice nicht erreichbar, ist der sichere Fallback, zu verweigern (Fail-closed), nicht zu erlauben. Fachleute sind sich bei Fallbacks auch allgemein uneins. Nygard und die Fachleute bei Netflix befürworten sie breit. Fachleute bei Amazon haben im Builders’-Library-Artikel Avoiding fallback in distributed systems argumentiert, dass Fallback-Pfade selten durchlaufen werden, schwer zu testen sind und selbst Ausfälle auslösen können, indem sie eine große Flotte im ungünstigsten Moment in einen ungetesteten Modus schalten; sie ziehen es vor, den primären Pfad zuverlässiger zu machen und schnell fehlzuschlagen. Beide Positionen sind sich über die praktische Regel einig: Ein Fallback muss einfacher sein als das, was er ersetzt, darf nicht von der ausgefallenen Komponente abhängen und muss regelmäßig durchlaufen werden, zum Beispiel an Game Days (Abschnitt 9.8), sonst sollte es ihn nicht geben.

Idempotenz: was Wiederholungen und Hedging sicher macht

Idempotenz ist die Eigenschaft, dass etwas zweimal zu tun dieselbe Wirkung hat wie es einmal zu tun. Jedes Muster in diesem Abschnitt, das Arbeit wiederholt, hängt davon ab: Wiederholungen nach einem Timeout, Hedged Requests, erneute Zustellung von Nachrichten nach dem Absturz eines Consumers und Replays aus einer Dead Letter Queue. Abschnitt 6.5 hat gezeigt, wie ein Idempotenzschlüssel POST /orders sicher wiederholbar macht, und Abschnitt 6.4, wie idempotente Consumer Nachrichten deduplizieren. Die architektonische Regel ist einfach: Prüfen Sie vor dem Hinzufügen einer Wiederholung, ob die Operation idempotent ist, und wenn nicht, fügen Sie zuerst einen Schlüssel oder eine Duplikatprüfung hinzu. Eine Retry-Policy für einen nicht idempotenten Zahlungsaufruf ist kein Resilienzmuster, sondern ein Generator für Doppelabbuchungen.

Hedged Requests

Wiederholungen behandeln Fehler; Hedged Requests behandeln Langsamkeit. Abschnitt 2.4 hat sie nach Dean und Barrosos The Tail at Scale (2013) eingeführt: Senden Sie eine Anfrage an ein Replikat, und wenn es nicht innerhalb, sagen wir, der Latenz des 95. Perzentils geantwortet hat, senden Sie eine Kopie an ein zweites Replikat, verwenden Sie die Antwort, die zuerst kommt, und brechen Sie die andere ab. Weil nur die langsamsten paar Prozent der Anfragen dupliziert werden, ist die zusätzliche Last gering, während der Tail drastisch schrumpft, wie der in Abschnitt 2.4 zitierte Benchmark gezeigt hat.

Hedging hat Voraussetzungen. Die Operation muss idempotent sein, normalerweise ein Lesezugriff. Es muss unabhängige Replikate geben, damit die zweite Kopie wahrscheinlich nicht aus demselben Grund langsam ist. Die zusätzlichen Anfragen müssen budgetiert und unter Überlast abgeschaltet werden, denn wenn jedes Replikat langsam ist, verdoppelt Hedging die Last auf einem System, das ohnehin kämpft, genau wie eine unbudgetierte Wiederholung. Sorgfältig eingesetzt auf leselastigen Fan-out-Pfaden wie Suche und Speisekartenabfragen, ist es eines der wirksamsten verfügbaren Werkzeuge gegen Tail-Latenz.

Die Muster auf einen Blick

MusterSchützt vorHauptkosten oder -risiko
TimeoutUnbegrenztes Warten; Ressourcen, die von toten Aufrufen gehalten werdenZu kurz lässt gesunde langsame Aufrufe scheitern; zu lang schützt nichts
Deadline-Weitergabe (Deadline Propagation)Arbeit, die weiterläuft, nachdem der Aufrufer aufgegeben hatBraucht Unterstützung in jedem Service und jeder Bibliothek auf dem Pfad
Wiederholung mit Backoff und JitterVorübergehende FehlerZusätzliche Last; Duplikate, sofern nicht idempotent
Retry-BudgetRetry-Stürme, die die Last vervielfachenManche vorübergehenden Fehler werden in schlechten Phasen nicht wiederholt
Circuit BreakerWiederholte Aufrufe einer ausgefallenen AbhängigkeitFeinabstimmung; kann eine nur teilweise fehlerhafte Abhängigkeit abschneiden
BulkheadEine Abhängigkeit oder ein Aufrufer verbraucht alle RessourcenReservierte Kapazität kann nicht geteilt werden
Fallback und DegradationTotalausfall eines FeaturesUngetestete Codepfade; veraltete oder ungefähre Ergebnisse
Hedged RequestTail-Latenz durch langsame ReplikateZusätzliche Last; nur Lesezugriffe

Checkliste: jeder Remote-Aufruf

Prüfen Sie jeden Integrationspunkt, einschließlich Datenbanken, Caches, Broker und Drittanbieter-APIs, anhand dieser Liste.

  • Connect- und Request-Timeouts sind explizit gesetztGewählt anhand der gemessenen hohen Perzentile der Abhängigkeit und der eigenen Deadline des Aufrufers, nie auf einem Bibliotheksstandard belassen.
  • Die verbleibende Deadline wird weitergegebenNachgelagerte Aufrufe erhalten die Restzeit, und die Arbeit stoppt, wenn sie abläuft.
  • Wiederholungen sind begrenzt und sicherNur vorübergehende Fehler, nur idempotente Operationen oder solche mit Idempotenzschlüssel, auf einer Schicht, mit begrenztem exponentiellem Backoff und Jitter.
  • Ein Retry-Budget begrenzt die zusätzliche LastWiederholungen bleiben ein kleiner Anteil des Verkehrs (zum Beispiel 10 %), sodass ein Ausfall die Last nicht vervielfachen kann.
  • Ein Circuit Breaker schützt die AbhängigkeitSchwellen, Wartezeit und was als Fehler zählt, sind abgestimmt, und sein Zustand wird als Metrik exportiert.
  • Die Nebenläufigkeit zur Abhängigkeit ist begrenztEin Bulkhead (eigener Pool oder Semaphor) verhindert, dass diese Abhängigkeit jeden Thread oder jede Verbindung verbraucht.
  • Das Verhalten im Fehlerfall ist entschiedenEin Fallback, eine eingeschränkte Antwort oder ein klarer Fehler, mit dem Product Owner abgestimmt, und Fail-closed bei Sicherheitsentscheidungen.
  • Timeouts, Wiederholungen, ausgelöste Breaker und Fallbacks sind beobachtbarGezählt, grafisch dargestellt und mit Alarmen versehen, damit stille Degradation bemerkt wird (Abschnitt 9.6).
  • Der Fehlerpfad ist getestetFault Injection oder ein Game Day zeigt, dass Timeouts, Breaker und Fallback tatsächlich funktionieren (Abschnitt 9.8).

Prüfen Sie Ihr Verständnis

Ein Service erhält 50 Anfragen pro Sekunde, die jeweils eine Abhängigkeit mit einem Timeout von 4 Sekunden aufrufen. Wie viele Threads können diese Aufrufe belegen, wenn die Abhängigkeit hängt?

Nach Littles Gesetz etwa 50 × 4 = 200 Threads; deshalb müssen der Timeout und ein Bulkhead-Limit zur Poolgröße passen.

Warum ist Full Jitter besser als einfaches exponentielles Backoff, wenn viele Clients gleichzeitig scheitern?

Einfaches Backoff lässt alle Clients in denselben Momenten wiederholen, in synchronisierten Wellen; zufällige Wartezeiten verteilen die Wiederholungen, sodass die Abhängigkeit eine gleichmäßige, geringere Last sieht.

In welchem Zustand macht ein Circuit Breaker keine Remote-Aufrufe, und was beendet diesen Zustand?

Im offenen Zustand; wenn die Wartezeit abläuft, wird er halboffen und lässt Testaufrufe durch.

Warum sollte eine Autorisierungsprüfung nicht darauf zurückfallen, den Zugriff zu erlauben, wenn der Autorisierungsservice ausgefallen ist?

Sicherheitsentscheidungen müssen geschlossen fehlschlagen (Fail-closed): Zugriff während eines Ausfalls zu erlauben, würde jeden hereinlassen, sobald der Service ausfällt oder angegriffen wird.

Weiter · 9.5SLOs und Error BudgetsWie Sie Zuverlässigkeit aus Sicht der Nutzer mit SLIs messen, SLO-Ziele festlegen, daraus Error Budgets und Richtlinien ableiten, nach Burn Rate alarmieren und SLAs mit sicherem Abstand lockerer halten als die internen Ziele.Weiter →

Quellen und weiterführende Literatur

Haben Sie eine Frage zu diesem Abschnitt? ArchitectBot, der KI-Lernassistent des Kurses, antwortet zuerst aus dem Kurs.

Fragen: „Unser Checkout ruft drei externe Services synchron auf. Mit welchen Timeouts, Wiederholungen und Circuit-Breaker-Einstellungen sollten wir beginnen, und wie verhindern wir einen Retry-Sturm?“