Jeder Circuit Breaker, den ich in Produktion geprüft habe, startet irgendwann einen Hintergrund-Thread. Das kann eine Go-Goroutine, ein Java-ScheduledExecutorService oder ein Rust-Tokio-Task sein. Die Aufgabe ist immer dieselbe: alle paar Sekunden aufwachen, prüfen, ob der Downstream-Service wiederhergestellt ist, und von OPEN zurück zu CLOSED wechseln.
Dieser Entwurf ist falsch. Er lässt im großen Maßstab Ressourcen lecken, erschwert das Herunterfahren und erzeugt Race Conditions, die wirklich schwer zu testen sind. Schlimmer noch, die Hintergrundarbeit ist völlig unnötig. Sie können einen Circuit Breaker bauen, der nie von selbst aufwacht, nie einen Timer allokiert und trotzdem die Wiederherstellung korrekt erkennt.
Die versteckten Kosten von Health-Check-Goroutines
Ein Circuit Breaker protokolliert Fehler. Nach genug aufeinanderfolgenden Fehlern schaltet er auf OPEN und beginnt, Anfragen sofort abzulehnen. Das Ziel ist, dem ausfallenden Service eine Pause zu gönnen, anstatt ihn mit Retry-Traffic zu ertränken.
Der schwierige Teil ist die Entscheidung, wann ein erneuter Versuch unternommen werden soll. Die meisten Bibliotheken lösen das mit einem setTimeout oder time.AfterFunc. In Go sieht eine typische Implementierung so aus:
func (cb *CircuitBreaker) Trip() {
cb.state.Store(StateOpen)
time.AfterFunc(cb.timeout, func() {
cb.state.Store(StateHalfOpen)
})
}
Das funktioniert für einen einzelnen Breaker. Nicht aber für zehntausend.
Wenn Sie einen Circuit Breaker pro Downstream-Host erstellen (ein gängiges Muster in Microservices), haben Sie nun zehntausend Goroutines, die im Hintergrund schlafen. Jede Goroutine kostet ~2 KB Stack-Speicher und erzeugt Scheduling-Overhead. Bei Container-Neustarts konkurrieren diese Goroutines mit Shutdown-Deadlines. Bei Timeouts feuern sie genau im falschen Moment und erzeugen Flapping.
Der Hintergrund-Thread löst ein Problem, das nicht existiert. Die Wiederherstellung muss nicht proaktiv erkannt werden. Sie kann lazy – auf dem Request-Pfad – erkannt werden.
Wie Lazy Recovery funktioniert
Statt eines Timers, der den Breaker umstellt, speichern Sie einen einzigen Timestamp: den Moment, in dem der Breaker auf OPEN geschaltet hat. Bei jeder eingehenden Anfrage vergleichen Sie now mit diesem Timestamp plus dem konfigurierten Timeout. Wenn genug Zeit verstrichen ist, lassen Sie einen einzelnen Probe-Request durch. Wenn die Probe erfolgreich ist, schließen Sie den Breaker. Wenn sie fehlschlägt, aktualisieren Sie den Timestamp und bleiben auf OPEN.
Die state machine bleibt identisch. Nur der Übergangs-Trigger ändert sich.
- CLOSED: Anfragen werden durchgelassen. Fehler erhöhen einen Zähler. Wenn der Zähler den Schwellenwert erreicht, atomarer Wechsel zu OPEN und
trippedAtspeichern. - OPEN: Jede eingehende Anfrage prüft
time.Now() > trippedAt + timeout. Wenn falsch, Fail-Fast. Wenn wahr, atomarer Wechsel zu HALF-OPEN und diese eine Anfrage durchlassen. - HALF-OPEN: Genau eine Anfrage ist unterwegs. Wenn sie erfolgreich ist, Wechsel zu CLOSED und Fehlerzähler zurücksetzen. Wenn sie fehlschlägt, zurück zu OPEN und
trippedAtaktualisieren.
Keine Goroutine wacht jemals auf. Kein Timer wird allokiert. Der Breaker ist völlig passiv, bis eine Anfrage eintrifft.
Eine funktionierende Implementierung in Go
Hier ist ein vollständiger Circuit Breaker ohne Hintergrund-Overhead. Er verwendet ausschließlich sync/atomic für Zustandsübergänge und speichert den Auslösezeitpunkt als Nanosekunden-Zähler.
package breaker
import (
"errors"
"sync/atomic"
"time"
)
type State int32
const (
StateClosed State = iota
StateOpen
StateHalfOpen
)
type CircuitBreaker struct {
// state is accessed with atomic operations.
state int32
failures int32
threshold int32
timeout time.Duration
trippedAt int64 // nanoseconds since Unix epoch
}
func New(threshold int, timeout time.Duration) *CircuitBreaker {
return &CircuitBreaker{
threshold: int32(threshold),
timeout: timeout,
}
}
func (cb *CircuitBreaker) State() State {
return State(atomic.LoadInt32(&cb.state))
}
// Allow reports whether the current request may proceed.
// It returns a done function that must be called with the outcome.
func (cb *CircuitBreaker) Allow() (done func(success bool), err error) {
switch State(atomic.LoadInt32(&cb.state)) {
case StateClosed:
return cb.trackClosed, nil
case StateOpen:
// Lazy recovery check: has the timeout elapsed?
if time.Now().UnixNano()-atomic.LoadInt64(&cb.trippedAt) < int64(cb.timeout) {
return nil, errors.New("circuit breaker is open")
}
// Race: multiple goroutines may see this simultaneously.
// Only one wins the CAS to HALF-OPEN.
if atomic.CompareAndSwapInt32(&cb.state, int32(StateOpen), int32(StateHalfOpen)) {
return cb.trackHalfOpen, nil
}
// Another goroutine won the race; fail fast this request.
return nil, errors.New("circuit breaker is open")
case StateHalfOpen:
// Only one probe at a time. Every other request fails fast.
return nil, errors.New("circuit breaker is half-open")
}
return nil, errors.New("unknown circuit breaker state")
}
func (cb *CircuitBreaker) trackClosed(success bool) {
if success {
atomic.StoreInt32(&cb.failures, 0)
return
}
// Increment failures and trip if threshold reached.
if atomic.AddInt32(&cb.failures, 1) >= cb.threshold {
// Record the trip time before switching state so readers
// never see OPEN with a stale trippedAt.
atomic.StoreInt64(&cb.trippedAt, time.Now().UnixNano())
atomic.StoreInt32(&cb.state, int32(StateOpen))
}
}
func (cb *CircuitBreaker) trackHalfOpen(success bool) {
if success {
atomic.StoreInt32(&cb.failures, 0)
atomic.StoreInt32(&cb.state, int32(StateClosed))
return
}
atomic.StoreInt64(&cb.trippedAt, time.Now().UnixNano())
atomic.StoreInt32(&cb.state, int32(StateOpen))
}
Die zentrale Invariante: trippedAt wird immer geschrieben, bevor der Zustand zu OPEN wechselt. Leser in Allow() können dann trippedAt sicher lesen, nachdem sie OPEN gesehen haben, in dem Wissen, dass der Wert frisch ist. Auf dem Rückweg von HALF-OPEN aktualisieren wir trippedAt, bevor wir zurück zu OPEN fallen, damit der Cooldown von null neu startet.
Warum die meisten Bibliotheken das nicht machen
Der lazy Entwurf hat einen scheinbaren Nachteil: Die Wiederherstellung wird nur erkannt, wenn eine Anfrage eintrifft. Wenn Ihr Service eine Stunde lang keinen Traffic erhält, bleibt der Breaker eine Stunde lang auf OPEN.
Das klingt schlimm. Ist es aber nicht.
Wenn es keine Anfragen gibt, gibt es nichts zu schützen. Der Breaker existiert, um kaskadierende Ausfälle während des Traffics zu verhindern, nicht um ein Echtzeit-Health-Dashboard zu pflegen. Wenn die nächste Anfrage eintrifft, läuft der Timeout-Check in Nanosekunden ab und die Probe feuert sofort. Die effektive Wiederherstellungslatenz ist begrenzt durch max(timeout, time-between-requests).
Für hochfrequente Services ist die Lücke zwischen Anfragen vernachlässigbar. Für niedrigfrequente Services dominiert sowieso der Timeout. Der Hintergrund-Timer verbessert die echte Wiederherstellungszeit in der Praxis fast nie.
Der andere Grund, warum Bibliotheken Timer verwenden, ist historisch. Das Circuit-Breaker-Muster wurde in Umgebungen populär (Java mit Hystrix, .NET mit Polly), in denen eine einzelne Breaker-Instanz eine ganze service dependency bewachte, keine Verbindung pro Host. Ein Hintergrund-Thread war akzeptabel. In modernen verteilten Systemen, in denen Sie möglicherweise einen Breaker pro Upstream-Endpoint haben, bricht diese Annahme zusammen.
Die Race Conditions testen
Die CAS-Schleife beim Übergang von OPEN zu HALF-OPEN ist die einzige Stelle, an der Goroutines konkurrieren. Wenn zwei Anfragen gleichzeitig nach dem Timeout eintreffen, wird nur eine als Probe durchgelassen. Die andere fällt auf Fail-Fast zurück und versucht es bei der nächsten Anfrage erneut. Das ist korrektes Verhalten. Sie wollen niemals mehrere Probes unterwegs während der Wiederherstellung, weil ein einzelner Fehler unter mehreren Erfolgen Sie trotzdem zurück zu OPEN werfen könnte.
Das Testen ist unkompliziert, weil es keine asynchronen Timer gibt. Sie können einen Unit-Test schreiben, der trippedAt direkt manipuliert (oder einen Time-Wrapper verwendet), ohne zu schlafen:
func TestLazyRecovery(t *testing.T) {
cb := New(1, time.Minute)
// Trip the breaker.
done, _ := cb.Allow()
done(false)
if cb.State() != StateOpen {
t.Fatal("expected OPEN")
}
// Simulate timeout by winding back trippedAt.
atomic.StoreInt64(&cb.trippedAt, time.Now().Add(-2*time.Minute).UnixNano())
done, err := cb.Allow()
if err != nil {
t.Fatalf("expected probe to be allowed: %v", err)
}
// Success closes the breaker.
done(true)
if cb.State() != StateClosed {
t.Fatal("expected CLOSED after successful probe")
}
}
Kein time.Sleep. Kein sync.WaitGroup für Goroutines. Der Test ist deterministisch, weil die Implementierung synchron ist.
Was wir aufgegeben haben
Es gibt einen echten Verlust: Sie können einen Breaker nicht eager vor dem Senden von Traffic vorwärmen. Wenn Sie eine dependency nach einem festen Zeitplan prüfen müssen (sagen wir, alle 5 Sekunden), um einen Connection Pool warm zu halten, brauchen Sie immer noch einen Timer. Aber dieser Timer gehört zu Ihrem Connection Pool oder Health Checker, nicht zum Circuit Breaker. Der Breaker sollte den Pool schützen. Er sollte ihn nicht verwalten.
Trennen Sie die Belange. Health Checking wärmt Verbindungen auf. Circuit Breaking verhindert kaskadierende Überlastung. Wenn Sie sie zusammenführen, erhalten Sie an beiden Stellen Komplexität.
Ein Muster für andere Sprachen
Die gleiche Struktur funktioniert überall, wo Sie atomares Compare-and-Swap und eine monotone Uhr haben. In Rust mit std::sync::atomic, in Java mit AtomicIntegerFieldUpdater und System.nanoTime(), in C++ mit std::atomic und einem benutzerdefinierten Enum. Die Implementierung ist in jedem Fall unter hundert Zeilen.
Wenn Ihre Sprache kein CAS bereitstellt, ist ein sync.Mutex (oder das Äquivalent) immer noch billiger als ein Hintergrund-Thread. Der Mutex wird nur für Nanosekunden pro Anfrage gehalten und nur während Zustandsübergängen. Er blockiert nie für I/O oder schläft.
Probieren Sie es aus
Die vollständige Implementierung oben ist produktionsreif als Ausgangspunkt. Fügen Sie Metriken, Logging und adaptive Schwellenwerte hinzu. Lassen Sie aber die Goroutine weg. Ihr Runtime-Scheduler wird es Ihnen danken, Ihre Tests werden schneller laufen, und Sie werden aufhören, sich zu fragen, warum dieser eine Container sich nicht sauber herunterfahren lässt.