我審閱過的每一個 production 級斷路器,最終都會產生一個背景執行緒。它可能是 Go 的 goroutine、Java 的 ScheduledExecutorService,或是 Rust 的 tokio task。工作內容永遠一樣:每隔幾秒醒來一次,檢查下游服務是否已恢復,然後從 OPEN 切換回 CLOSED。
那種設計是錯的。它在規模化時會洩漏資源、讓關閉流程變得複雜,還會產生極難測試的 race condition。更糟的是,背景工作完全沒有必要。你可以打造一個斷路器,它永遠不會自己醒來、永遠不會分配 timer,卻仍然能正確地偵測恢復。
health-check goroutine 的隱藏成本
斷路器會追蹤失敗。在連續發生夠多錯誤後,它會 trip 到 OPEN 並立即開始拒絕請求。目的是讓故障的服務喘口氣,而不是用重試流量淹沒它。
棘手的部分在於決定何時再次嘗試。大多數函式庫用 setTimeout 或 time.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 中用 AtomicIntegerFieldUpdater 和 System.nanoTime()、在 C++ 中用 std::atomic 和自訂 enum。每種情況下的實作都不到一百行。
如果你的語言沒有提供 CAS,sync.Mutex(或等價物)仍然比背景執行緒便宜。每個請求只會持有 mutex nanosecond 級的時間,而且只在狀態轉換期間。它永遠不會為了 I/O 而阻塞或沉睡。
試試看
上面的完整實作已經可以作為 production 的起點。在上面加入 metrics、logging 和 adaptive threshold。但請去掉 goroutine。你的 runtime scheduler 會感謝你,你的測試會跑得更快,你也不會再納悶為什麼那個 container就是不肯乾淨地關閉。