返回文章列表

上游已经挂了,为什么还要继续请求:从 ModelGate 讲熔断器

从 ModelGate 的 Provider 治理出发,解释 Circuit Breaker 的 CLOSED、OPEN、HALF_OPEN 三种状态、滑动窗口失败率、探测请求,以及它与 Retry、路由和流式请求之间的边界。

ModelGate 是我用 Go 实现的 LLM API 网关。

一条最基本的请求链路是:

Client
  ↓
ModelGate
  ↓
Provider
  ↓
DeepSeek / OpenAI-Compatible API

上一篇讲 Retry 时,一个核心思路是:

一次上游调用失败,不一定意味着下一次也会失败。

所以对于 429、503、网络故障和超时这类可能暂时恢复的问题,可以在有限预算内再次尝试。

但继续往下想,就会出现另一个问题:

如果 Provider 已经持续故障,为什么还要让每一个新请求都亲自失败一次?

假设某个 Provider 已经连续返回:

503
503
503
503
503

此时再来 1000 个请求。

如果每个请求仍然执行:

调用 Provider
    ↓
等待失败
    ↓
Retry
    ↓
再次失败

这些请求大概率不会帮助 Provider 恢复。

反而会继续消耗:

连接
goroutine
超时预算
上游容量
网关自己的资源

更严重的时候,还可能让本来已经过载的上游承受更多请求。

所以 ModelGate 在 Retry 外面又加入了一层:

Circuit Breaker,熔断器。

它试图回答的不是:

这一次失败以后要不要再试一次?

而是:

这个 Provider 最近是不是已经差到暂时不值得继续请求?

项目地址:theHerta27/ModelGate

熔断器首先不是“失败一次就封掉 Provider”

最简单的想法可能是:

请求失败
    ↓
Provider 不可用

但现实网络中偶尔失败一次很正常。

例如:

一次 DNS 抖动
一次连接重置
一次 503

如果一发生失败就把 Provider 禁用,很容易出现:

偶发错误
    ↓
立即熔断
    ↓
大量正常流量被拒绝

所以 ModelGate 判断的不是:

刚才有没有失败

而是:

最近一段请求的失败比例

默认配置是:

WindowSize          = 20
MinimumRequests     = 10
FailureRatio        = 0.5
OpenTimeout         = 30s
HalfOpenMaxRequests = 1

可以先把它理解成:

至少观察 10 个有效结果以后,如果最近最多 20 个结果里的失败比例达到 50%,才打开熔断器。

这里有两个很重要的限制:

最少请求数
+
失败比例

而不是简单的:

连续失败 N 次

为什么需要 MinimumRequests

假设服务刚启动,只收到一个请求:

Request 1 → 503

此时:

失败率 = 100%

如果只看失败比例:

100% >= 50%

就应该立刻熔断。

显然太敏感了。

只有一个样本,几乎不能说明 Provider 已经进入持续故障。

所以 ModelGate 默认要求:

MinimumRequests = 10

在至少记录 10 个结果之前,即使失败比例很高,也不会根据这套规则打开熔断器。

例如:

前 3 次:

失败
失败
成功

失败率 = 66.7%

虽然已经超过 50%,但是:

样本数 = 3
<
MinimumRequests = 10

所以仍然保持:

CLOSED

这实际上是在避免:

小样本造成过于激进的判断。

WindowSize 不是“20 秒”

ModelGate 的:

WindowSize = 20

并不是时间窗口。

它表示:

最多保留最近 20 个被计入熔断统计的逻辑请求结果。

源码里保存的是:

outcomes []bool
next     int
failures int

当记录还没有达到窗口大小时:

b.outcomes = append(b.outcomes, success)

窗口满以后,就覆盖最老的位置:

if !b.outcomes[b.next] {
    b.failures--
}

b.outcomes[b.next] = success

if !success {
    b.failures++
}

b.next = (b.next + 1) % b.options.WindowSize

所以它本质上是一个固定大小的循环窗口:

最近 20 个结果

[✓][✓][✗][✓][✗] ... [✗]
                         ↑
                      新结果

新的结果进来,最老的结果逐渐被替换。

因此:

WindowSize = 20

更准确的意思是:

用最近最多 20 个逻辑结果判断 Provider 当前状态。

而不是:

每隔 20 秒统计一次。

什么情况下真正打开熔断器

在 CLOSED 状态下,每个需要计入统计的请求结束后,都会更新窗口。

判断条件是:

if len(b.outcomes) >= b.options.MinimumRequests &&
    float64(b.failures)/float64(len(b.outcomes)) >= b.options.FailureRatio {
    b.transitionLocked(StateOpen)
}

默认情况下就是:

记录数量 >= 10

并且

失败数量 / 记录数量 >= 0.5

例如前 10 个逻辑请求:

✓ ✓ ✗ ✗ ✓ ✗ ✗ ✓ ✗ ✗

其中:

成功 = 4
失败 = 6

失败比例:

6 / 10 = 60%

已经满足:

60% >= 50%

熔断器于是从:

CLOSED

切换到:

OPEN

CLOSED、OPEN、HALF_OPEN 到底是什么意思

Circuit Breaker 最核心的就是三个状态:

stateDiagram-v2
    [*] --> CLOSED

    CLOSED --> OPEN: 失败率达到阈值

    OPEN --> HALF_OPEN: OpenTimeout 到期

    HALF_OPEN --> CLOSED: 探测成功
    HALF_OPEN --> OPEN: 探测失败

可以把它理解成一种 Provider 健康状态机。

CLOSED

正常工作状态。

请求
  ↓
允许访问 Provider
  ↓
记录最终结果

这里的“closed”容易第一次看反。

熔断器关闭,意味着:

电路是连通的,请求可以正常通过。

OPEN

熔断状态。

请求
  ↓
直接拒绝
  ↓
不再访问 Provider

电路打开,意味着请求链路被断开。

HALF_OPEN

试探恢复状态。

Provider 已经冷却了一段时间。

但系统还不能确定:

它真的恢复了吗?

所以只允许少量请求通过。

这些请求相当于:

Probe,探测请求。

OPEN 后到底发生什么

每一次调用 Provider 之前,ModelGate 都先:

circuitPermit, err := p.breaker.Allow()

如果当前处于 OPEN:

if b.state == StateOpen {
    return nil, ErrOpen
}

于是请求不会再继续进入:

Retry
  ↓
HTTP Client
  ↓
真实 Provider

而是在本地直接得到:

circuit breaker is open

完整路径从原来的:

请求
 ↓
Semaphore
 ↓
Circuit Breaker
 ↓
Retry
 ↓
Provider

变成:

请求
 ↓
Semaphore
 ↓
Circuit Breaker
 ↓
ErrOpen

也就是说:

Provider 已经被判断为持续故障以后,新请求不必再等待一次真实网络失败。

这就是熔断最直接的价值:

Fail Fast。

Fail Fast 为什么有意义

假设一个上游已经完全不可达。

没有熔断器时:

请求 A
→ dial
→ timeout

请求 B
→ dial
→ timeout

请求 C
→ dial
→ timeout

每个请求都要:

亲自证明一次 Provider 确实挂了

如果单次超时需要几秒:

用户延迟高
网关连接被占用
goroutine 等待
Provider 仍然承受探测流量

加入熔断以后:

最近失败已经达到阈值
    ↓
OPEN
    ↓
后续请求直接拒绝该 Provider

不需要再消耗一次完整的失败链路。

所以 Circuit Breaker 并不是让 Provider:

更快恢复

而是让系统在 Provider 已经明显异常时:

不要继续把大量资源浪费在大概率失败的调用上。

为什么 OPEN 以后不能永远不请求

另一个极端是:

Provider 一旦 OPEN
→ 永远禁止访问

那它即使一分钟后恢复了,ModelGate 也永远不知道。

因此 OPEN 必须有一个冷却时间。

ModelGate 默认:

OpenTimeout = 30s

进入 OPEN 时会记录:

b.openedAt = b.options.Now()

之后每次调用:

Allow()
State()
Available()

都会检查:

if b.state != StateOpen ||
    b.options.Now().Sub(b.openedAt) < b.options.OpenTimeout {
    return false
}

b.transitionLocked(StateHalfOpen)

也就是说:

OPEN
  ↓
等待至少 30 秒
  ↓
HALF_OPEN

有一个实现细节我觉得挺有意思:

ModelGate 没有专门启动一个 goroutine,在 30 秒以后修改熔断器状态。

而是在下一次有人查询状态或尝试请求时,才检查:

OpenTimeout 是否已经过去

如果过去,再惰性地切换成:

HALF_OPEN

所以没有额外的后台定时器需要维护。

为什么不能从 OPEN 直接恢复成 CLOSED

假设 Provider 之前已经严重故障。

30 秒过去了。

如果直接:

OPEN
 ↓
CLOSED

所有流量立刻恢复。

假设 Provider 实际上还没有完全恢复:

1000 个请求
    ↓
同时重新进入 Provider

可能瞬间再次把它压垮。

所以中间需要:

HALF_OPEN

只放少量请求进去。

默认:

HalfOpenMaxRequests = 1

也就是:

先让一个真实请求试一下。

如果这个请求成功:

HALF_OPEN
    ↓
Probe Success
    ↓
CLOSED

认为 Provider 已经可以重新接收正常流量。

如果这个请求失败:

HALF_OPEN
    ↓
Probe Failure
    ↓
OPEN

说明恢复还没有成功。

重新进入冷却阶段。

HALF_OPEN 为什么只能放少量流量

假设没有这个限制。

OPEN 30 秒以后:

HALF_OPEN

此时刚好同时来了 500 个请求。

如果全部放进去:

500 个 Probe

那 HALF_OPEN 和:

直接 CLOSED

几乎没区别。

因此 ModelGate 会记录:

halfOpenActive int

当处于 HALF_OPEN 时:

if b.halfOpenActive >= b.options.HalfOpenMaxRequests {
    return nil, ErrOpen
}

获得探测资格时:

b.halfOpenActive++

默认最大:

1

于是:

第一个请求
→ 可以作为 Probe

其他并发请求
→ ErrOpen

直到这个 Probe 得到结果。

这实际上就是:

恢复阶段也需要限流。

因为恢复中的服务通常比正常服务更加脆弱。

如果允许两个 Probe 呢

ModelGate 的实现并不写死:

HalfOpenMaxRequests = 1

配置可以改成:

2

这时最多允许两个 HALF_OPEN 请求同时进行。

测试里也覆盖了这个场景:

Probe A → Allowed
Probe B → Allowed
Probe C → ErrOpen

两个 Probe 都成功以后:

halfOpenPassed = 2

达到:

HalfOpenMaxRequests = 2

熔断器才回到:

CLOSED

但只要其中出现:

OutcomeFailure

就立即重新:

OPEN

这体现的是一种比较保守的恢复策略:

恢复需要证明成功,重新失败只需要一次明确证据。

CLOSED 后为什么要清空旧窗口

如果 HALF_OPEN 探测成功,Provider 回到:

CLOSED

源码会:

b.outcomes = nil
b.next = 0
b.failures = 0

也就是把之前导致熔断的历史窗口清掉。

否则会出现:

旧窗口:
大量失败

Provider 刚恢复:

Probe 成功

一进入 CLOSED:

又立刻因为旧失败率过高重新 OPEN

那就无法真正恢复。

因此一次成功恢复实际上开始了新的统计周期:

之前的故障历史
→ 不继续拖累新的 CLOSED 状态

为什么不是所有错误都应该让熔断器记失败

和 Retry 一样,Circuit Breaker 也必须区分错误。

假设客户端传了一个错误请求:

400 Bad Request

这并不能说明:

Provider 挂了

如果有人不断发送错误参数:

400
400
400
400

最后把 Provider 熔断,显然不合理。

ModelGate 因此单独实现了:

func IsCircuitFailure(err error) bool

当前会认为这些情况属于熔断失败:

网络错误
Invalid Response
HTTP 429
HTTP >= 500
其他无法归类的内部上游错误

而:

context.Canceled

明确不算失败。

对于结构化的普通 4xx,例如:

400
401
403

也不会因为客户端自己的问题污染 Provider 健康统计。

这和 Retry Policy 虽然有重叠,但不是完全相同。

Retryable 和 Circuit Failure 不是一个概念

这是 ModelGate 里一个值得注意的区别。

Retry 当前比较保守。

主要重试:

429
503
DeadlineExceeded
明确网络故障

但熔断器判断 Provider 健康时,还会把:

其他 5xx
Invalid Response

算成 Circuit Failure。

例如 Provider 连续返回:

500

当前 Retry Policy 不一定会自动重复请求。

但是这些:

500

仍然是在说明:

Provider 当前服务质量有问题。

所以可以进入熔断统计。

也就是说:

Retryable

问的是:

这一次失败,再试一次有没有意义?

而:

Circuit Failure

问的是:

这个失败是否应该影响我对 Provider 整体健康状态的判断?

两个问题不一样。

因此它们不应该强行共用一个布尔变量。

客户端取消为什么不应该计入熔断

假设用户:

打开聊天页面
 ↓
发起请求
 ↓
等了一秒
 ↓
主动关闭页面

Context 被取消。

这条请求可能最终返回:

context.Canceled

但这不能说明:

Provider 不健康

真正发生的是:

客户端已经不需要结果了。

所以:

if err == nil || errors.Is(err, context.Canceled) {
    return false
}

客户端取消不会增加失败率。

否则如果大量用户主动离开页面:

Canceled
Canceled
Canceled
Canceled

熔断器反而会错误地判断:

Provider 正在大量失败

这说明做可靠性统计时,一个非常重要的问题是:

错误是谁造成的?

不是所有 error 都代表被依赖服务出了问题。

Circuit Breaker 看到的是 Retry 之后的最终结果

上一篇讲 Retry 时提到一个很重要的设计。

ModelGate 的调用顺序是:

Circuit Breaker Allow
        ↓
Retry.Do
        ↓
Attempt 1
Attempt 2
Attempt 3
        ↓
得到最终 err
        ↓
Circuit Breaker Done

代码大致是:

circuitPermit, err := p.breaker.Allow()

err = p.retry.Do(
    ctx,
    provider.IsRetryable,
    func(attemptCtx context.Context) error {
        // call provider
    },
)

circuitPermit.Done(circuitOutcome(err))

因此熔断器统计的是:

逻辑请求的最终结果。

假设:

Attempt 1 → 503
Attempt 2 → 503
Attempt 3 → success

Circuit Breaker 最终收到:

OutcomeSuccess

而不是:

Failure
Failure
Success

为什么这样做?

假设:

WindowSize = 20

如果一个逻辑请求内部最多 Retry 3 次,而且每一个 Attempt 都进入熔断统计:

一个用户请求

就可能贡献:

三个窗口样本

Retry 次数越多,对 Circuit Breaker 的权重越大。

结果会把两个不同概念混在一起:

用户逻辑请求失败率

和:

内部 Attempt 失败次数

ModelGate 当前选择:

Retry 先尝试内部恢复,Circuit Breaker 再判断这一个逻辑调用最终有没有恢复成功。

这样每一个 Gateway → Provider 的逻辑调用在窗口里只贡献一个结果。

那 Retry 会不会把故障隐藏掉

会隐藏一部分。

例如:

100 个逻辑请求

每一个都是:

Attempt 1 → 503
Attempt 2 → success

从 Circuit Breaker 的最终结果看:

100% Success

所以不会熔断。

但事实上 Provider 的第一次调用已经非常不稳定。

这也是为什么 ModelGate 另外记录:

Upstream Attempt Metrics

Retry 内部的:

success
retryable_error
error

仍然可以从监控中观察。

因此:

Circuit Breaker

不是唯一的故障观测来源。

它的目标是:

判断逻辑请求是否已经差到应该停止继续访问 Provider。

而 Retry Attempt 指标可以告诉我们:

即使最终还能恢复,上游是否已经开始频繁抖动。

这两个视角需要同时存在。

流式请求什么时候算成功

非流式调用比较简单。

Chat() 返回完整响应:

response != nil
err == nil

就可以立即:

OutcomeSuccess

但 SSE 不一样。

ChatStream() 返回一个 Stream,只代表:

上游流建立成功了。

后面仍然可能:

收到几帧
    ↓
网络断开

如果流刚建立时就立刻:

Circuit Breaker → Success

那么一个大量中途断流的 Provider 可能看起来一直很健康。

所以 ModelGate 返回的是包装后的:

managedStream

真正到:

Recv()

时才继续观察流结果。

正常:

io.EOF

表示流完整结束:

s.finish(circuitbreaker.OutcomeSuccess)

如果中途:

Recv() → error

则:

s.finish(circuitOutcome(err))

也就是说:

建立连接成功
≠
整个流成功

熔断器会把 Stream 的真实生命周期考虑进去。

为什么 Close 不一定代表失败

客户端可能主动停止读取,然后调用:

stream.Close()

这和:

Provider 中途故障

不是一回事。

所以如果底层:

Close()

本身成功:

s.finish(circuitbreaker.OutcomeIgnored)

不会把一次正常资源释放当作 Provider Failure。

如果:

Close()

自己也返回错误:

OutcomeFailure

才会影响 Circuit Breaker。

这里再次体现的是:

熔断器不能只看“函数返回了没有”。

而要判断:

这个结果是不是 Provider 健康状态的有效证据。

Permit 为什么只能 Done 一次

Circuit Breaker 的:

Allow()

返回的是一个:

Permit

请求结束后:

permit.Done(outcome)

源码里:

type Permit struct {
    breaker *Breaker
    state State
    generation uint64
    once sync.Once
}

Done 会:

p.once.Do(func() {
    p.breaker.complete(...)
})

因此同一个 Permit 即使:

Done
Done
Done

也只会统计一次。

为什么需要这个保护?

流式请求尤其容易有多条清理路径:

Recv → EOF
Recv → Error
Close
Context Cancel

如果:

EOF

已经把请求记录为成功。

后面调用:

Close

又再记录一次结果,就会导致一个逻辑请求在熔断窗口中出现多个样本。

所以:

一次 Permit
→ 最多一次 Outcome

是一个很重要的不变量。

generation 是为了防什么

Breaker 里还有:

generation uint64

每次状态切换都会:

b.generation++

Permit 创建时记录:

generation: b.generation

请求完成时会检查:

if generation != b.generation ||
    permitState != b.state {
    return
}

这个设计主要是在防:

旧状态下发出去的请求,晚很久才回来以后,污染新的状态统计。

例如:

Generation 1
CLOSED

请求 A 开始
        ↓
其他请求让 Breaker OPEN
        ↓
等待 30 秒
        ↓
HALF_OPEN
        ↓
Probe 成功
        ↓
重新 CLOSED,Generation 已变化
        ↓
很久以前的请求 A 才返回

如果 A 还能继续写入当前窗口:

旧时代的结果

就可能干扰一个已经重新开始的健康周期。

所以 Permit 会记住:

我是在哪个 generation 下被允许的

如果返回时 Breaker 已经换了一代:

结果作废

这是一个实现层面的细节,但它让我更容易理解:

并发状态机不仅要处理“当前状态是什么”,还要处理“这个异步结果属于哪个状态时期”。

熔断器为什么必须并发安全

ModelGate 是 Go 服务。

同一个 Provider 可能同时被很多 goroutine 调用:

goroutine 1 ─┐
goroutine 2 ─┤
goroutine 3 ─┼→ Circuit Breaker
goroutine 4 ─┤
goroutine 5 ─┘

这些请求会同时:

Allow
Done
State
Available

如果 Breaker 内部没有同步保护:

failures
next
state
halfOpenActive
generation

都可能发生数据竞争。

因此实现内部使用:

mu sync.Mutex

保护整个状态转换和统计窗口。

这也是为什么熔断器不能只写成:

if failures >= 5 {
    open = true
}

它本质上是一个:

被大量并发请求共享的状态机。

Circuit Breaker 和 Router 是怎样配合的

ModelGate 支持多个 Provider。

Router 在选择目标时会检查:

candidate.Available()

而 Governance Provider 的:

func (p *Provider) Available() bool {
    return p.breaker.Available()
}

所以如果某个 Provider 已经:

OPEN

Router 后续选择时就会跳过它。

例如:

Provider A → OPEN
Provider B → CLOSED
Provider C → CLOSED

Router 会从:

B / C

中继续选择。

因此 Circuit Breaker 不只是:

调用到 Provider A 时快速失败

它也会影响后续的:

Provider 路由选择

这让熔断状态真正成为了 Provider 健康信息的一部分。

但 Router 不会因为一次失败立刻跨 Provider Retry

这里需要和上一篇 Retry 的边界区分开。

假设 Router 选择:

Provider A

当前这一次调用:

Attempt 1 → A
Attempt 2 → A
Attempt 3 → A

Retry 仍然发生在已经选择好的 Provider 内部。

并不会:

A 失败
 ↓
立刻换 B
 ↓
B 失败
 ↓
换 C

Circuit Breaker 如果最终根据历史结果把:

A → OPEN

那么后续路由请求才会跳过 A。

所以:

Retry
→ 当前 Provider 内部恢复

Circuit Breaker
→ 维护 Provider 健康状态

Router
→ 后续请求避开不可用 Provider

三者的职责并不一样。

为什么不能把 Semaphore 和 Circuit Breaker 混在一起

ModelGate 在 Provider 前面还有一个:

Semaphore

默认:

ProviderMaxConcurrency = 50

它解决的问题是:

同时最多允许多少个请求进入这个 Provider?

而 Circuit Breaker 解决:

Provider 最近是不是已经持续失败到不值得继续请求?

例如 Provider 完全健康,但当前已经:

50 个请求正在执行

第 51 个请求需要等待 Semaphore。

这不是故障。

反过来 Provider 当前没有很多请求,但是:

最近 10 个请求
6 个失败

Circuit Breaker 可能直接 OPEN。

这也说明:

Concurrency Limit

和:

Circuit Breaker

不是同一个东西。

一个控制:

同时有多少请求

一个控制:

当前是否还应该请求

为什么顺序是 Semaphore → Circuit Breaker

ModelGate 当前 Chat() 的顺序是:

Acquire Semaphore
        ↓
Circuit Breaker Allow
        ↓
Retry
        ↓
Provider

也就是说一个请求首先取得 Provider 并发 Permit,再检查 Breaker。

这是当前代码的真实实现。

它的直接效果是:

Circuit Breaker OPEN 时,最终仍然会很快释放刚取得的 Semaphore Permit。

因为:

defer concurrencyPermit.Release()

已经注册。

对于流式请求,如果 Breaker 拒绝:

concurrencyPermit.Release()

也会立即执行。

另一种设计也可以考虑先检查熔断器再拿 Semaphore,从而让已经 OPEN 的请求更早失败。

但当前项目没有采用那个顺序。

所以这里不能把现有实现描述成:

熔断以后完全不会碰并发控制层。

更准确的是:

它不会真正进入 Retry 和上游 Provider 调用。

OPEN 以后请求是不是全部失败

如果系统只有一个 Provider:

primary → OPEN

那么对应请求最终可能得到不可用错误。

但如果 Router 中还有其他健康 Provider:

primary → OPEN
backup → CLOSED

后续路由可以选择:

backup

所以 Circuit Breaker 的价值还取决于系统架构。

在单 Provider 场景里:

OPEN
→ Fail Fast

主要是保护资源和降低无意义等待。

在多 Provider 场景里:

OPEN
→ Router 排除该 Provider
→ 流量转向其他健康目标

还可以参与故障隔离。

怎样验证熔断器的状态转换

ModelGate 对 Breaker 本身做了独立单元测试。

最基本的一条路径是:

CLOSED
 ↓
OPEN
 ↓
HALF_OPEN
 ↓
CLOSED

测试使用一个可控制的时间函数:

now := time.Unix(100, 0)

Now: func() time.Time {
    return now
}

这样不需要真的:

sleep 30 秒

而是手动:

now = now.Add(time.Second)

推进时间。

例如测试配置:

WindowSize = 2
MinimumRequests = 2
FailureRatio = 0.5

先执行:

Success
Failure

失败比例:

1 / 2 = 50%

于是:

CLOSED → OPEN

OpenTimeout 到期以后:

OPEN → HALF_OPEN

一个 Probe 成功:

HALF_OPEN → CLOSED

并且测试还会检查状态变化顺序:

open
half_open
closed

全部发生。

HALF_OPEN 失败也需要单独验证

另一条测试专门验证:

CLOSED
 ↓
Failure
 ↓
OPEN
 ↓
等待
 ↓
HALF_OPEN
 ↓
Probe Failure
 ↓
OPEN

而且重新 OPEN 以后:

OpenTimeout

需要从这次失败重新计算。

不能刚失败:

马上又 HALF_OPEN

否则就会形成:

试一下
失败
马上再试
失败
马上再试

这实际上失去了冷却时间的意义。

为什么要测试多个 HALF_OPEN Probe

当:

HalfOpenMaxRequests = 2

测试会:

Allow Probe 1
Allow Probe 2

随后:

Available() = false

第三个请求:

ErrOpen

两个 Probe 都成功以后才:

CLOSED

这条测试验证了一个并发状态:

不是“HALF_OPEN 就意味着所有请求又能进来了”。

而是:

HALF_OPEN
+
Probe 配额

一起决定当前可用性。

Provider 层还验证了 Retry 和 Circuit Breaker 的组合

Governance Provider 的测试里,有一个场景:

每个逻辑请求内部最多尝试:

3 次

上游一直返回:

503

设置:

WindowSize = 2
MinimumRequests = 2
FailureRatio = 1

连续两个逻辑请求最终失败:

Logical Request 1
→ 3 次 Attempt 全失败
→ 记 1 次 Circuit Failure

Logical Request 2
→ 3 次 Attempt 全失败
→ 再记 1 次 Circuit Failure

于是:

2 / 2 = 100%

Breaker OPEN。

接下来再调用:

Chat()

直接得到:

ErrOpen

不会再真正访问 fake Provider。

这条测试特别能说明:

熔断窗口记录的是两次逻辑失败,而不是六次内部 Retry Attempt。

为什么 Circuit Breaker 状态也需要监控

ModelGate 把熔断状态暴露成 Prometheus Gauge:

modelgate_circuit_breaker_state

值定义为:

0 = closed
1 = open
2 = half_open

这样 Grafana 可以直接看到:

Provider A
状态什么时候从 CLOSED 变 OPEN

持续多久

什么时候进入 HALF_OPEN

有没有成功恢复

为什么这比只记录:

error count

更有意义?

因为:

大量 error

说明过去发生了问题。

而:

Circuit State = OPEN

告诉运维:

系统现在正在主动隔离这个 Provider。

它属于当前运行状态,而不仅仅是历史事件。

熔断器本身不是健康检查

ModelGate 另外还有 Provider 健康检查机制。

Circuit Breaker 和主动 Health Check 也不能混为一谈。

Circuit Breaker 的信息来源主要是:

真实业务请求的结果

也就是被动观察。

它知道:

最近真正调用 Provider 时,有多少失败。

而主动健康检查通常是:

定期发探测请求

判断:

Provider 当前能不能响应。

两者可以互补。

例如:

业务流量很低

可能很久都没有足够样本触发 Circuit Breaker。

主动健康检查还能独立发现问题。

反过来:

/health 返回正常

也不一定代表真实模型生成请求:

延迟正常
不会 429
不会 5xx

所以“健康”本身也有不同层次。

熔断器不等于彻底解决雪崩

Circuit Breaker 经常被称为:

防止级联故障

但不能因此理解成:

有了熔断器,系统就不会雪崩。

例如 Provider A OPEN 后,如果所有流量立即转到:

Provider B

而 B 原本只承担 50% 流量:

现在突然承担 100%

B 自己也可能过载。

于是:

A OPEN
  ↓
流量全部转 B
  ↓
B 过载
  ↓
B OPEN

仍然可能形成连锁故障。

所以完整的可靠性还需要结合:

Timeout
Retry
Backoff
Circuit Breaker
Concurrency Limit
Load Balancing
Capacity Planning

Circuit Breaker 只是其中一层。

它真正能保证的是:

当某个依赖已经明显失败时,不再无边界地继续向它发送请求。

阈值应该怎么定

ModelGate 当前默认:

WindowSize = 20
MinimumRequests = 10
FailureRatio = 0.5
OpenTimeout = 30s
HalfOpenMaxRequests = 1

这些是项目里的配置默认值。

但它们不能被解释成:

生产环境的最佳参数。

比如:

MinimumRequests 太小

Breaker 容易因为偶发错误过度敏感。

MinimumRequests 太大

故障真正发生以后,又需要较长时间才能识别。

同样:

FailureRatio 太低
→ 容易误熔断

FailureRatio 太高
→ Provider 已经很差了仍然继续放流量

OpenTimeout 也是取舍:

太短
→ Provider 还没恢复就频繁 Probe

太长
→ Provider 明明已经恢复却长时间无法重新接流量

因此真正上线以后,这些参数应该结合:

Provider 正常错误率
请求量
故障恢复速度
SLO
备用 Provider 容量

再调整。

项目现在能证明的是:

这套状态机和并发边界按设定参数工作。

而不是:

这些默认参数已经经过真实生产流量调优。

面试时我会怎样回答

如果面试官问:

你 ModelGate 里的熔断器怎么实现的?

我会回答:

我给每个 Provider 单独维护一个并发安全的 Circuit Breaker,有 CLOSED、OPEN、HALF_OPEN 三种状态。CLOSED 时记录最近逻辑请求的成功失败结果,默认最多看 20 个样本,至少有 10 个请求后,如果失败比例达到 50% 就 OPEN。

OPEN 后新的上游调用会直接 fail fast,不进入 Retry 和真实 Provider;默认冷却 30 秒后进入 HALF_OPEN。HALF_OPEN 默认只允许一个探测请求,成功就恢复 CLOSED,失败就立即重新 OPEN。

熔断统计看的不是每次 Retry Attempt,而是 Retry 结束后的最终逻辑结果,否则一个重试三次的请求会在窗口里占三个样本。另外像客户端主动取消、普通 4xx 不会算 Provider Failure,而网络故障、Invalid Response、429 和 5xx 会影响熔断状态。

如果继续问:

为什么 HALF_OPEN 不能直接恢复全部流量?

我会回答:

因为 OpenTimeout 到期只说明已经等待了一段时间,并不能证明 Provider 已经恢复。如果直接 CLOSED,大量请求可能瞬间重新打过去。所以 HALF_OPEN 只放少量真实请求做探测,当前默认就是 1 个,探测成功以后才恢复正常流量。

如果继续问:

Retry 和熔断有什么区别?

我会回答:

Retry 是单个逻辑请求内部的恢复机制,回答“这次失败要不要再试”;Circuit Breaker 是跨请求维护的 Provider 健康状态,回答“最近已经失败成这样了,新请求还值不值得继续尝试”。Retry 处理瞬时故障,熔断器处理持续故障,两者是不同时间尺度上的治理。

我从这个问题中理解到的事情

一开始接触熔断器时,我最容易记住的是:

CLOSED
OPEN
HALF_OPEN

然后觉得它就是一个:

失败几次
→ 关掉服务
→ 等一会
→ 再打开

的状态机。

但真正把它和 Retry、Router、流式请求放在一起以后,我觉得更重要的是:

熔断器其实是在维护系统对一个外部依赖的“信任程度”。

CLOSED 表示:

目前没有足够证据认为它不健康
→ 正常放行

OPEN 表示:

最近已经有足够多的失败证据
→ 暂时不再相信它

HALF_OPEN 表示:

已经冷却了一段时间
→ 允许有限的真实请求重新验证

所以一个熔断器真正需要考虑的不是只有状态名字。

还包括:

什么结果算失败?

至少观察多少请求以后才判断?

观察最近多少个结果?

失败到什么比例才 OPEN?

OPEN 多久以后允许恢复?

恢复时放多少请求?

旧状态下晚到的请求结果怎么办?

Retry 内部失败应该算几个样本?

流建立成功但中途断掉应该怎么算?

多个 goroutine 同时修改状态时怎样保证一致?

ModelGate 当前把这些关系组织成:

Sliding Window
→ 保存最近逻辑结果

Minimum Requests
→ 避免小样本误判

Failure Ratio
→ 判断持续故障

OPEN
→ Fail Fast,停止无意义调用

Open Timeout
→ 给上游恢复时间

HALF_OPEN
→ 用少量真实请求验证恢复

Probe Limit
→ 防止恢复瞬间重新压垮上游

Generation
→ 防止旧请求污染新的状态周期

Mutex
→ 保证并发状态转换安全

Retry Final Outcome
→ 每个逻辑请求只贡献一个熔断样本

Router Available
→ 后续路由避开已熔断 Provider

而我觉得这里最值得记住的一句话是:

Retry 是“这一次再试试”,Circuit Breaker 是“最近已经失败成这样了,先别再试了”。

一个面对瞬时故障。

一个面对持续故障。

两者放在一起,才比较完整地回答了:

当上游服务开始变得不可靠时,网关应该怎样继续工作。

延伸阅读

  • Martin Fowler:Circuit Breaker
  • Microsoft Azure Architecture Center:Circuit Breaker Pattern
  • AWS Builders’ Library:Timeouts, retries and backoff with jitter
  • Go Documentation:sync.Mutex