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 次成功才闭合,而不是“成功一次就闭合”。一次成功可能是缓存命中或恰好打到了健康实例。

闭合时还有两个细节:

  1. 清空旧窗口:不清空的话,窗口里还留着故障期的高失败率,刚闭合就又被打开。
  2. 恢复要限速:闭合瞬间全量流量涌入,对刚恢复的下游仍是一次冲击。常见做法是闭合后走慢启动(如 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 接口不允许“降级成功”:支付接口返回“支付成功”但实际没扣款,比直接失败严重得多。降级的边界是“可以不完整,不能不正确”。

降级的三个必须

  1. 可观测:响应里带标记(degraded: true / X-Degraded: recommend),并单独埋点。否则降级会变成“悄悄错三个月”。
  2. 可恢复:降级有超时或探测机制,下游恢复后自动回切;不能靠人肉改配置。
  3. 有边界:写清楚“降级时返回什么、持续多久、谁来决定”。降级到空列表和降级到缓存,业务含义完全不同。

降级要写进 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 次

三个读数,三条结论:

  1. 故障 4 秒内的下游调用从 800 次降到 103 次(8 倍),其中 99 次是检测窗口里的“学费”,真正在 open 期间打过去的只有 4 次探测(3495/4495/5495/6495)。熔断打开后,下游每 1 秒才挨一次打。
  2. P99 没有下降(两边都是 300ms)。因为 103 次失败 / 2000 个请求 ≈ 5.2%,超过了 1% 的分位线——熔断减少的是下游负载与调用方资源占用(快速失败的 796 个请求 0ms 返回,不再占 300ms 的线程),不是“让被放行的请求变快”。
  3. 故障结束后 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/客户端取消/参数错误一律不计;同时把限流放在熔断之后(先判断熔断,再判断配额),或者在指标上把两类失败分开统计。