上游已经挂了,为什么还要继续请求:从 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