我審閱過的每一個 production 級斷路器,最終都會產生一個背景執行緒。它可能是 Go 的 goroutine、Java 的 ScheduledExecutorService,或是 Rust 的 tokio task。工作內容永遠一樣:每隔幾秒醒來一次,檢查下游服務是否已恢復,然後從 OPEN 切換回 CLOSED。

那種設計是錯的。它在規模化時會洩漏資源、讓關閉流程變得複雜,還會產生極難測試的 race condition。更糟的是,背景工作完全沒有必要。你可以打造一個斷路器,它永遠不會自己醒來、永遠不會分配 timer,卻仍然能正確地偵測恢復。

health-check goroutine 的隱藏成本

斷路器會追蹤失敗。在連續發生夠多錯誤後,它會 trip 到 OPEN 並立即開始拒絕請求。目的是讓故障的服務喘口氣,而不是用重試流量淹沒它。

棘手的部分在於決定何時再次嘗試。大多數函式庫用 setTimeouttime.AfterFunc 來解決這個問題。在 Go 中,典型的實作看起來像這樣:

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

這對單一斷路器有效。對一萬個斷路器無效。

如果你為每個下游 host 建立一個斷路器(這在 microservices 中很常見),你現在就有一萬個 goroutine 在背景中沉睡。每個 goroutine 要花費約 2 KB 的堆疊空間,還會增加排程開銷。在container 重啟時,這些 goroutine 會與 shutdown deadline 競爭。在 timeout 時,它們會在剛好不對的時機觸發,造成 flapping。

背景執行緒正在解決一個根本不存在的問題。恢復不需要被主動偵測。它可以被惰性偵測,就在請求路徑上。

lazy recovery 如何運作

與其用一個 timer 來轉換斷路器,不如儲存一個 timestamp:斷路器 trip 到 OPEN 的那一刻。對每個進來的請求,將 now 與該 timestamp 加上設定的 timeout 做比較。如果已經過了夠久的時間,就允許一個 probe 通過。如果 probe 成功,就關閉斷路器。如果失敗,就更新 timestamp 並保持 OPEN。

state machine保持不變。只有轉換的觸發條件改變了。

  • CLOSED:請求直接通過。失敗會遞增 counter。當 counter 達到 threshold 時,以原子操作切換到 OPEN 並記錄 trippedAt
  • OPEN:每個進來的請求都會檢查 time.Now() > trippedAt + timeout。若為 false,則 fail fast。若為 true,則以原子操作切換到 HALF-OPEN 並讓這個請求通過。
  • HALF-OPEN:剛好有一個請求正在處理中。如果成功,就切換到 CLOSED 並重置 failure counter。如果失敗,就切換回 OPEN 並更新 trippedAt

沒有任何 goroutine 會醒來。沒有分配任何 timer。在請求到來之前,斷路器完全是被動的。

一個可用的 Go 實作

這是一個完整的、零背景開銷的斷路器。它只用 sync/atomic 來做狀態轉換,並將 trip 的 timestamp 儲存為 nanosecond counter。

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,讓 cooldown 從零重新開始。

為什麼大多數函式庫不這樣做

lazy 設計有一個明顯的缺點:恢復只會在請求到達時被偵測到。如果你的服務一整小時都沒有流量,斷路器就會保持 OPEN 一整小時。

這聽起來很糟。其實不然。

如果沒有請求,就沒有東西需要保護。斷路器的存在是為了在流量期間防止級聯故障,而不是維護一個即時的健康儀表板。當下一個請求確實到達時,timeout 檢查會在 nanosecond 內完成,probe 也會立即觸發。實際的恢復延遲被限制在 max(timeout, time-between-requests) 之內。

對於高流量服務,請求之間的間隔可以忽略不計。對於低流量服務,無論如何都是 timeout 佔主導。背景 timer 在實務上幾乎從未改善真正的恢復時間。

函式庫使用 timer 的另一個原因是歷史因素。circuit breaker 模式在一些環境中普及(Java 的 Hystrix、.NET 的 Polly),當時單一斷路器實例守護的是整個服務依賴,而不是每個 host 的連線。一個背景執行緒是可以接受的。在現代分散式系統中,你可能會為每個 upstream endpoint 配一個斷路器,那個假設就不成立了。

測試 race condition

OPEN 到 HALF-OPEN 轉換上的 CAS loop 是 goroutine 唯一會競爭的地方。如果有兩個請求在 timeout 後同時到達,只有一個會以 probe 身份繼續。另一個會 fail fast 並在下一個請求時重試。這是正確的行為。你在恢復期間絕不會希望有多個 probe 同時在飛,因為在幾個成功中夾雜一個失敗,仍然可能把你打回 OPEN。

測試很簡單,因為沒有非同步的 timer。你可以寫一個 unit test,直接操作 trippedAt(或使用 time wrapper),完全不需要 sleeping:

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。不需要為 goroutine 準備 sync.WaitGroup。這個測試是確定性的,因為實作是同步的。

我們放棄了什麼

有一個真正的損失:你無法在發送流量之前積極地預熱斷路器。如果你需要按照固定排程(例如每 5 秒)probe 某個依賴,以保持 connection pool 溫熱,你仍然需要一個 timer。但那個 timer 應該屬於你的 connection pool 或 health checker,而不是斷路器。斷路器應該保護 pool。它不應該管理 pool。

讓關注點保持分離。Health checking 負責溫熱連線。circuit breaking 負責防止級聯過載。當你把它們混在一起,兩邊都會變得複雜。

適用於其他語言的模式

相同的結構在任何提供 atomic compare-and-swap 和 monotonic clock 的地方都能運作。在 Rust 中用 std::sync::atomic、在 Java 中用 AtomicIntegerFieldUpdaterSystem.nanoTime()、在 C++ 中用 std::atomic 和自訂 enum。每種情況下的實作都不到一百行。

如果你的語言沒有提供 CAS,sync.Mutex(或等價物)仍然比背景執行緒便宜。每個請求只會持有 mutex nanosecond 級的時間,而且只在狀態轉換期間。它永遠不會為了 I/O 而阻塞或沉睡。

試試看

上面的完整實作已經可以作為 production 的起點。在上面加入 metrics、logging 和 adaptive threshold。但請去掉 goroutine。你的 runtime scheduler 會感謝你,你的測試會跑得更快,你也不會再納悶為什麼那個 container就是不肯乾淨地關閉。