mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
3679 字
10 分钟
熔断器(Circuit Breaker)
2026-06-13

一、为什么需要熔断器#

想象一个典型的微服务调用链:A 调用 B,B 调用 C。某天 C 挂了。B 对 C 的每次请求都在等超时,线程池里的线程一个接一个被占用。等 B 的线程耗尽,B 也开始超时,于是 A 的线程也开始堆积。最终,一个下游的故障像多米诺骨牌一样推倒了整条链路——这就是级联故障。

这种事情不是纸上谈兵。2015 年亚马逊 DynamoDB 在美东区域发生故障,直接导致大量依赖 DynamoDB 的服务级联崩溃,连 AWS 自己的控制台都受了影响。根本原因并不复杂:下游存储不可用后,上游服务还在不停地重试和等待超时,线程池和连接池被耗尽,原本健康的节点也被拖垮。如果调用方在检测到下游持续失败后能主动停止请求,故障范围会小得多。

没有熔断器的时候,系统对故障的响应就是”硬扛”:重试、等待、超时,周而复始。每一个请求都傻傻地等完整超时时间才放弃,既浪费了调用方的资源,又给已经不堪重负的下游雪上加霜。更糟糕的是,很多系统还会加上自动重试——下游已经喘不过气了,你还在加倍发请求,这和 DDoS 攻击有什么区别?

熔断器要解决的核心问题是:当下游服务已经明显不可用时,调用方不应该继续浪费资源去等待注定失败的响应。就像电路里的保险丝一样,电流异常时自动熔断,保护整个线路。快速失败比慢慢等死好得多——至少调用方可以立刻释放线程和连接,去做别的事情。

二、现实类比#

家里的保险丝。电流正常时,保险丝安静地让电流通过。一旦电流过大——比如短路了——保险丝自己熔断,立刻切断电路,防止线路过热起火。等故障排除、保险丝冷却后,你可以换一根新的重新通电。熔断器做的事情完全一样:检测到异常就跳闸,冷却一段时间后放一个试探请求进去,看看下游是不是恢复了。

三、核心思想#

熔断器本质上是一个三态状态机,包裹在每次远程调用外面。三种状态分别是:

  • 关闭(CLOSED):正常状态,所有请求直接通过,同时累计连续失败次数
  • 打开(OPEN):跳闸状态,所有请求立即失败,不尝试调用下游
  • 半开(HALF_OPEN):冷却期结束后的探测状态,允许一个请求通过以测试下游是否恢复
stateDiagram-v2 [*] --> CLOSED CLOSED --> OPEN : 连续失败次数 >= 阈值 OPEN --> HALF_OPEN : 冷却时间已过 HALF_OPEN --> CLOSED : 探测成功 HALF_OPEN --> 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.Mutexatomic.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 HystrixHystrixCircuitBreakerImpl经典熔断器实现。三态枚举在 L142,markSuccess/markNonSuccess 驱动 HALF_OPEN 转换在 L204-L224,attemptExecutioncompareAndSet 实现 OPEN 到 HALF_OPEN 的原子转换在 L264-L289。Netflix 全部微服务架构使用
Sony gobreakerCircuitBreaker 结构体含状态、代计数器、计数和互斥锁。onSuccess/onFailure(L310-L332)驱动状态转换;基于代的过期检测(L334-L380)防止对过期状态读取进行操作。Sony 生产环境使用
resilience4jCircuitBreaker 模块Java 熔断器库,适用于 Spring Boot / Micronaut。支持基于慢调用率和错误率的熔断触发,比简单的连续失败计数更精细

七、小结#

何时使用:

  • 微服务间调用——防止下游服务宕机时的级联故障,保护调用方的线程池和连接池。一个下游不可用可能波及整条链路,熔断器是阻断级联故障最直接的手段
  • 数据库连接——在数据库过载时停止持续请求,避免连接池耗尽。数据库过载时还在不断建连接、等查询,只会雪上加霜,熔断后快速失败给它喘息的时间
  • 外部 API 调用——优雅处理第三方服务中断,避免被外部故障拖垮。第三方服务的可用性你控制不了,但你可以控制自己不被它拖下水
  • 任何可能暂时不可用的共享资源——用快速失败替代超时等待。核心判断标准:如果调用失败后你除了等没有别的办法,就该加熔断器

何时不用:

  • 进程内调用——本地函数调用没有网络开销,直接用错误处理即可,熔断器增加多余开销。进程内调用的失败通常是逻辑错误,不是”暂时不可用”,熔断器帮不上忙
  • 非幂等操作——HALF_OPEN 的探测请求可能产生重复数据,需要先加幂等键或去重机制。比如支付接口,探测请求成功扣款但调用方因超时没收到响应,重试就会重复扣款
  • 单消费者系统——只有一个调用者时,简单的退避/重试比完整状态机更轻量。熔断器的价值在于保护多个调用方不被同时拖垮,只有一个调用者时退避策略就够了
  • 发射后不管——如果不等待响应,就没有需要熔断的东西。比如日志写入、指标上报,发出去就不管了,不存在”等超时占用资源”的问题

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

熔断器(Circuit Breaker)
https://blog.souloss.com/posts/programming/system-patterns/system-patterns-circuit-breaker/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时