Das Circuit-Breaker-Pattern in PHP implementieren
AI generated
8.4
PHP, Resilienz, Architektur-Patterns
Das Circuit-Breaker-Pattern
in PHP implementieren, ohne Framework-Abhängigkeit

Wenn eine externe API langsam antwortet oder komplett ausfällt, wartet jeder aufrufende Request in einer typischen PHP-Anwendung brav auf sein Timeout, während sich im Hintergrund PHP-FPM-Worker stauen und die eigene Anwendung mit in den Abgrund reißen. Das Circuit-Breaker-Pattern durchbricht diese Kette, indem es nach einer bestimmten Anzahl von Fehlern den Stromkreis unterbricht und weitere Aufrufe sofort mit einem Fallback beantwortet, statt sie erneut ins Leere laufen zu lassen.

16 Min. Lesezeit Circuit Breaker, State Machine Redis, Zustandspersistenz

1. Das Problem: kaskadierende Fehler bei instabilen Abhängigkeiten

In verteilten Systemen ruft eine PHP-Anwendung regelmäßig externe Dienste auf: Zahlungsanbieter, Versanddienstleister, Preisvergleichsportale oder interne Microservices. Fällt einer dieser Dienste aus oder wird nur noch extrem langsam, merkt die aufrufende Anwendung das zunächst gar nicht, sie wartet einfach auf die Antwort, bis ein Timeout greift. Bei typischen PHP-FPM-Deployments mit einer begrenzten Anzahl an Worker-Prozessen führt das dazu, dass immer mehr Worker in wartenden Requests blockiert sind, während gleichzeitig neue Anfragen eintreffen.

Das Ergebnis ist ein kaskadierender Fehler: Ein einzelner instabiler Drittanbieter reicht aus, um den gesamten Worker-Pool zu erschöpfen und damit auch völlig unabhängige Funktionen der eigenen Anwendung lahmzulegen, die gar nichts mit dem fehlerhaften Dienst zu tun haben. Genau dieses Szenario verhindert das Circuit-Breaker-Pattern, indem es nach wiederholten Fehlern erkennt, dass ein Dienst gerade nicht erreichbar ist, und weitere Aufrufe für eine Weile gar nicht erst versucht.

2. Die drei Zustände: Closed, Open und Half-Open

Ein Circuit Breaker funktioniert als endliche Zustandsmaschine mit genau drei Zuständen. Im Zustand Closed läuft alles normal: Aufrufe werden ganz regulär an den externen Dienst weitergereicht, im Hintergrund wird aber die Fehlerrate mitgezählt. Überschreitet die Anzahl aufeinanderfolgender Fehler einen konfigurierten Schwellenwert, wechselt der Breaker in den Zustand Open.

Im Zustand Open wird kein einziger Aufruf mehr an den echten Dienst weitergeleitet, stattdessen schlägt der Aufruf sofort mit einer klar erkennbaren Exception fehl oder liefert einen Fallback-Wert zurück, was den externen Dienst entlastet und die eigene Anwendung vor unnötigen Wartezeiten schützt. Nach Ablauf eines konfigurierten Timeouts wechselt der Breaker eigenständig in den Zustand Half-Open, in dem testweise ein einzelner Aufruf durchgelassen wird: Gelingt dieser, springt der Breaker zurück zu Closed, scheitert er, geht es zurück zu Open.

3. Eine eigene Implementierung ohne Framework-Abhängigkeit

Für eine framework-unabhängige Implementierung genügen eine kleine Klasse für die Zustandsmaschine und eine Speicher-Abstraktion für den Zustand. Der Kern ist eine Methode call(), die eine Callback-Funktion entgegennimmt, den aktuellen Zustand prüft und je nach Zustand entweder ausführt, ablehnt oder testweise durchlässt.

Wichtig ist, dass die Klasse selbst keine Annahmen über HTTP-Clients, Datenbanken oder ein bestimmtes Framework trifft, sondern rein mit generischen Callables arbeitet. Dadurch lässt sich derselbe Circuit Breaker für HTTP-Aufrufe, Datenbankverbindungen oder Aufrufe an Message Queues gleichermaßen verwenden.


<?php

declare(strict_types=1);

namespace App\Resilience;

enum CircuitState: string
{
    case Closed = 'closed';
    case Open = 'open';
    case HalfOpen = 'half_open';
}

interface CircuitBreakerStore
{
    public function getState(string $key): CircuitState;
    public function getFailureCount(string $key): int;
    public function recordSuccess(string $key): void;
    public function recordFailure(string $key): void;
    public function transitionTo(string $key, CircuitState $state): void;
    public function secondsSinceOpened(string $key): int;
}

final class CircuitOpenException extends \RuntimeException
{
}

final class CircuitBreaker
{
    public function __construct(
        private readonly string $key,
        private readonly CircuitBreakerStore $store,
        private readonly int $failureThreshold = 5,
        private readonly int $openTimeoutSeconds = 30,
    ) {
    }

    /**
     * @template T
     * @param callable():T $operation
     * @return T
     */
    public function call(callable $operation)
    {
        $state = $this->store->getState($this->key);

        if ($state === CircuitState::Open) {
            if ($this->store->secondsSinceOpened($this->key) < $this->openTimeoutSeconds) {
                throw new CircuitOpenException("Circuit '{$this->key}' is open");
            }
            $this->store->transitionTo($this->key, CircuitState::HalfOpen);
        }

        try {
            $result = $operation();
        } catch (\Throwable $e) {
            $this->store->recordFailure($this->key);
            if ($this->store->getFailureCount($this->key) >= $this->failureThreshold) {
                $this->store->transitionTo($this->key, CircuitState::Open);
            }
            throw $e;
        }

        $this->store->recordSuccess($this->key);
        return $result;
    }
}

4. Zustandsübergänge im Detail: Schwellenwerte und Timer

Zwei Parameter bestimmen maßgeblich, wie empfindlich ein Circuit Breaker reagiert: der failureThreshold, also die Anzahl aufeinanderfolgender Fehler, nach denen von Closed zu Open gewechselt wird, und der openTimeout, also die Zeitspanne, die der Breaker im Zustand Open verharrt, bevor er es erneut versucht. Ein zu niedriger Schwellenwert öffnet den Breaker schon bei kurzen, vorübergehenden Ausreißern, ein zu hoher Schwellenwert lässt zu viele fehlgeschlagene Requests durch, bevor überhaupt reagiert wird.

Im Zustand Half-Open lohnt sich zusätzlich ein successThreshold, also die Anzahl aufeinanderfolgender erfolgreicher Testaufrufe, die nötig sind, bevor der Breaker vollständig zu Closed zurückkehrt. Ein einzelner erfolgreicher Testaufruf kann Zufall sein, mehrere aufeinanderfolgende Erfolge sind ein deutlich verlässlicheres Signal, dass sich der externe Dienst tatsächlich erholt hat.

5. Anwendungsfall: eine fehlerhafte externe API vor Überlastung schützen

Ein typisches Praxisbeispiel ist die Anbindung eines Zahlungsanbieters im Checkout. Fällt der Zahlungsanbieter aus, sollen weder tausende Checkout-Requests sinnlos auf ein Timeout warten, noch soll der Zahlungsanbieter während seiner Ausfallphase mit weiteren Anfragen bombardiert werden, was seine Erholung nur verzögert. Der Circuit Breaker schützt in diesem Szenario beide Seiten gleichzeitig: die eigene Anwendung vor blockierten Workern und den externen Dienst vor zusätzlicher Last während der Wiederherstellung.

Anstatt den Checkout einfach mit einem Fehler abzubrechen, kann ein sinnvoller Fallback greifen, sobald der Breaker offen ist: Die Bestellung wird zunächst in einen Status wie 'Zahlung ausstehend' versetzt und asynchron nachbearbeitet, sobald der Dienst wieder erreichbar ist. So bleibt die Nutzererfahrung intakt, obwohl im Hintergrund ein Systemteil gerade ausfällt.


<?php

declare(strict_types=1);

namespace App\Payment;

use App\Resilience\CircuitBreaker;
use App\Resilience\CircuitOpenException;

final class PaymentGatewayClient
{
    public function __construct(
        private readonly CircuitBreaker $breaker,
        private readonly string $endpoint,
    ) {
    }

    public function charge(string $orderId, int $amountCents): PaymentResult
    {
        try {
            return $this->breaker->call(function () use ($orderId, $amountCents): PaymentResult {
                $response = $this->sendRequest($orderId, $amountCents);
                return PaymentResult::fromResponse($response);
            });
        } catch (CircuitOpenException) {
            // Fallback: the order moves into a queued "payment pending"
            // state instead of blocking the checkout request on a dead gateway.
            return PaymentResult::pendingRetry($orderId);
        }
    }

    private function sendRequest(string $orderId, int $amountCents): array
    {
        $ch = curl_init($this->endpoint . '/charges');
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => json_encode(['order_id' => $orderId, 'amount' => $amountCents]),
            CURLOPT_TIMEOUT => 3,
            CURLOPT_RETURNTRANSFER => true,
        ]);
        $body = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($body === false || $httpCode >= 500) {
            throw new \RuntimeException("Payment gateway request failed with status {$httpCode}");
        }

        return json_decode($body, true);
    }
}

6. Zustand zwischen Requests persistieren: Redis als Speicher

Eine In-Memory-Implementierung des Zustands funktioniert in PHP nur innerhalb eines einzelnen Requests, denn PHP-FPM-Worker starten für jeden Request in der Regel mit einem leeren Speicherzustand neu. Ohne einen geteilten, externen Speicher würde der Circuit Breaker bei jedem einzelnen Request wieder bei Closed beginnen und seine eigentliche Aufgabe, wiederholte Fehler über viele Requests hinweg zu erkennen, nie erfüllen können.

Redis eignet sich für diese Aufgabe besonders gut, da es sehr schnelle, atomare Operationen bereitstellt und ohnehin häufig als Cache-Schicht bereits im Stack vorhanden ist. Der Zustand, der Fehlerzähler und der Zeitpunkt der letzten Zustandsänderung werden dabei pro Circuit-Breaker-Schlüssel in einer Redis-Hash-Struktur mit passendem TTL gespeichert, sodass alle PHP-FPM-Worker auf denselben, konsistenten Zustand zugreifen.


<?php

declare(strict_types=1);

namespace App\Resilience;

final class RedisCircuitBreakerStore implements CircuitBreakerStore
{
    public function __construct(private readonly \Redis $redis)
    {
    }

    public function getState(string $key): CircuitState
    {
        $value = $this->redis->hGet($this->redisKey($key), 'state');
        return $value !== false ? CircuitState::from($value) : CircuitState::Closed;
    }

    public function getFailureCount(string $key): int
    {
        return (int) $this->redis->hGet($this->redisKey($key), 'failures');
    }

    public function recordSuccess(string $key): void
    {
        $this->redis->hSet($this->redisKey($key), 'failures', '0');
    }

    public function recordFailure(string $key): void
    {
        $this->redis->hIncrBy($this->redisKey($key), 'failures', 1);
    }

    public function transitionTo(string $key, CircuitState $state): void
    {
        $redisKey = $this->redisKey($key);
        $this->redis->hSet($redisKey, 'state', $state->value);
        $this->redis->hSet($redisKey, 'opened_at', (string) time());
        $this->redis->expire($redisKey, 3600);
    }

    public function secondsSinceOpened(string $key): int
    {
        $openedAt = (int) $this->redis->hGet($this->redisKey($key), 'opened_at');
        return $openedAt === 0 ? PHP_INT_MAX : time() - $openedAt;
    }

    private function redisKey(string $key): string
    {
        return "circuit_breaker:{$key}";
    }
}

7. Race Conditions vermeiden: atomare Operationen in Redis

Sobald mehrere PHP-FPM-Worker gleichzeitig denselben Fehler feststellen, können sie parallel versuchen, den Fehlerzähler zu erhöhen und den Zustand zu wechseln. Ein naives Muster aus separatem Lesen, Erhöhen im PHP-Code und Zurückschreiben ist dabei anfällig für Race Conditions: Zwei Worker lesen denselben alten Zählerstand, erhöhen ihn beide lokal um eins, und einer der beiden Schreibvorgänge geht verloren, sodass der Zähler fälschlicherweise zu niedrig bleibt.

Die Lösung liegt in atomaren Redis-Operationen wie HINCRBY, das Lesen und Erhöhen in einem einzigen, nicht unterbrechbaren Schritt ausführt, oder in einem kleinen Lua-Skript für komplexere Zustandsübergänge, die mehrere Redis-Befehle atomar bündeln müssen. Redis führt ein Lua-Skript stets vollständig aus, bevor es den nächsten Befehl eines anderen Clients annimmt, wodurch der gesamte Zustandsübergang gegen parallele Zugriffe abgesichert ist.


<?php

declare(strict_types=1);

// Lua script executed atomically inside Redis: increments the failure
// counter and returns the new value in a single, uninterruptible step,
// instead of a PHP-side "read, add one, write" sequence that two
// concurrent PHP-FPM workers could interleave and corrupt.
$script = <<<'LUA'
local key = KEYS[1]
local count = redis.call('HINCRBY', key, 'failures', 1)
redis.call('EXPIRE', key, 3600)
return count
LUA;

$newFailureCount = $redis->eval($script, ['circuit_breaker:payment_gateway'], 1);

8. Monitoring und Observability für den Circuit-Zustand

Ein Circuit Breaker, der unbemerkt dauerhaft offen bleibt, verwandelt einen kurzen Ausfall in einen langfristigen Funktionsverlust, ohne dass jemand davon erfährt. Deshalb sollte jeder Zustandswechsel strukturiert geloggt werden, mit Zeitstempel, Breaker-Schlüssel, altem und neuem Zustand sowie der zugrunde liegenden Fehlermeldung, damit sich Ausfälle im Nachhinein exakt nachvollziehen lassen.

Zusätzlich zum Logging lohnen sich Metriken, die von einem Monitoring-System wie Prometheus oder einem APM-Tool erfasst werden können: die aktuelle Dauer im Zustand Open, die Häufigkeit von Zustandswechseln pro Zeitraum und die Erfolgsquote der Testaufrufe im Half-Open-Zustand. Ein Alert, der auslöst, sobald ein Breaker länger als eine definierte Schwelle im Zustand Open verharrt, macht aus einem stillen Ausfall ein sichtbares, priorisierbares Ereignis für das Betriebsteam.

9. Häufige Fehler bei der praktischen Umsetzung

Ein verbreiteter Fehler ist ein zu aggressiv eingestellter Schwellenwert, der den Breaker bereits bei ganz normalen, vereinzelten Netzwerkfehlern öffnet und damit einen eigentlich gesunden Dienst unnötig blockiert. Ebenso problematisch ist es, jede Art von Fehler gleich zu behandeln: Ein 4xx-Fehler durch eine ungültige Anfrage sagt nichts über die Verfügbarkeit des Dienstes aus und sollte den Fehlerzähler eines Circuit Breakers nicht erhöhen, während Timeouts und 5xx-Fehler sehr wohl zählen sollten.

Ein weiterer häufiger Fehler ist ein fehlendes Limit für parallele Testaufrufe im Zustand Half-Open: Ohne diese Begrenzung können mehrere gleichzeitige Requests gleichzeitig als Testaufruf durchgelassen werden und den gerade erst wieder anlaufenden Dienst erneut überlasten. Schließlich wird oft ein einziger, globaler Breaker für alle Aufrufe an einen externen Anbieter verwendet, obwohl unterschiedliche Endpunkte desselben Anbieters unterschiedlich stabil sein können und daher eigene Breaker-Instanzen verdienen.

Parameter Bedeutung Typischer Startwert Auswirkung bei falscher Einstellung
failureThreshold Anzahl Fehler bis zum Wechsel nach Open 5 Zu niedrig: Breaker öffnet bei kurzen Ausreißern
openTimeout Wartezeit im Zustand Open 30 Sekunden Zu kurz: Dienst wird während Erholung erneut belastet
successThreshold Erfolge bis zur Rückkehr nach Closed 2 Zu niedrig: schneller Rückfall in Open bei erneuten Fehlern
Half-Open-Limit Parallele Testaufrufe im Half-Open 1 Zu hoch: Testphase überlastet den erholenden Dienst erneut
Speicherort Wo der Zustand abgelegt wird Redis mit TTL In-Memory: Zustand geht bei jedem Request verloren

Mironsoft

PHP-Modernisierung, Code-Qualität und Legacy-Refactoring

Gewachsener PHP-Code, der niemand mehr gern anfasst?

Wir modernisieren PHP-Codebasen auf aktuelle Sprachstandards, führen statische Analyse und Coding Standards ein und refactorn Legacy-Code Schritt für Schritt, ohne den laufenden Betrieb zu gefährden.

Legacy-Refactoring

Gewachsenen PHP-Code strukturiert und risikoarm modernisieren.

Code-Qualität etablieren

PHPStan, Coding Standards und CI-Checks nachhaltig im Team verankern.

Versions-Upgrade

PHP-Major-Version-Upgrades sicher planen und ohne Ausfallzeit umsetzen.

10. Zusammenfassung

Circuit-Breaker-Pattern in PHP: Das Wichtigste auf einen Blick

Zustandsmaschine

Drei klar definierte Zustände: Closed für Normalbetrieb, Open für sofortige Ablehnung, Half-Open für vorsichtige Testaufrufe.

Framework-unabhängig

Eine schlanke Klasse mit generischen Callables funktioniert für HTTP-Clients, Datenbanken und Message Queues gleichermaßen.

Persistenz

Redis speichert Zustand und Fehlerzähler über einzelne PHP-FPM-Requests hinweg, atomare Operationen verhindern Race Conditions.

Beobachtbarkeit

Jeder Zustandswechsel wird geloggt, Metriken und Alerts machen Ausfälle sichtbar, bevor Kunden sie melden.

11. FAQ: Circuit-Breaker-Pattern in PHP: Das Wichtigste auf einen Blick

1Was ist das Circuit-Breaker-Pattern in PHP?
Ein Entwurfsmuster, das Aufrufe an eine potenziell instabile Abhängigkeit über eine Zustandsmaschine mit den Zuständen Closed, Open und Half-Open steuert, um kaskadierende Fehler zu verhindern, wenn diese Abhängigkeit ausfällt oder extrem langsam antwortet.
2Wann sollte ich einen Circuit Breaker einsetzen?
Immer dann, wenn eine Anwendung von externen Diensten wie Zahlungsanbietern, APIs Dritter oder internen Microservices abhängt, deren Ausfall sonst zu Timeout-Ketten und blockierten PHP-FPM-Workern führen würde.
3Wie unterscheidet sich Open vom Zustand Half-Open?
Im Zustand Open wird jeder Aufruf sofort ohne echten Versuch abgelehnt. Im Zustand Half-Open lässt der Breaker gezielt eine begrenzte Anzahl an Testaufrufen durch, um zu prüfen, ob sich der Dienst erholt hat.
4Warum reicht eine In-Memory-Implementierung in PHP oft nicht aus?
Weil PHP-FPM-Prozesse pro Request typischerweise mit leerem Speicherzustand arbeiten. Ohne einen externen Speicher wie Redis würde der Breaker bei jedem Request wieder bei Closed beginnen.
5Welche Rolle spielt Redis beim Circuit Breaker?
Redis dient als zentraler, von allen PHP-FPM-Workern gemeinsam genutzter Speicher für den aktuellen Zustand, den Fehlerzähler und den Zeitpunkt des letzten Zustandswechsels, sodass der Breaker über Requests hinweg konsistent bleibt.
6Wie vermeide ich Race Conditions bei mehreren gleichzeitigen Requests?
Durch atomare Redis-Operationen wie HINCRBY oder ein Lua-Skript, das Lesen und Schreiben in einem einzigen, nicht unterbrechbaren Schritt ausführt, statt Zustand naiv per separatem GET und SET zu ändern.
7Sollte jeder Fehler den Fehlerzähler erhöhen?
Nein. Nur echte Infrastruktur- und Verfügbarkeitsfehler wie Timeouts oder 5xx-Antworten sollten zählen. Fachliche Fehler wie eine ungültige Eingabe mit 4xx-Antwort deuten nicht auf einen instabilen Dienst hin und sollten den Breaker nicht öffnen.
8Wie kombiniere ich einen Circuit Breaker mit Retry-Logik?
Retries mit Backoff greifen bei einzelnen, vorübergehenden Fehlern innerhalb des Zustands Closed, während der Circuit Breaker die übergeordnete Entscheidung trifft, ganze Serien von Aufrufen zu unterbinden, sobald ein Dienst dauerhaft ausfällt.
9Was passiert mit Requests, während der Breaker offen ist?
Sie schlagen sofort mit einer klar erkennbaren Exception fehl oder erhalten einen definierten Fallback-Wert, etwa einen Cache-Treffer oder eine reduzierte Funktionalität, statt auf ein Timeout zu warten.
10Wie überwache ich einen Circuit Breaker in Produktion?
Durch strukturiertes Logging jedes Zustandswechsels, Metriken über Dauer und Häufigkeit von Open-Phasen sowie Alerts, die auslösen, wenn ein Breaker ungewöhnlich lange im Zustand Open verharrt.