# 併發計數與韌性：sharded metrics 與 circuit breaker

> gortex 用 FNV hash 把鎖拆成 16 片、高頻計數走 atomic，circuit breaker 是三態機，這裡用 atomic 與 mutex 比 channel 直接。

原文：https://sheng.page/posts/gortex-metrics-and-circuit-breaker/ · 發布：2026-05-03 · 作者：Sheng

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

> 前置閱讀：〈[goroutine 與 channel](/posts/go-goroutines-and-channels/)〉、〈[記憶體與併發原語](/posts/go-memory-and-sync-primitives/)〉

## Lock sharding：一把拆 16 把

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

```go
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 算出它落在哪一片，只鎖那一片：

```go
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`：

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

只有要連帶更新 map（依 status、依 method 分類）時才上 `httpMu`。這正是併發原語那篇的選法：單一計數用 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` 比對世代，把跨世代的舊請求結果丟掉。

```go
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

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

高頻計數的本質是「很多 goroutine 各自加一下」，最適合 atomic（單值無鎖）加 lock sharding（把競爭分散）。斷路器是「一小塊共享狀態機」，用 mutex 直接保護最直接。channel 真正適合的是〈[WebSocket Hub：Go 版 Actor 模型](/posts/gortex-websocket-actor-hub/)〉那種「獨佔一塊狀態、序列化事件流」的場景。同一個專案，這篇用 atomic / mutex、Hub 那篇用 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 留給 WebSocket Hub 那篇。
- 這是 goroutine 那篇「別迷信 channel」加併發原語那篇「選對原語」的實戰對照（這篇鎖 / atomic ↔ Hub 那篇 channel）。

原始碼：[yshengliao/gortex](https://github.com/yshengliao/gortex)。

---

> 想法與技術判斷出自 Sheng，和 Claude 一起起草 · 環境 Go 1.25（gortex go.mod）· 本系列為事後回填整理 · 列入 20260613 blog 翻新計劃，新漆未乾。