35.6 熔断与降级
一句话
熔断是“下游已经坏了,先别打”的自动开关,降级是“打不过去时返回什么”的预案;熔断省的是调用方的线程与下游的负载,降级省的是用户的体验——只做熔断会得到一个快速报错的系统,只做降级会得到一个永远在等超时的系统。
为什么超时和重试不够
下游从 10ms 变成 3s,入口 1000 QPS,连接池 200:
在途请求 = QPS × RT = 1000 × 3s = 3000 > 连接池 200
多出来的 2800 个请求在池外排队,排队时间随负载线性上涨。结果是:
| 现象 | 数字 |
|---|---|
| 真正打到下游的并发 | 200(池上限) |
| 其余请求的等待 | 秒级到分钟级,最后超时 |
| 调用方线程/协程 | 被 3s 的超时占满,连不依赖该下游的接口也开始排队 |
| 重试 | 把 3s 的超时再乘一遍(见 35.5 超时、重试与退避) |
超时的作用是“别等太久”,它不能阻止你去等:每个请求仍然要占住一个线程/连接 3 秒。熔断做的事是把“等 3 秒再失败”换成“0 毫秒就失败”,从而把调用方的资源释放出来。
六种稳定性手段,各管一段
| 手段 | 保护谁 | 触发条件 | 失效时的代价 | 主场 |
|---|---|---|---|---|
| 超时 | 调用方的时间 | 单次调用超过阈值 | 线程被长期占用 | 35.5 |
| 重试 | 单次偶发失败 | 可重试错误 | 流量放大 | 35.5 |
| 熔断 | 调用方资源 + 下游 | 失败率超阈值 | 正常请求被误拒 | 本节 |
| 降级 | 用户体验 | 熔断/超时/异常 | 返回不完整或陈旧数据 | 本节 |
| 限流 | 自己(服务端) | 到达速率超上限 | 拒绝部分请求 | 35.7 限流 |
| 隔离 | 爆炸半径 | 资源池/线程池打满 | 部分功能不可用 | 35.8 故障隔离 |
🔑 熔断和限流常被混为一谈:限流保护“我自己不被压垮”,熔断保护“我不被下游拖垮”。前者在入口,后者在出口。
三态机:closed / open / half-open
失败率 ≥ 阈值(且样本足够)
closed ────────────────────────────► open
▲ │
│ 连续 N 次探测成功 │ 冷却时间到
│ ▼
└────────────────────────────── half-open
探测失败 ──────────────► open(重新计时)
| 状态 | 请求怎么处理 | 统计 | 出口条件 |
|---|---|---|---|
| closed | 全部放行 | 正常累计成功/失败 | 窗口内失败率 ≥ 阈值且请求数 ≥ 最小样本 → open |
| open | 全部快速失败(0ms) | 不统计 | 冷却时间到 → half-open |
| half-open | 只放极少量探测 | 只统计探测结果 | 连续 N 次成功 → closed;任一失败 → open |
五个参数必须显式给定,缺一个就会出问题:
| 参数 | 作用 | 缺了会怎样 |
|---|---|---|
| 统计窗口 | 看多久的失败率(1s / 10s / 滑动桶) | 窗口太长,故障结束还在熔断;太短,噪声就打开 |
| 最小请求数 | 样本不足不判断(如 20) | 5 QPS 时 2 个失败 = 100%,正常抖动就熔断 |
| 失败率阈值 | 多少算坏(如 50%) | 太高等于没熔断,太低等于常态熔断 |
| 打开时长 | open 持续多久(如 1s / 5s / 30s) | 太短 → 反复探测、震荡;太长 → 恢复慢 |
| 半开探测数 | 连续几次成功才闭合(如 3) | 1 次就闭合,会被一次偶然成功骗回 closed |
⚠️ 最小请求数是新手最常漏的参数。低流量接口(1 QPS)在 1s 窗口里只有 1 个请求,失败 1 个就是 100% 失败率,熔断器会永久卡在 open/half-open 之间。
检测有多快:窗口 × 阈值
熔断不是“一失败就打开”,它需要窗口内的失败率爬过阈值。所以检测延迟 ≈ 窗口长度 × 阈值(流量足够大时):
200 QPS、1s 窗口、50% 阈值:
前 500ms 全成功 → 窗口内失败率 0%
再 500ms 全失败 → 窗口内 200 个请求里有 100 个失败 = 50%
→ 大约在故障开始后 500ms 才会打开
实测(代码见本节末尾)在 t=2000ms 注入故障,t=2495ms 才打开——那 495ms 里放过去的 99 个请求,就是熔断的“学费”。想把检测延迟压到 100ms 级,只能缩窗口或降阈值,代价是误判率上升。
🔑 熔断的价值不在“省掉第一次失败”,而在“不把余下的 3.5 秒都浪费掉”。故障持续越久,熔断的相对收益越大;秒级抖动反而可能不划算。
半开:一次只放一个
半开态是熔断器最容易写错的地方。三种常见实现:
| 做法 | 后果 |
|---|---|
| 半开 = 全量放行 | 第二次雪崩:下游刚喘口气又被全量打死,熔断在 open 和 half-open 之间高频震荡 |
| 半开 = 固定放 N 个(N=1~5) | 可控,推荐;N 太小恢复慢,N 太大会打疼刚恢复的下游 |
| 半开 = 按比例放(如 5%) | 流量大时仍可能过量,需要额外限流兜底 |
除了探测数量,还要处理“探测结果的判定”:应当要求连续 N 次成功才闭合,而不是“成功一次就闭合”。一次成功可能是缓存命中或恰好打到了健康实例。
闭合时还有两个细节:
- 清空旧窗口:不清空的话,窗口里还留着故障期的高失败率,刚闭合就又被打开。
- 恢复要限速:闭合瞬间全量流量涌入,对刚恢复的下游仍是一次冲击。常见做法是闭合后走慢启动(如 30s 内线性升到 100%),或者要求入口限流配合(见 35.7 限流)。
熔断粒度:一个熔断器管多大范围
| 粒度 | 例子 | 优点 | 缺点 |
|---|---|---|---|
| 按接口 | GetUser / ListOrders 各一个 | 故障隔离最精确 | 熔断器数量 = 接口数 × 下游数,内存与统计成本高 |
| 按下游服务 | 对“用户服务”一个熔断器 | 实现简单,统计样本充足 | 一个慢接口会拖累同服务的其他接口 |
| 按下游实例 | IP:port 级 | 能摘掉单台坏实例 | 与负载均衡、服务发现重复(见 35.4 故障检测) |
| 按租户/业务线 | 大客户单独一个 | 防止一个大客户拖垮全体 | 参数难配,低流量租户统计不足 |
我的判断是:按“下游服务 + 接口”两个层级各一层。服务级负责快速止血,接口级负责精细隔离;两者都开时以更严格的一层为准。不要做“全局一个熔断器”——一个非核心接口坏掉,会让全站所有下游都被判死。
降级:先定“返回什么”,再谈开关
熔断决定“不发请求”,降级决定“不发请求之后怎么办”。没有降级的熔断,只是把 500 从 3 秒后提前到 0 秒。
| 降级手段 | 返回什么 | 适合 | 风险 |
|---|---|---|---|
| 默认值 | 空列表、0、false、通用文案 | 非核心字段、推荐位、评论数 | 语义歧义:“没有数据”和“查不到”看起来一样 |
| 读缓存旧值 | 上一次成功的快照 | 商品详情、配置、榜单 | 数据陈旧,价格/库存不能这么干 |
| 功能裁剪 | 隐藏入口、关闭模块 | 积分、皮肤、签到 | 用户以为功能消失 |
| 异步化 | 202 已受理,稍后查询 | 通知、发货、报表导出 | 需要状态查询接口配套 |
| 只读降级 | 拒绝写,允许读 | 存储故障、主库切换 | 业务停摆但有信息可看 |
| 转排队 | 落到 MQ / 本地队列 | 峰值写入 | 队列积压后仍是失败,见 43.5 积压与背压 |
按业务分级,而不是按技术分级
| 级别 | 典型接口 | 故障时的策略 |
|---|---|---|
| P0 核心 | 登录、下单、支付、库存扣减 | 不降级:靠冗余、多活、容量保障;宁可慢,不能错 |
| P1 重要 | 商品详情、订单列表、物流轨迹 | 降级到缓存/默认值,响应里标记 degraded=true |
| P2 一般 | 推荐、评价、积分、排行榜 | 直接关闭,前端隐藏入口 |
| P3 后台 | 报表、导出、对账 | 排队或直接拒绝,给出预计恢复时间 |
⚠️ P0 接口不允许“降级成功”:支付接口返回“支付成功”但实际没扣款,比直接失败严重得多。降级的边界是“可以不完整,不能不正确”。
降级的三个必须
- 可观测:响应里带标记(
degraded: true/X-Degraded: recommend),并单独埋点。否则降级会变成“悄悄错三个月”。 - 可恢复:降级有超时或探测机制,下游恢复后自动回切;不能靠人肉改配置。
- 有边界:写清楚“降级时返回什么、持续多久、谁来决定”。降级到空列表和降级到缓存,业务含义完全不同。
降级要写进 SLO:降级响应算成功还是失败?建议算“可用但降级”——计入可用性,同时单列 degraded_rate;当 degraded_rate > 5% 时按故障处理,因为它意味着核心依赖已经不可用,只是还没冒烟。
可运行的熔断器
下面是一个按 100ms 桶滑动统计、半开只放一个探测的熔断器,外加一个虚拟时钟仿真:200 QPS,下游在 2s–6s 之间全挂。
package main
import (
"fmt"
"sort"
)
type State int
const (
Closed State = iota
Open
HalfOpen
)
func (s State) String() string {
return [...]string{"closed", "open", "half-open"}[s]
}
type bucket struct{ total, fail int }
// Breaker 是一个按滑动窗口统计、带半开探测的熔断器。
type Breaker struct {
buckets [10]bucket // 10 个桶 × 100ms = 1s 滑动窗口
lastRoll int64
state State
openedAt int64
halfInFlt int // 半开态的在途探测数
halfOK int // 半开态连续成功数
transitions []string
// 四个必须显式给定的参数(窗口长度由 buckets 数组决定,这里固定 1s)
minRequests int // 窗口内请求数不足时不做判断
failRate float64 // 失败率阈值
openFor int64 // 打开后多久允许探测 (ms)
halfOpenMax int // 半开态需要几次连续成功才闭合
}
func newBreaker() *Breaker {
return &Breaker{state: Closed, minRequests: 20, failRate: 0.5,
openFor: 1000, halfOpenMax: 3}
}
func (b *Breaker) roll(now int64) {
if b.lastRoll == 0 {
b.lastRoll = now / 100 * 100
return
}
for b.lastRoll+100 <= now {
b.lastRoll += 100
b.buckets[(b.lastRoll/100)%10] = bucket{} // 桶过期即清零,窗口自动滑动
}
}
func (b *Breaker) stats() (total, fail int) {
for _, bk := range b.buckets {
total += bk.total
fail += bk.fail
}
return
}
// Allow 决定这次请求要不要真的发出去。
func (b *Breaker) Allow(now int64) bool {
b.roll(now)
switch b.state {
case Open:
if now-b.openedAt >= b.openFor { // 冷却结束,放一个探测
b.state = HalfOpen
b.halfOK, b.halfInFlt = 0, 1
b.transitions = append(b.transitions, fmt.Sprintf("%5dms open→half-open", now))
return true
}
return false
case HalfOpen:
if b.halfInFlt >= 1 { // 关键:半开一次只放一个,不放全量
return false
}
b.halfInFlt++
return true
}
return true
}
// Record 上报一次真实调用的结果。ok=false 代表超时 / 5xx / 连接失败。
func (b *Breaker) Record(now int64, ok bool) {
b.roll(now)
bk := &b.buckets[(now/100)%10]
bk.total++
if !ok {
bk.fail++
}
switch b.state {
case HalfOpen:
b.halfInFlt--
if !ok {
b.trip(now)
return
}
b.halfOK++
if b.halfOK >= b.halfOpenMax {
b.state = Closed
b.buckets = [10]bucket{} // 闭合时清空旧窗口,避免历史失败立刻又打开
b.transitions = append(b.transitions, fmt.Sprintf("%5dms half-open→closed(连续 %d 次成功)", now, b.halfOpenMax))
}
case Closed:
t, f := b.stats()
if t >= b.minRequests && float64(f)/float64(t) >= b.failRate {
b.trip(now)
}
}
}
func (b *Breaker) trip(now int64) {
b.state = Open
b.openedAt = now
b.halfInFlt, b.halfOK = 0, 0
b.transitions = append(b.transitions, fmt.Sprintf("%5dms →open", now))
}
type result struct {
forwarded, rejected, downFail int
outFwd, outRej int // 故障窗口内的放行 / 拒绝数
p50, p99 int
transitions []string
}
// run 用虚拟时钟跑 10s:200 QPS,下游在 2s–6s 之间全挂。
func run(withBreaker bool) result {
br := newBreaker()
var r result
var lat []int
for t := int64(0); t < 10000; t += 5 { // 200 QPS = 每 5ms 一个
broken := t >= 2000 && t < 6000
allowed := true
if withBreaker {
allowed = br.Allow(t)
}
if !allowed {
r.rejected++
lat = append(lat, 0) // 快速失败,不占线程
if broken {
r.outRej++
}
continue
}
r.forwarded++
if broken {
r.outFwd++
r.downFail++
lat = append(lat, 300) // 超时
} else {
lat = append(lat, 8)
}
if withBreaker {
br.Record(t, !broken)
}
}
sort.Ints(lat)
r.p50, r.p99 = lat[len(lat)/2], lat[len(lat)*99/100]
r.transitions = br.transitions
return r
}
func main() {
no, yes := run(false), run(true)
fmt.Printf("%-10s %10s %10s %10s %8s %8s\n", "方案", "放行", "快速失败", "下游失败", "P50", "P99")
fmt.Printf("%-10s %10d %10d %10d %6dms %6dms\n", "无熔断", no.forwarded, no.rejected, no.downFail, no.p50, no.p99)
fmt.Printf("%-10s %10d %10d %10d %6dms %6dms\n", "有熔断", yes.forwarded, yes.rejected, yes.downFail, yes.p50, yes.p99)
fmt.Println("\n状态迁移:")
for _, s := range yes.transitions {
fmt.Println(" ", s)
}
// 只看故障窗口 (2s–6s) 内的下游调用量
fmt.Printf("\n故障 4s 内打到下游的请求:无熔断 %d 次,有熔断 %d 次(降低 %.0f 倍),快速失败 %d 次\n",
no.outFwd, yes.outFwd, float64(no.outFwd)/float64(yes.outFwd), yes.outRej)
}
输出:
方案 放行 快速失败 下游失败 P50 P99
无熔断 2000 0 800 8ms 300ms
有熔断 1204 796 103 8ms 300ms
状态迁移:
2495ms →open
3495ms open→half-open
3495ms →open
4495ms open→half-open
4495ms →open
5495ms open→half-open
5495ms →open
6495ms open→half-open
6505ms half-open→closed(连续 3 次成功)
故障 4s 内打到下游的请求:无熔断 800 次,有熔断 103 次(降低 8 倍),快速失败 697 次
三个读数,三条结论:
- 故障 4 秒内的下游调用从 800 次降到 103 次(8 倍),其中 99 次是检测窗口里的“学费”,真正在 open 期间打过去的只有 4 次探测(3495/4495/5495/6495)。熔断打开后,下游每 1 秒才挨一次打。
- P99 没有下降(两边都是 300ms)。因为 103 次失败 / 2000 个请求 ≈ 5.2%,超过了 1% 的分位线——熔断减少的是下游负载与调用方资源占用(快速失败的 796 个请求 0ms 返回,不再占 300ms 的线程),不是“让被放行的请求变快”。
- 故障结束后 505ms 才完全恢复(6000ms 恢复,6505ms 闭合)。因为 open 计时从 5495ms 起算,到 6495ms 才放探测。冷却时间决定了故障后的恢复延迟,配 30s 就意味着下游恢复后还要白等 30 秒。
🔑 想让 P99 也降下来,光有熔断不够,必须配降级:熔断期间返回缓存或默认值,把这些请求从“失败”变成“降级成功”。熔断管资源,降级管体验。
什么时候该熔断,什么时候别
| 你的情况 | 选择 |
|---|---|
| 强依赖、下游偶发超时、调用方线程宝贵 | 熔断 + 降级(缓存/默认值) |
| 弱依赖(推荐、评论数) | 直接降级到默认值,不必熔断 |
| 幂等只读、可重试 | 超时 + 重试 + 熔断三者配合 |
| 支付/扣款等写操作 | 不自动降级,熔断后返回明确失败,由业务补偿 |
| 下游是同一进程内的模块 | 不要熔断,用隔离(线程池/信号量)或直接优化 |
| 低流量接口(< 1 QPS) | 熔断器统计不足,改用固定超时 + 降级 |
跨篇相关:熔断的判定输入来自故障检测与超时,见 35.4 故障检测;熔断打开后要靠限流控制恢复期的流量(35.7 限流);缓存层故障时的降级路径,见 41.3 缓存穿透 / 击穿 / 雪崩。
反模式
| 反模式 | 后果 |
|---|---|
| 没有最小请求数 | 低流量接口 1 次失败就熔断,长期卡在 open |
| 半开态放全量流量 | 下游二次雪崩,熔断在 open/half-open 间高频震荡 |
| 打开时长 100ms | 每秒探测 10 次,等于没熔断,还把探测流量压给下游 |
| 闭合时不清空窗口 | 刚闭合就被历史失败率再次打开,永远恢复不了 |
| 熔断后直接抛 500,没有降级 | 用户看到的是错误页,业务损失和没熔断一样 |
| 降级逻辑依赖同一个故障下游 | 降级自己也超时,等于没降级(降级必须走旁路) |
| 降级返回空列表且无标记 | 前端把“故障”渲染成“没有数据”,用户重试加剧故障 |
| 把 429(限流拒绝)计入熔断失败率 | 自己限流 → 自己熔断,自激 |
| 把客户端取消、参数错误计入失败率 | 误熔断,正常流量被拒 |
| 全局一个熔断器 | 一个非核心接口坏掉,全站下游被一起判死 |
| 只熔断不隔离 | 熔断前线程池已被打满,熔断只能救后面的请求 |
| 用熔断代替容量规划 | 熔断是止血带,不是血 |
自检
Q1 你们给下游加了 3s 超时和 2 次重试。下游变慢到 3s 时,为什么整个服务(包括不调用该下游的接口)都变卡了?熔断能解决哪一部分?
▸ 答案:超时不释放资源。1000 QPS × 3s = 3000 个在途请求,连接池只有 200,其余在池外排队,线程/协程被占满;重试再放大 3 倍压力,于是所有接口(共用一个线程池/事件循环)一起排队。熔断把“等 3s 失败”变成“0ms 失败”,释放的是调用方线程与连接,并把下游负载从 1000 QPS 压到每秒几次探测。它解决不了“已经打到下游那部分请求”的延迟,那要靠降级。
Q2 200 QPS、1s 窗口、50% 阈值、最小请求数 20。t=2000ms 注入故障,熔断器什么时候打开?为什么不是 2000ms?实测 2495ms,差在哪?
▸ 答案:打开需要窗口内失败率 ≥ 50%。窗口是 1s,t=2000ms 时窗口里 200 个请求全是成功的,失败率 0%;要等失败请求占满窗口的一半,约需 500ms,即 t≈2500ms。实测 2495ms 与估算一致。差值来自“失败率是滚动计算的”:达到 50% 的确切时刻取决于桶边界与请求分布,不是精确的 500ms。想把检测延迟压到 100ms 级,只能缩短窗口或降低阈值,代价是误判率上升(一次抖动就熔断)。
Q3 你的熔断器在半开态直接放全量流量,会看到什么现象?为什么把“半开 3 次成功闭合”改成“1 次成功闭合”也不好?
▸ 答案:半开放全量 = 下游刚恢复就被全量打回去,状态在 open→half-open→open 之间高频震荡,监控上表现为“熔断器疯狂开关 + 下游错误率锯齿”,比不熔断更糟。改成 1 次成功就闭合则走向另一个极端:一次成功可能来自缓存命中、恰好打到健康实例、或下游只是间歇性恢复,闭合后立刻被后续失败再次打开,形成慢震荡。正确做法是半开只放 1–5 个探测、要求连续 N 次成功(N=3~10)、闭合后清空窗口并配合慢启动。
Q4 下游推荐服务挂了,你的降级逻辑返回空列表,接口耗时从 300ms 降到 5ms,成功率 100%。这算成功降级吗?会带来什么次生问题?
▸ 答案:不算。三个问题:① 语义错误——空列表在业务上表示“没有推荐”,用户会以为内容不存在,而不是“暂时不可用”;② 不可观测——成功率 100%、无错误日志,故障被掩盖,可能持续数天;③ 次生流量——用户刷新/重试去找内容,推荐接口的调用量反而上升,如果降级逻辑还带缓存回源,会把压力转给下游或 DB。正确做法是返回带标记的降级响应(degraded=true + 兜底内容/文案)、单独埋点 degraded_rate、并在 SLO 里明确它算“可用但降级”。
Q5 你们入口做了限流:超过 1000 QPS 返回 429。上线后发现高峰期熔断器频繁打开,但下游其实很健康。哪里错了?
▸ 答案:把 429 计入了熔断器的失败率。限流拒绝是你自己产生的,不代表下游故障;高峰期 20% 的请求被限流,熔断器看到 20% 失败率,再叠加一点正常抖动就越过阈值,于是“限流 → 误熔断 → 更多请求被拒 → 失败率更高”形成自激。修法:熔断只统计真实打出去的调用结果(超时、连接失败、5xx),429/客户端取消/参数错误一律不计;同时把限流放在熔断之后(先判断熔断,再判断配额),或者在指标上把两类失败分开统计。