併發計數與韌性:sharded metrics 與 circuit breaker

高併發下,一把全域鎖就是瓶頸。gortex 的 metrics collector 把鎖拆成 16 片、計數器用 atomic;circuit breaker 則是教科書級的三態機。這篇講為什麼這裡反而不該用 channel。

前置:A4 goroutine/channel、A5 併發原語。

Lock sharding:一把拆 16 把

高併發下,所有 goroutine 搶同一把鎖改同一個 metrics map,那把鎖就是瓶頸。gortex 的 ShardedCollector 把 business metrics 拆成 16 個獨立分片,每片自己一把鎖:

type ShardedCollector struct {
    httpRequestCount int64 // atomic 計數
    shardCount       int   // 固定 16
    shards           []*metricShard
}
type metricShard struct {
    mu      sync.RWMutex
    metrics map[string]float64
    lruList *list.List // per-shard LRU
    // ...
}

要記一個 metric,先用 FNV hash 算出它落在哪一片,只鎖那一片:

shardIndex := c.hashKey(metricKey) // fnv.New32a() % 16
shard := c.shards[shardIndex]
shard.mu.Lock()
defer shard.mu.Unlock()
// ...只動這一片

16 是固定值(原始碼註解寫「for predictable performance」),不隨 CPU 數變動。鎖競爭就從「全域一把」分散到「平均 1/16」。

atomic 計數與 per-shard LRU

不是所有東西都用鎖,最高頻的計數器,像 HTTP 請求數、WebSocket 連線數,直接用 atomic

atomic.AddInt64(&c.httpRequestCount, 1) // 無鎖

只有要連帶更新 map(依 status、依 method 分類)時才上 httpMu。這正是 A5 的選法:單一計數用 atomic,一段狀態才用 Mutex。

每片各自做 LRU eviction:metric 數超過該片上限(maxCardinality / 16)就從該片的 list.List 尾端淘汰最久沒用到的。LRU 是 per-shard 的,若做成全域 LRU,它本身又會變成新瓶頸,拆鎖就白拆了。

Circuit breaker 三態機

斷路器擋住打往已壞下游的請求,給它時間恢復。gortex 的是教科書三態:

  • Closed:正常放行;失敗累積到 ReadyToTrip(預設「請求數 > 10 且失敗率 > 0.5」)就跳 Open。
  • Open:直接回 ErrCircuitOpen,不打下游;過了 Timeout(預設 60s)轉 Half-Open。
  • Half-Open:只放 MaxRequests 個試探請求;成功達 MaxRequests 個才回 Closed,一個失敗就退回 Open。

並發控制有兩個關鍵:state 存在 atomic.Value、counts 與 expiry 用 sync.Mutex 保護;以及 generation,用 expiry.UnixNano() 當世代號,afterRequest 比對世代,把跨世代的舊請求結果丟掉。

func (cb *CircuitBreaker) onBeforeRequestHalfOpen() (uint64, error) {
    cb.mu.Lock()
    defer cb.mu.Unlock()
    if cb.halfOpen >= cb.config.MaxRequests {
        return 0, ErrTooManyRequests
    }
    cb.halfOpen++
    return uint64(cb.expiry.UnixNano()), nil
}

注意 half-open 的限流計數 halfOpen 是用 mu 保護的,不是 atomic,因為這個門檻必須跟 state / expiry 的轉換在同一把鎖下保持一致,否則併發試探會衝破 MaxRequests

這裡為什麼不用 channel

A4 留的伏筆在這裡收。如果照 actor 心法,把所有計數塞進一個 channel、由單一 goroutine 序列化處理,那個 goroutine 就成了新的全域瓶頸,跟「一把全域鎖」一樣糟,還多一層延遲。

高頻計數的本質是「很多 goroutine 各自加一下」,最適合 atomic(單值無鎖)加 lock sharding(把競爭分散)。斷路器是「一小塊共享狀態機」,用 mutex 直接保護最直接。channel 真正適合的是 B6 那種「獨佔一塊狀態、序列化事件流」的場景。同一個專案,B5 用 atomic / mutex、B6 用 channel,看資料是什麼性質來選工具,不是反射性套 channel。

重點整理

  • Lock sharding:FNV hash 把 metrics 分到 16 片、各片一把鎖,競爭降到約 1/16。
  • 最高頻計數用 atomic(無鎖),要連帶更新 map 才上 Mutex;LRU 做成 per-shard,避免全域瓶頸。
  • 斷路器三態 Closed / Open / Half-Open:state 在 atomic.Value、counts 在 mutex、用 generation 丟掉舊世代請求;half-open 限流刻意用 mu 保護以維持一致。
  • 為什麼不用 channel:高頻計數與共享狀態機用 atomic / mutex 更快更直接;channel 留給 B6 的 hub。
  • 這是 A4「別迷信 channel」加 A5「選對原語」的實戰對照(B5 鎖 / atomic ↔ B6 channel,gortex-websocket-actor-hub)。

原始碼:yshengliao/gortex


大綱 Sheng,內文 Claude 協助 · 環境 Go 1.25(gortex go.mod)· 本系列為事後回填整理 · 列入 20260613 blog 翻新計劃,新漆未乾。