熔断器:你服务崩溃前的最后一根稻草

2026-10-14 17 0

熔断器:你服务崩溃前的最后一根稻草

凌晨三点,你被手机震醒。监控大屏一片红色,接口错误率飙到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这些成熟的库。别自己造轮子了,除非你想重新踩一遍前人踩过的坑。

写在最后

熔断器不是银弹,它解决的是"下游故障时保护上游"的问题,而不是"下游不故障"的问题。

但它解决的是最要命的那类问题——级联崩溃。一个设计良好的熔断器,能让你的服务在下游故障时仍然保持可用(降级),而不是和下游手拉手一起沉船。

分布式系统的稳定性,不是靠一个熔断器就能解决的,它需要熔断器、限流、重试、超时、隔离等多重手段配合。但熔断器是其中最关键的一环——它是最后的兜底,是防止雪崩的堤坝。

下次半夜三点被叫醒的时候,希望你已经加好了熔断器。

相关文章

RESTful API设计避坑指南:我用尸体铺出来的经验
你的API报错400,客户当场问候你全家:论Error Response设计
限流:你的服务离崩溃,只差一个双十一
写了5年代码,我依然被这10个API设计问题坑了无数次
你的微服务调试三板斧:加日志、加日志、还是加日志?——是时候了解一下分布式追踪了
为什么你的服务总是莫名其妙挂掉?TCP Keep-Alive这个隐形杀手你必须了解

发布评论