熔断器:你服务崩溃前的最后一根稻草
凌晨三点,你被手机震醒。监控大屏一片红色,接口错误率飙到40%。你慌忙打开日志,发现所有超时都指向同一个下游服务——商品查询。
你赶紧给下游团队打电话。对方睡眼惺忪:"我们正在做紧急GC优化,预计需要两小时才能恢复。"
两小时?你的服务已经半死不活了。更要命的是,由于你没有熔断机制,大量请求正在疯狂重试,不仅把自己的资源耗尽,还把下游仅剩的一点处理能力彻底堵死——这不是救援,这是帮倒忙。
那天早上的经历让我彻底想明白一件事:服务不可用本身不可怕,可怕的是你的服务因为下游故障把自己也拖死。熔断器(Circuit Breaker)就是来解决这个问题的。它不是银弹,但它是分布式系统必备的最后一道防线。
熔断器是什么
想象一下你家的电路保险丝。当电流过载时,保险丝会熔断,切断电路,防止电器被烧坏。熔断器模式就是这个原理的软件实现:当下游服务持续失败时,"切断"对下游的调用,快速失败返回,而不是让请求堆积、线程耗尽、资源耗光。
没有熔断器时,服务调用是这样的:
func GetProduct(id string) (*Product, error) {
resp, err := http.Post(productService, id)
if err != nil {
return nil, err // 超时了?继续重试!重试到天荒地老!
}
return parse(resp), nil
}
下游慢,你等;下游挂了,你还等——等到超时,然后报错,然后上游也跟着慢下来。一个请求的超时可能就几秒,但1000个并发请求堆积起来,就是线程池耗尽、内存暴涨、服务重启。这就是级联失败的经典路径。
加上熔断器之后:
func GetProduct(id string) (*Product, error) {
if cb.IsOpen() { // 断路器打开了
return fallback(id) // 直接走降级,不打扰下游
}
resp, err := http.Post(productService, id)
if err != nil {
cb.RecordFailure() // 记录失败
return fallback(id) // 降级处理
}
cb.RecordSuccess() // 记录成功
return parse(resp), nil
}
当下游出问题时,熔断器"打开",所有请求直接走降级逻辑,不发往下游。下游得到了喘息空间,你的服务也不会被拖垮。两小时后下游恢复了,熔断器自动"闭合",一切如常。
状态机:熔断器的灵魂
熔断器本质上是一个状态机,有且只有三种状态:
Closed(关闭态):正常运行状态,所有请求正常通过下游。失败计数器默默记录着一切,但请求仍然放行。这个状态下熔断器像个安静的监控者。
Open(打开态):检测到故障达到阈值,熔断器打开,所有请求直接降级,不发往下游。这是最关键的状态——保护下游不要被进一步打爆,也保护自己不要被拖死。
HalfOpen(半开态):Open状态持续一段时间后,熔断器进入半开态,此时放行一个请求试试水。如果成功,说明下游已经恢复,熔断器关闭(回到Closed);如果还是失败,说明下游还没恢复,继续Open。
这个状态转换看似简单,只有三条规则,但魔鬼都在细节里。滑动窗口怎么设计?半开态放行几个请求?成功要不要减少失败计数?每一个细节都是生产环境里踩出来的坑。
用Go实现一个工业级熔断器
先上一个完整实现,再逐行拆解那些容易被忽略的细节:
package circuitbreaker
import (
"math"
"sync"
"time"
)
type State int
const (
StateClosed State = iota
StateOpen
StateHalfOpen
)
type CircuitBreaker struct {
mu sync.RWMutex
state State
failureThreshold int // 触发熔断的失败次数阈值
halfOpenMaxReqs int // 半开态允许通过的请求数
openDuration time.Duration // Open持续多久后进入HalfOpen
failures int // 当前窗口失败数
successes int // 半开态成功计数
lastFailure time.Time // 上次失败时间
windowSize time.Duration // 滑动统计窗口大小
requests []requestRecord // 滑动窗口内的请求记录
}
type requestRecord struct {
timestamp time.Time
success bool
}
func New(failureThreshold int, openDuration time.Duration) *CircuitBreaker {
cb := &CircuitBreaker{
failureThreshold: failureThreshold,
halfOpenMaxReqs: 1, // 半开态只放行一个请求
openDuration: openDuration,
windowSize: 10 * time.Second,
}
go cb.stateManager() // 后台状态管理协程
return cb
}
func (cb *CircuitBreaker) IsOpen() bool {
cb.mu.RLock()
defer cb.mu.RUnlock()
return cb.state == StateOpen
}
func (cb *CircuitBreaker) Allow() bool {
cb.mu.Lock()
defer cb.mu.Unlock()
switch cb.state {
case StateClosed:
return true
case StateOpen:
if time.Since(cb.lastFailure) > cb.openDuration {
cb.state = StateHalfOpen
cb.successes = 0
return true
}
return false
case StateHalfOpen:
if cb.successes < cb.halfOpenMaxReqs {
return true
}
return false
}
return false
}
func (cb *CircuitBreaker) RecordSuccess() {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.cleanWindow()
cb.requests = append(cb.requests, requestRecord{time.Now(), true})
if cb.state == StateHalfOpen {
cb.successes++
if cb.successes >= cb.halfOpenMaxReqs {
cb.state = StateClosed
cb.failures = 0
cb.requests = nil
}
} else {
if cb.failures > 0 {
cb.failures--
}
}
}
func (cb *CircuitBreaker) RecordFailure() {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.lastFailure = time.Now()
cb.cleanWindow()
cb.requests = append(cb.requests, requestRecord{time.Now(), false})
cb.failures++
if cb.state == StateHalfOpen {
cb.state = StateOpen
} else if cb.failures >= cb.failureThreshold {
cb.state = StateOpen
}
}
func (cb *CircuitBreaker) cleanWindow() {
cutoff := time.Now().Add(-cb.windowSize)
filtered := make([]requestRecord, 0)
for _, r := range cb.requests {
if r.timestamp.After(cutoff) {
filtered = append(filtered, r)
}
}
cb.requests = filtered
}
五个生产级细节
1. 滑动窗口而不是简单计数器
很多人用简单计数器:失败N次就开熔断。但这是个巨大的坑。
比如,前59秒失败了99次,第60秒成功1次,第61秒又失败了——此时如果计数器清零重来,熔断器就白白错过了高失败率的时段。而真实错误率可能仍然很高。
正确的做法是滑动窗口:在最近N秒内,失败率超过X%就熔断。上述代码中requests数组就是滑动窗口,每次记录前先清理过期数据,只统计最近10秒的情况。windowSize的大小根据你的业务SLA来定,一般5-30秒比较合理。
2. 半开态的请求隔离
半开态最容易被忽略的一个细节:如果此时大量请求涌入,你打算放行几个?
答案是只放行一个。如果半开态放行10个请求,而这10个请求全部失败,那下游服务会承受10倍的压力冲击,很可能直接被打死,从半开态打回Open态,形成恶性循环。
我们的实现中用halfOpenMaxReqs=1确保了这个隔离。只放一个请求出去探路,成功了再逐步恢复正常流量。
3. 成功要"奖励"失败计数减少
在Closed状态下,某次成功后主动减少失败计数(比如减1),这能让熔断器在下游部分恢复时逐渐自我修复,而不需要等到失败计数彻底归零才能恢复。
这个技巧能显著减少"抖动"(flapping)现象——否则熔断器会在Open和Closed之间反复横跳,每隔几秒就开关一次,下游服务始终处于不稳定状态。
4. 熔断器本身也需要容错
你没看错。熔断器本身也可能成为单点故障。如果熔断器的状态管理协程崩溃了怎么办?如果进程重启,熔断器状态全部丢失怎么办?
生产级做法:熔断器状态外置到Redis或Etcd,分布式共享。或者更简单——定期将状态快照写盘,进程重启后能恢复。这些问题很多人没想过,但一旦遇到就是半夜三点。
// 状态快照持久化示例
type CircuitBreakerState struct {
State State
Failures int
LastFailure time.Time
}
func (cb *CircuitBreaker) Snapshot() CircuitBreakerState {
cb.mu.RLock()
defer cb.mu.RUnlock()
return CircuitBreakerState{
State: cb.state,
Failures: cb.failures,
LastFailure: cb.lastFailure,
}
}
5. 降级策略才是真正的救命稻草
熔断之后怎么办?"返回一个error"不是答案,是逃避。
真正的降级策略包括:返回缓存数据(即使可能过期)、返回默认值、路由到备用服务、返回一个"服务繁忙请稍后再试"的友好提示。每一种降级都比直接报错强一百倍。
func GetProduct(id string) (*Product, error) {
if !cb.Allow() {
if product := localCache.Get(id); product != nil {
log.Warnf("circuit open, returning stale cache for %s", id)
return product, nil
}
return &Product{ID: id, Name: "商品加载中..."}, nil
}
// 正常逻辑...
}
你可能在用的"伪熔断"
写到这里我得吐槽一下。见过太多所谓的"熔断器实现",本质上是这样的:
var failedCount = 0
func Call() error {
if failedCount > 10 {
time.Sleep(5 * time.Second)
failedCount = 0
}
err := doRequest()
if err != nil {
failedCount++
return err
}
return nil
}
这玩意儿有几个致命问题:第一,没有状态机概念,不知道自己在哪个态,明明应该是Open却在Sleep结束后直接放行;第二,用Sleep代替真实的计时器,进程重启后状态丢失;第三,没有滑动窗口,抖动严重;第四,没有降级策略,熔断后的请求仍然报错。
如果你正在用这种"伪熔断",我建议你直接上Sentinel(阿里的,Hystrix的替代品)、或者Gobreaker、sony/gobreaker这些成熟的库。别自己造轮子了,除非你想重新踩一遍前人踩过的坑。
写在最后
熔断器不是银弹,它解决的是"下游故障时保护上游"的问题,而不是"下游不故障"的问题。
但它解决的是最要命的那类问题——级联崩溃。一个设计良好的熔断器,能让你的服务在下游故障时仍然保持可用(降级),而不是和下游手拉手一起沉船。
分布式系统的稳定性,不是靠一个熔断器就能解决的,它需要熔断器、限流、重试、超时、隔离等多重手段配合。但熔断器是其中最关键的一环——它是最后的兜底,是防止雪崩的堤坝。
下次半夜三点被叫醒的时候,希望你已经加好了熔断器。