私がレビューしたすべての本番環境のサーキットブレーカーは、最終的にバックグラウンドスレッドを起動する。それはGoのゴルーチンかもしれないし、JavaのScheduledExecutorServiceかもしれないし、Rustのtokioタスクかもしれない。仕事はいつも同じだ:数秒ごとに目を覚まし、ダウンストリームサービスが復旧したかどうかを確認し、OPENからCLOSEDへ遷移する。

その設計は間違っている。スケールでリソースを漏洩させ、シャットダウンを複雑にし、本当にテストが難しい競合状態を生み出す。さらに悪いことに、バックグラウンド処理は完全に不要だ。自分で目を覚ますこともなく、タイマーを割り当てることもなく、それでも正しく復旧を検知するサーキットブレーカーを構築できる。

ヘルスチェックゴルーチンの隠されたコスト

サーキットブレーカーは障害を追跡する。十分な連続エラーが発生すると、OPENにトリップし、即座にリクエストを拒否し始める。目的は、失敗しているサービスをリトライトラフィックで溺れさせるのではなく、一息つく時間を与えることだ。

難しい部分は、いつリトライするかを決定することだ。ほとんどのライブラリは、これをsetTimeouttime.AfterFuncで解決する。Goでは、典型的な実装は次のようになる:

func (cb *CircuitBreaker) Trip() {
    cb.state.Store(StateOpen)
    time.AfterFunc(cb.timeout, func() {
        cb.state.Store(StateHalfOpen)
    })
}

これは単一のブレーカーでは機能する。一万個では機能しない。

ダウンストリームホストごとに1つのサーキットブレーカーを作成する場合(マイクロサービスでの一般的なパターン)、バックグラウンドで1万個のゴルーチンがスリープしていることになる。各ゴルーチンは約2KBのスタック領域を消費し、スケジューリングオーバーヘッドを追加する。コンテナ再起動時、これらのゴルーチンはシャットダウンデッドラインと競合する。タイムアウト時、彼らはまさに間違った瞬間に発火し、フラッピングを引き起こす。

バックグラウンドスレッドは、存在しない問題を解決している。復旧は能動的に検知される必要はない。リクエストパス上で遅延的に検知できる。

遅延復旧の仕組み

ブレーカーを遷移させるタイマーの代わりに、単一のタイムスタンプを保存する:ブレーカーがOPENにトリップした瞬間だ。すべての着信リクエストで、nowをそのタイムスタンプに設定されたタイムアウトを加えた値と比較する。十分な時間が経過していれば、単一のプローブを通す。プローブが成功すれば、ブレーカーをクローズする。失敗すれば、タイムスタンプを更新してOPENのままにする。

ステートマシンは同じままだ。変わるのは遷移トリガーだけだ。

  • CLOSED: リクエストは通過する。障害はカウンターをインクリメントする。カウンターが閾値に達すると、原子操作でOPENにスワップし、trippedAtを記録する。
  • OPEN: すべての着信リクエストはtime.Now() > trippedAt + timeoutをチェックする。falseなら即座に失敗する。trueなら、原子操作でHALF-OPENにスワップし、この1つのリクエストを通す。
  • HALF-OPEN: ちょうど1つのリクエストが実行中だ。成功すればCLOSEDにスワップし、障害カウンターをリセットする。失敗すればOPENに戻り、trippedAtを更新する。

どのゴルーチンも目を覚ますことはない。タイマーは割り当てられない。ブレーカーはリクエストが到着するまで完全に受動的だ。

Goでの動作する実装

以下は、完全なゼロバックグラウンドのサーキットブレーカーだ。状態遷移にはsync/atomicのみを使用し、トリップしたタイムスタンプをナノ秒カウンターとして保存する。

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))
}

重要な不変条件:trippedAtは、状態がOPENに遷移する前に常に書き込まれる。Allow()内のリーダーは、OPENを見た後にtrippedAtを安全に読み取ることができ、それが新鮮であることを知っている。HALF-OPENからの戻りパスでは、OPENに戻る前にtrippedAtを更新することで、クールダウンがゼロから再開されるようにする。

なぜほとんどのライブラリがこれをしないのか

遅延設計には1つの明らかな欠点がある:復旧はリクエストが到着したときにのみ検知される。サービスが1時間トラフィックを受け取らない場合、ブレーカーは1時間OPENのままだ。

これは悪いように聞こえる。だがそうではない。

リクエストがなければ、保護するものはない。ブレーカーは、リアルタイムのヘルスダッシュボードを維持するためではなく、トラフィック中の連鎖的な障害を防ぐために存在する。次のリクエストが到着したとき、タイムアウトチェックはナノ秒で実行され、プローブは即座に発火する。実効的な復旧遅延はmax(timeout, time-between-requests)によって制限される。

高トラフィックのサービスでは、リクエスト間のギャップは無視できる。低トラフィックのサービスでは、どちらにしてもタイムアウトが支配的だ。実際には、バックグラウンドタイマーが実際の復旧時間を改善することはほとんどない。

ライブラリがタイマーを使用するもう1つの理由は歴史的なものだ。サーキットブレーカーパターンは、単一のブレーカーインスタンスがホストごとの接続ではなく、サービス依存関係全体を保護する環境(JavaのHystrix、.NETのPolly)で普及した。1つのバックグラウンドスレッドは許容できた。上流エンドポイントごとにブレーカーを持つ可能性がある現代の分散システムでは、その仮定は崩れる。

競合状態のテスト

OPENからHALF-OPENへの遷移におけるCASループは、ゴルーチンが競合する唯一の場所だ。タイムアウト後に2つのリクエストが同時に到着した場合、1つだけがプローブとして進む。もう1つは即座に失敗し、次のリクエストでリトライする。これは正しい動作だ。復旧中に複数のプローブを実行中にしたいことは決してない。なぜなら、いくつかの成功の中の1つの失敗でも、OPENに戻ってしまう可能性があるからだ。

非同期のタイマーがないため、テストは簡単だ。スリープなしでtrippedAtを直接操作する(または時間のラッパーを使用する)単体テストを書くことができる:

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")
	}
}

time.Sleepもなければ、ゴルーチン用のsync.WaitGroupもない。実装が同期的であるため、テストは決定論的だ。

失うもの

1つの本当の損失がある:トラフィックを送信する前にブレーカーを積極的にプレウォームすることはできない。接続プールを温かく保つために、固定スケジュール(例えば5秒ごと)で依存関係をプローブする必要がある場合、それでもタイマーが必要だ。だが、そのタイマーはサーキットブレーカーではなく、接続プールやヘルスチェッカーに属する。ブレーカーはプールを保護すべきだ。管理すべきではない。

関心事を分離しておく。ヘルスチェックは接続を温める。サーキットブレーキングは連鎖的な過負荷を防ぐ。これらを統合すると、両方の場所で複雑さが生じる。

他の言語でのパターン

同じ構造は、原子性のある compare-and-swap と単調増加するクロックがある場所ならどこでも機能する。Rustのstd::sync::atomicでも、JavaのAtomicIntegerFieldUpdaterSystem.nanoTime()でも、C++のstd::atomicとカスタムenumでも。どの場合でも、実装は100行未満だ。

使用言語がCASを公開していない場合でも、sync.Mutex(または同等のもの)はバックグラウンドスレッドよりも安価だ。ミューテックスはリクエストごとにナノ秒間のみ保持され、状態遷移中のみだ。I/Oをブロックしたりスリープしたりすることは決してない。

試してみよう

上記の完全な実装は、出発点として本番環境に耐えるものだ。その上にメトリクス、ログ、適応的な閾値を追加すればよい。だがゴルーチンは省くこと。ランタイムスケジューラーは感謝し、テストはより速く実行され、あのコンテナがなぜクリーンにシャットダウンを拒否するのか考える必要がなくなる。