一、为什么需要熔断器
想象一个典型的微服务调用链:A 调用 B,B 调用 C。某天 C 挂了。B 对 C 的每次请求都在等超时,线程池里的线程一个接一个被占用。等 B 的线程耗尽,B 也开始超时,于是 A 的线程也开始堆积。最终,一个下游的故障像多米诺骨牌一样推倒了整条链路——这就是级联故障。
这种事情不是纸上谈兵。2015 年亚马逊 DynamoDB 在美东区域发生故障,直接导致大量依赖 DynamoDB 的服务级联崩溃,连 AWS 自己的控制台都受了影响。根本原因并不复杂:下游存储不可用后,上游服务还在不停地重试和等待超时,线程池和连接池被耗尽,原本健康的节点也被拖垮。如果调用方在检测到下游持续失败后能主动停止请求,故障范围会小得多。
没有熔断器的时候,系统对故障的响应就是”硬扛”:重试、等待、超时,周而复始。每一个请求都傻傻地等完整超时时间才放弃,既浪费了调用方的资源,又给已经不堪重负的下游雪上加霜。更糟糕的是,很多系统还会加上自动重试——下游已经喘不过气了,你还在加倍发请求,这和 DDoS 攻击有什么区别?
熔断器要解决的核心问题是:当下游服务已经明显不可用时,调用方不应该继续浪费资源去等待注定失败的响应。就像电路里的保险丝一样,电流异常时自动熔断,保护整个线路。快速失败比慢慢等死好得多——至少调用方可以立刻释放线程和连接,去做别的事情。
二、现实类比
家里的保险丝。电流正常时,保险丝安静地让电流通过。一旦电流过大——比如短路了——保险丝自己熔断,立刻切断电路,防止线路过热起火。等故障排除、保险丝冷却后,你可以换一根新的重新通电。熔断器做的事情完全一样:检测到异常就跳闸,冷却一段时间后放一个试探请求进去,看看下游是不是恢复了。
三、核心思想
熔断器本质上是一个三态状态机,包裹在每次远程调用外面。三种状态分别是:
- 关闭(CLOSED):正常状态,所有请求直接通过,同时累计连续失败次数
- 打开(OPEN):跳闸状态,所有请求立即失败,不尝试调用下游
- 半开(HALF_OPEN):冷却期结束后的探测状态,允许一个请求通过以测试下游是否恢复
3.1 状态转换的细节
三种状态之间的转换看起来简单,但每个转换都有值得注意的边界情况。
从 CLOSED 到 OPEN 的触发条件,最基础的实现是”连续失败次数达到阈值”。但生产环境往往需要更精细的策略:resilience4j 支持基于”时间窗口内的错误率”来触发,比如最近 10 秒内错误率超过 50% 就跳闸。这种方式能区分”低流量下偶尔失败”和”高流量下大面积失败”——前者可能只是网络抖动,后者才是真正的下游故障。
从 OPEN 到 HALF_OPEN 的转换完全由时间驱动。冷却时间太短,下游还没恢复就放请求进去,只会再次跳闸;太长则白白损失流量。一般从几秒到几十秒起步,取决于下游的恢复速度。有些实现采用递增冷却时间——第一次 5 秒,探测失败后 10 秒,再失败 20 秒——避免在下游持续不可用时频繁探测。
3.2 HALF_OPEN 的惊群问题
HALF_OPEN 状态有一个容易忽略的问题:如果冷却期结束时,同时有 100 个请求在等待,它们会不会同时涌入下游?这就是惊群问题(thundering herd)。如果 HALF_OPEN 状态不限制放行数量,所有等待的请求会同时打到下游,刚恢复的下游可能瞬间又被压垮,导致再次跳闸——然后冷却、再探测、再压垮,形成恶性循环。
解决方法是在 HALF_OPEN 状态只放行一个请求,其余直接快速失败。这要求状态转换必须是原子的:只有一个请求能”抢到”探测资格。Go 里可以用 sync.Mutex 或 atomic.CompareAndSwap,Java 里 Hystrix 用的是 compareAndSet——先尝试把状态从 OPEN 原子地改为 HALF_OPEN,成功的请求获得探测权,其余继续快速失败。
3.3 基于延迟的触发
除了错误率,延迟也是触发跳闸的重要信号。如果下游没报错,但响应时间从 50ms 飙到 5 秒,说明它已经在苦苦支撑。resilience4j 支持基于”慢调用率”触发熔断——当超过一定比例的调用耗时超过阈值时,即使最终成功了也触发跳闸。这种机制在下游”半死不活”时特别有用,比等它彻底挂了再跳闸更聪明。
3.4 降级策略
熔断器解决的是「什么时候停」的问题,但停了之后呢?直接返回错误并不是唯一的选择。降级策略(Fallback / Graceful Degradation)解决的就是「停了之后返回什么」——用次优结果替代失败,让系统在部分能力丧失时仍能提供有价值的服务。
常见的降级策略有三种:
- 返回缓存数据:本地或 Redis 中有最近一次成功的响应,直接返回。数据可能不是最新的,但比报错好得多
- 返回默认值:没有缓存时,返回一个合理的兜底值。比如商品推荐服务挂了,返回热门榜单而非空页面
- 返回简化响应:砍掉非核心字段,只返回最关键的数据。比如评论服务挂了,文章正文照常返回,评论区显示「暂时无法加载」
降级和熔断是互补的:熔断器决定何时停止调用下游,降级策略决定停止后给调用方返回什么。一个完整的保护方案应该两者兼备。
package fallback
import "fmt"
// FallbackFunc 定义降级函数的类型type FallbackFunc func(error) (string, error)
// WithFallback 用降级函数包装可能失败的调用func WithFallback(primary FallbackFunc, fallback FallbackFunc) FallbackFunc { return func(err error) (string, error) { result, err := primary(err) if err != nil { // 主逻辑失败,执行降级 fallbackResult, fallbackErr := fallback(err) if fallbackErr != nil { // 降级也失败了,返回原始错误 return "", fmt.Errorf("主逻辑和降级均失败: %w, 降级错误: %v", err, fallbackErr) } return fallbackResult, nil } return result, nil }}
// CachedFallback 示例:优先用缓存,缓存也没有则返回默认值func CachedFallback(cache map[string]string, defaultVal string) FallbackFunc { return func(err error) (string, error) { // 尝试从缓存获取 if val, ok := cache["latest"]; ok { return val, nil } // 缓存也没有,返回默认值 return defaultVal, nil }}上面的 WithFallback 把主逻辑和降级逻辑组合在一起:主逻辑成功就直接返回,失败则自动切换到降级。CachedFallback 展示了一种常见的降级实现——先查缓存,缓存没有则返回默认值。在实际的微服务中,通常在熔断器的 Call 方法里集成降级:熔断器跳闸时不再返回错误,而是直接调用降级函数,调用方完全感知不到下游故障。
关键数据结构:
| 属性 | 值 | 说明 |
|---|---|---|
| 状态 | 3 种枚举 | CLOSED / OPEN / HALF_OPEN |
| 失败计数器 | 整数 | 记录连续失败次数 |
| 最后失败时间 | 时间戳 | 用于判断冷却期是否结束 |
| 阈值 | 整数 | 触发跳闸的连续失败次数 |
| 冷却时间 | 毫秒 | OPEN 状态持续的最短时间 |
操作复杂度:
| 操作 | 复杂度 | 说明 |
|---|---|---|
| 调用检查 | O(1) | 比较状态 + 失败计数器 |
| 状态转换 | O(1) | 原子更新状态枚举 |
四、变体与对比
| 模式 | 触发条件 | 行为 | 适用场景 |
|---|---|---|---|
| 熔断器 | 连续错误达到阈值 | 跳闸,所有请求快速失败 | 下游服务宕机 |
| 限流器 | 请求速率超过限额 | 拒绝多余请求 | 正常流量控制 |
| 指数退避重试 | 单次请求失败 | 等待后重试同一请求 | 瞬时网络抖动 |
| 超时 | 单次请求耗时过长 | 中止单个请求 | 防止单请求阻塞 |
熔断器和限流器常被混淆。限流器管的是”正常流量太多”,在请求入口处设卡;熔断器管的是”下游已经挂了”,在调用出口处拉闸。两者保护的方向不同:限流器保护的是被调用方,防止它被过多请求压垮;熔断器保护的是调用方,防止它在下游不可用时浪费资源。
熔断器和重试是互补关系。重试解决的是瞬时抖动——偶尔丢一个包,重试一次就好了。但如果下游已经持续失败,重试只会火上浇油。熔断器知道下游不可用时直接阻止重试,避免重试风暴。实际系统中,两者经常配合使用:熔断器优先级高于重试,一旦跳闸,重试逻辑根本不会执行。
五、多语言实现
5.1 Go 实现
package circuitbreaker
import ( "fmt" "sync" "time")
type State int
const ( StateClosed State = iota // 关闭状态,正常放行 StateOpen // 打开状态,快速失败 StateHalfOpen // 半开状态,允许探测)
type CircuitBreaker struct { mu sync.Mutex threshold int // 连续失败阈值 resetTimeout int64 // 冷却时间(毫秒) state State // 当前状态 failCount int // 连续失败计数 lastFailTime int64 // 最后一次失败的时间戳}
func New(threshold int, resetTimeoutMs int64) *CircuitBreaker { return &CircuitBreaker{ threshold: threshold, resetTimeout: resetTimeoutMs, state: StateClosed, }}
func (cb *CircuitBreaker) Call(fn func() error) error { cb.mu.Lock() // 冷却期结束,从 OPEN 转为 HALF_OPEN if cb.state == StateOpen && time.Now().UnixMilli()-cb.lastFailTime >= cb.resetTimeout { cb.state = StateHalfOpen } state := cb.state cb.mu.Unlock()
// OPEN 状态直接拒绝 if state == StateOpen { return fmt.Errorf("circuit breaker is OPEN") }
err := fn() cb.mu.Lock() defer cb.mu.Unlock()
if err != nil { cb.failCount++ cb.lastFailTime = time.Now().UnixMilli() if cb.failCount >= cb.threshold { cb.state = StateOpen // 连续失败达标,跳闸 } return err }
// 成功则重置 cb.failCount = 0 cb.state = StateClosed return nil}Go 实现用 sync.Mutex 保护状态变更。Call 方法先检查是否需要从 OPEN 转为 HALF_OPEN,然后根据状态决定是放行还是拒绝。HALF_OPEN 时只放一个请求进去,成功就回 CLOSED,失败就重新跳闸。
这里有一个值得讨论的设计决策:锁的粒度。Call 方法在检查状态时加锁,释放锁后才执行 fn(),执行完再加锁更新状态。这种两段锁设计是必要的——如果持锁执行 fn(),所有并发请求都会阻塞在锁上,熔断器本身就成了瓶颈。代价是存在竞态窗口:多个请求可能同时通过状态检查进入 HALF_OPEN。如果需要严格限制只放行一个探测请求,可以用 atomic.CompareAndSwap 替代互斥锁——在状态检查时用 CAS 原子地把 OPEN 改为 HALF_OPEN,只有一个请求能成功。
5.2 TypeScript 实现
type State = "CLOSED" | "OPEN" | "HALF_OPEN";
class CircuitBreaker { private state: State = "CLOSED"; private failureCount = 0; private lastFailureTime = 0;
constructor( private threshold: number, private resetTimeout: number, // 毫秒 ) {}
private checkState(): State { // 冷却期结束,转为 HALF_OPEN if ( this.state === "OPEN" && Date.now() - this.lastFailureTime >= this.resetTimeout ) { this.state = "HALF_OPEN"; } return this.state; }
async call<T>(fn: () => Promise<T>): Promise<T> { if (this.checkState() === "OPEN") { throw new Error("Circuit breaker is OPEN"); }
try { const result = await fn(); // 探测成功,恢复到 CLOSED this.failureCount = 0; this.state = "CLOSED"; return result; } catch (err) { this.failureCount++; this.lastFailureTime = Date.now(); // 连续失败达标,跳闸 if (this.failureCount >= this.threshold) { this.state = "OPEN"; } throw err; } }}TypeScript 版本用 async/await 处理异步调用,逻辑和 Go 版一致。call 方法接受一个返回 Promise 的函数,这让它天然适配前端的 fetch 或 Node.js 的 HTTP 请求。
不过 TypeScript 版本有一个 Go 版不需要担心的问题:JavaScript 单线程不存在真正的并发竞态,但 async/await 引入了协作式并发。当 fn() 在 await 时挂起,其他调用可能同时进入 call 方法,看到相同的 HALF_OPEN 状态,导致多个探测请求同时发出。如果需要严格限制,可以加一个 isHalfOpenProbe 标志位,HALF_OPEN 状态下只放行第一个请求。
六、生产验证
| 项目 | 源码位置 | 用途 |
|---|---|---|
| Netflix Hystrix | HystrixCircuitBreakerImpl | 经典熔断器实现。三态枚举在 L142,markSuccess/markNonSuccess 驱动 HALF_OPEN 转换在 L204-L224,attemptExecution 用 compareAndSet 实现 OPEN 到 HALF_OPEN 的原子转换在 L264-L289。Netflix 全部微服务架构使用 |
| Sony gobreaker | CircuitBreaker 结构体 | 含状态、代计数器、计数和互斥锁。onSuccess/onFailure(L310-L332)驱动状态转换;基于代的过期检测(L334-L380)防止对过期状态读取进行操作。Sony 生产环境使用 |
| resilience4j | CircuitBreaker 模块 | Java 熔断器库,适用于 Spring Boot / Micronaut。支持基于慢调用率和错误率的熔断触发,比简单的连续失败计数更精细 |
七、小结
何时使用:
- 微服务间调用——防止下游服务宕机时的级联故障,保护调用方的线程池和连接池。一个下游不可用可能波及整条链路,熔断器是阻断级联故障最直接的手段
- 数据库连接——在数据库过载时停止持续请求,避免连接池耗尽。数据库过载时还在不断建连接、等查询,只会雪上加霜,熔断后快速失败给它喘息的时间
- 外部 API 调用——优雅处理第三方服务中断,避免被外部故障拖垮。第三方服务的可用性你控制不了,但你可以控制自己不被它拖下水
- 任何可能暂时不可用的共享资源——用快速失败替代超时等待。核心判断标准:如果调用失败后你除了等没有别的办法,就该加熔断器
何时不用:
- 进程内调用——本地函数调用没有网络开销,直接用错误处理即可,熔断器增加多余开销。进程内调用的失败通常是逻辑错误,不是”暂时不可用”,熔断器帮不上忙
- 非幂等操作——HALF_OPEN 的探测请求可能产生重复数据,需要先加幂等键或去重机制。比如支付接口,探测请求成功扣款但调用方因超时没收到响应,重试就会重复扣款
- 单消费者系统——只有一个调用者时,简单的退避/重试比完整状态机更轻量。熔断器的价值在于保护多个调用方不被同时拖垮,只有一个调用者时退避策略就够了
- 发射后不管——如果不等待响应,就没有需要熔断的东西。比如日志写入、指标上报,发出去就不管了,不存在”等超时占用资源”的问题
八、参考资料
- Netflix Hystrix Wiki - Netflix 对熔断器机制的官方文档,含状态转换图
- Sony gobreaker - Go 语言熔断器库,代计数器设计值得学习
- resilience4j CircuitBreaker - Java 生态最活跃的熔断器实现,支持慢调用率触发
- Microsoft Circuit Breaker Pattern - Azure 架构中心对熔断器模式的系统描述
- Release It! (2nd Edition) - Michael Nygard 著,生产环境稳定性模式经典书籍
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






