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.
Inhaltsverzeichnis
- 1. Das Problem: kaskadierende Fehler bei instabilen Abhängigkeiten
- 2. Die drei Zustände: Closed, Open und Half-Open
- 3. Eine eigene Implementierung ohne Framework-Abhängigkeit
- 4. Zustandsübergänge im Detail: Schwellenwerte und Timer
- 5. Anwendungsfall: eine fehlerhafte externe API vor Überlastung schützen
- 6. Zustand zwischen Requests persistieren: Redis als Speicher
- 7. Race Conditions vermeiden: atomare Operationen in Redis
- 8. Monitoring und Observability für den Circuit-Zustand
- 9. Häufige Fehler bei der praktischen Umsetzung
- 10. Zusammenfassung
- 11. FAQ
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.