Theory · Microservice · Resilience

熔断 · 降级 · 容错

依赖不可靠是分布式系统的常态 —— 超时 · 重试 · 熔断 · 降级 · 隔离,五件套把故障关在笼子里

一句话本质

故障不可避免,目标不是"不失败",而是失败得可控:快速失败、影响面收敛、可自动恢复

核心敌人

级联失败(雪崩):慢依赖拖垮调用方线程/连接 → 调用方也变慢 → 故障沿调用链逐级放大

代表实现

熔断器:sony/gobreaker(Go);策略引擎:sentinel-golang;框架内置:go-zero/Kratos——思想同源 Hystrix

这份 deck 回答"依赖挂了怎么不跟着挂":先讲雪崩怎么发生(理解的起点),再依次展开五件套——超时、重试、熔断、降级、隔离,最后给框架实现与 QA。熔断器三态状态机是必背图;重试预算与舱壁隔离是区分背诵与理解的加分点。

Cascading Failure

雪崩是怎么发生的:一条慢依赖拖垮全链路

级联失败雪崩传播过程 支付服务变慢,订单服务的请求全部阻塞在等待支付上,goroutine 与连接被占满,订单服务自身失去响应,导致网关与前端调用方也超时堆积,故障从最底层依赖逐级向上传播放大。 T0 · 依赖变慢 支付服务 DB 慢查询,RT 从 20ms 涨到 2s。它还"活着",只是很慢。 最危险的状态:慢而不是挂 T1 · 资源被占满 订单服务每个请求都阻塞在等支付: goroutine 堆积 → 内存涨;连接池耗尽 Go 无线程池但 goroutine 栈也要内存 T2 · 调用方失能 订单服务自己也开始超时——包括 不依赖支付的健康接口也被拖垮。 局部故障 → 服务级故障 继续向上:网关重试加剧流量 → 前端用户刷新重试 → 请求量放大数倍 → 全站级联(重试风暴) 放大因子 = 重试次数 × 用户重试 × LB 健康检查探测……层层相乘 每个环节插入断路装置:超时(止损单跳)→ 重试预算(防流量放大)→ 熔断(停止无效尝试)→ 降级(有损服务)→ 隔离(限制爆炸半径) 设计目标:让故障"快速失败 + 局部化 + 可恢复",而不是完美避免失败
雪崩三阶段:T0 依赖变慢(活着但慢,最危险)、T1 调用方资源被占满(goroutine/连接池/内存)、T2 调用方自身失能(连健康接口都拖垮)。继续向上是重试与用户刷新的流量放大。反制思路在每个环节插断路装置:超时止损单跳、重试预算防放大、熔断停止无效尝试、降级有损服务、隔离限爆炸半径。金句:慢比挂更危险。

Overview

容错五件套:各管一段,组合生效

1 超时 Timeout

单跳止损:不无限等待。必须逐跳传播(deadline),且任何一跳的超时要小于上游剩余预算

2 重试 Retry

把偶发失败变成功:退避+抖动、只重试可重试错误、预算限制防放大、幂等前提

3 熔断 CircuitBreaker

停止无效尝试:失败率超阈值→快速失败→间歇探测恢复。保护调用方自己,也给依赖喘息时间

4 降级 Fallback

有损服务:返回缓存/默认值/简化逻辑。与熔断联动——熔断打开时执行什么

5 隔离 Isolation

限制爆炸半径:资源分组(舱壁模式),一个依赖拖垮只影响自己的资源池

组合拳示例(下单链路)

调用库存:200ms 超时 → 超时/连接拒绝重试 1 次(预算 10%)→ 失败率 50% 熔断 → 熔断期间走本地预扣 + 异步补偿 → 库存/支付/风控各自独立连接池隔离

依赖分级决定投入:强依赖(主链路,挂了业务不可用)→ 快速失败+兜底预案;弱依赖(旁路,如积分/通知)→ 降级开关常备,出问题直接关闭。面试先讲分级,再讲手段——"所有依赖一视同仁"是减分项。

五件套各司其职:超时单跳止损、重试换成功率(但要预算)、熔断停止无效尝试、降级定义"熔断之后给什么"、隔离限制爆炸半径。组合拳示例把五个工具放进下单链路。依赖分级(强依赖快速失败有预案、弱依赖降级开关常备)是策略前提,先分级再谈手段。

Timeout

超时设计:不只是设个数,是预算分配

三条铁律

  • 所有出站调用必须有超时:RPC、DB 查询、HTTP 客户端、锁等待——没有超时的调用是雪崩引信
  • 下游超时 < 上游超时:留出重试与传输余量。上游 500ms → 下游 DB 查询 ≤200ms——倒挂会让上游先超时放弃,下游还在空转
  • 超时必须传播:context deadline / gRPC grpc-timeout 头——入口 500ms 的预算,全链路共享并递减

预算分配实践

// 入口 SLO 500ms 的预算切分
入口网关     500ms 总预算
├─ auth 服务  50ms   (不可超)
├─ order 服务 400ms(三个兄弟项之和≤400)
│   ├─ 库存 RPC  150ms ×1重试
│   ├─ 商品 RPC  100ms(可降级)
│   └─ DB 查询   100ms
└─ 余量(序列化/调度)  50ms

关键点:重试预算要单独留(200ms×2 重试 > 上游剩余就别试了)——gRPC 的重试策略自动检查剩余 deadline。

连接级超时分层

拨号超时 < 请求超时 < 整体 deadline;连接池等待超时单独设——缺失它是"连接池耗尽拖垮服务"的直接原因。

超时后的善后

超时 ≠ 结束:下游可能已经执行成功(响应没回来)。所以超时后的重试必须配幂等(见幂等 deck);占用的 goroutine 要及时退出(context cancel 传导到 IO 操作)。

超时是预算分配不是拍脑袋数字。三条铁律:所有出站调用必有超时、下游小于上游(倒挂分析)、超时传播。预算树示例展示 500ms 怎么切分,重试预算单独留。连接级分层(拨号/请求/池等待)与超时善后(下游可能已成功、必须配幂等)是加分点。

Retry

重试设计:退避 · 抖动 · 预算 · 条件

指数退避 + 抖动(必须成对出现)

delay = base * 2^attempt   // 指数退避
delay += rand(0, delay*0.5) // 抖动
// 例: base=100ms
// 1次:100-150ms 2次:200-300ms
// 3次:400-600ms (设上限)

没有抖动的退避 = 所有客户端同一时刻重试(重试同步化,二次踩踏)。AWS 官方文档明确要求 jitter——这是被生产事故验证过的细节。

可重试判定(不是所有错误都重试)

  • 可重试:网络错误、连接拒绝、超时、503/429(带 Retry-After)——"请求大概率没被执行"
  • 不可重试:参数错误 400/InvalidArgument、业务拒绝、鉴权失败——重试一万次结果一样
  • 危险区:超时/响应丢失——请求可能已成功,重试前必须幂等
  • 重试次数上限:通常 2-3 次;叠加 deadline 剩余预算检查

重试预算(Retry Budget)——防风暴的关键

限制"重试流量占总请求比例"(10-20%):成功率暴跌时预算耗尽、重试自动停止——把重试从"放大器"变成"无成本保险"。Finagle/gRPC retryThrottling/Envoy 内置:maxTokens 1000, tokenRatio 0.1

链路放大倍数算给面试官看

A→B→C 每跳重试 3 次:C 收到的最大请求量 = 3(A重试) × 3(B重试) = 9 倍。多层叠加时重试是乘法不是加法——所以每跳重试次数要递减(入口 2 次,中间层 1 次,靠近 DB 不重试),且预算兜底。

重试五要素:指数退避(base×2^n)+ 抖动(防重试同步化)、可重试判定(网络错误可、参数错误不可、超时属危险区)、次数上限、重试预算(占总流量比例,gRPC retryThrottling)。放大倍数演示:两跳各 3 次重试 = 9 倍放大,所以逐跳递减。五要素缺一就是"裸重试"。

Circuit Breaker

熔断器:三态状态机(必背图)

熔断器三态状态机 闭合状态正常放行请求并统计失败率;失败率超过阈值进入断开状态直接快速失败;经过冷却期后进入半开状态放行少量探测请求,全部成功则回到闭合,任一失败则重回断开。 CLOSED 闭合 正常放行 + 统计失败率 (滑动窗口内) 触发条件举例: 失败率>50% 且样本≥20 OPEN 断开 快速失败(不发真实请求) 直接走降级逻辑 冷却期 cool down: 如 30s 内不试 HALF-OPEN 半开 放行少量探测请求 (如 5 个)试探恢复情况 全成功 → CLOSED 任一失败 → OPEN 失败率超阈值 冷却期到 探测全成功 → 恢复 探测失败 → 回炉 熔断对象是"调用方视角的某个依赖"(order→pay 一个熔断器,order→user 另一个);触发阈值常用"失败率 + 最小样本量"防止低流量误熔断。
三态状态机必背:CLOSED 正常放行并统计滑动窗口失败率(阈值要配最小样本量防低流量误熔断);OPEN 快速失败走降级,冷却期后进 HALF-OPEN;半开放行少量探测,全成功回闭合、任一失败回断开。两个易漏点:熔断器是按"调用方×依赖"成对存在的;熔断保护的是调用方自己的资源,同时给依赖喘息。

Fallback / Degradation

降级:有损服务的预案设计

降级的四种形态(按常用度)

  • 兜底数据:返回缓存旧值 / 默认值 / 空集合——推荐流挂了返回热门榜缓存
  • 简化逻辑:跳过非关键步骤——大促时跳过实时风控的次要校验(异步补验)
  • 拒绝部分:低优先级请求直接拒绝/排队——写接口保护读接口
  • 读降级/写降级:DB 压力大时读走缓存快照;写先落 MQ 异步化

降级开关体系(预案 ≠ 现场发挥)

  • 开关前置:降级预案在大促前写好、演练过,而不是故障现场现想
  • 开关下发走配置中心(动态生效,秒级)——与配置中心 deck 联动
  • 分级预案:P0 不降(下单主链路)→ P1 自动降(弱依赖熔断联动)→ P2 人工降(运营后台等)
  • 自动 vs 人工:熔断自动触发局部降级;全站级降级人工决策(自动降级误判的代价太高)

与熔断的联动关系

熔断解决"要不要继续打",降级解决"不打之后用户看到什么"。实现上两者常在一个组件里:gobreaker 的 Fallback / sentinel 的 blockHandler。没有降级逻辑的熔断 = 只是把报错变得更快,用户体验仍然中断。

降级恢复与观测

恢复顺序与降级顺序相反(先恢复数据一致性要求高的);降级期间要打显式指标(fallback_total)与日志,让"有损服务"可观测可统计——SRE 视角:降级率也是 SLO 的一部分。

降级四形态:兜底数据(缓存旧值/默认值)、简化逻辑(跳过次要校验)、拒绝部分(保核心拒边缘)、读写分离降级。开关体系是灵魂:预案提前写好演练过、配置中心下发秒级生效、P0-P2 分级、自动与人工的边界(局部自动、全站人工)。与熔断的关系:熔断管"要不要打",降级管"不打给什么"。降级率是 SLO 一部分。

Bulkhead

舱壁隔离:把爆炸半径关进资源池

舱壁模式(Bulkhead,源自船舱设计)

不同依赖使用独立资源池:A 依赖的连接池/信号量打满,不影响 B 依赖——把"一个慢依赖拖垮整个服务"限制为"只影响走 A 池的请求"。

隔离维度Go 实现
连接池隔离每依赖独立 HTTP/gRPC 连接池、独立 DB 连接池
并发隔离semaphore 限制单依赖并发(golang.org/x/sync/semaphore)
实例隔离核心服务与普通服务物理分离部署
请求分类核心/非核心接口标记,非核心先限

Hystrix 的两种隔离与 Go 的取舍

Hystrix 线程池隔离(强隔离,多一跳线程切换)vs 信号量隔离(轻量,同一进程)。Go 无线程池概念,goroutine 天然轻量——但goroutine 泄漏会等效放大:每阻塞一个 goroutine 就耗一份栈内存。Go 的等价物是:semaphore 并发上限 + context 超时强制退出 + 连接池上限(pool_max)。

隔离的参数怎么定

并发上限 = 目标吞吐 × 依赖 RT(Little's Law)。例:某依赖 P99 200ms、期望它最多承担 500 QPS → 上限 100 并发;超过的部分快速失败转降级。上限是保护自己,不是限制吞吐——先压测定容量再设上限。

舱壁隔离:不同依赖独立资源池,把"一个慢依赖拖垮服务"限制为"只影响走该池的请求"。Go 的等价实现:semaphore 并发上限、context 超时退出、连接池上限——并说明 goroutine 泄漏等效线程池耗尽。参数用 Little's Law 定:并发=吞吐×RT。与限流/Pod 级隔离的边界辨析见下一页。

Bulkhead · Boundary

隔离的边界:与限流、物理隔离的分工

与限流的区别

限流拒绝"多余的外部流量"(保护服务);隔离约束"自己对外占用的资源"(保护调用方的其他依赖调用)。方向相反、常常配合:进来的限流、出去的隔离。

Pod 级隔离

更粗粒度:核心链路服务与后台任务(导表/报表)分集群部署——一个批处理任务打爆网卡不至于影响在线交易。K8s 节点池 + taint/toleration 实现。

舱壁速查:进来的限流、出去的隔离;并发上限 = 吞吐 × RT(Little's Law)——先压测定容量,再设上限;Pod 级物理隔离是比连接池更粗的防线。

边界辨析页:限流防"进来的外部流量",隔离管"自己出去占用的资源"——方向相反、常常配合;Pod 级隔离把核心与后台任务分集群(节点池+taint/toleration)。速查收束 Little's Law。与上一页配套记忆:隔离是"保护自己不被自己的依赖拖垮"。

Implementations

容错框架版图与 Go 侧实现

三代表系

框架语言/定位特点
HystrixJava(Netflix,已停更)熔断隔离语义开山之作;线程池隔离;已停更,思想永存
Resilience4jJava(函数式轻量)Hystrix 推荐继任;函数式组合器,各组件可叠加
SentinelJava + Go 多语言(阿里)流控/熔断/热点/系统自适应;控制台规则下发;sentinel-golang GA

Go 原生:sony/gobreaker 熔断器

cb := gobreaker.NewCircuitBreaker(
  gobreaker.Settings{
    Name: "pay",
    MaxRequests: 5,              // half-open 探测数
    Interval:    10*time.Second, // 计数窗口
    Timeout:     30*time.Second, // open 冷却
    ReadyToTrip: func(c gobreaker.Counts) bool { // 失败率>50% 且样本≥20
      return c.Requests >= 20 && c.TotalFailures*2 > c.Requests
    },
  })
v, err := cb.Execute(func() (any, error) { return callPay(ctx, req) })

框架内置(少装组件)

go-zero 内置熔断(Google SRE 自适应算法,按成功率动态调拒绝概率);Kratos 生态接 sentinel-golang/gobreaker。趋势:熔断器按调用方部署,网络位置越近越好。

选型话术

只要熔断 → gobreaker(库级零部署);要控制台+热点/自适应 → sentinel-golang;框架统一治理 → go-zero/Kratos 内置;Mesh → 治理下沉 Sidecar(贴近调用方)。

框架版图:Hystrix(停更但语义源头)、Resilience4j(函数式继任)、Sentinel(控制台+多语言,sentinel-golang GA)。Go 侧代码示例背 gobreaker 的 Settings:MaxRequests、Interval、Timeout、ReadyToTrip(失败率+最小样本)。选型四路:gobreaker 库级、sentinel-golang 控制台、框架内置、Mesh 下沉。最后点出熔断器部署位置的原理:越贴近调用方越好。

Interview QA · 1/2

高频 QA(一):机制细节

Q1 熔断器的统计窗口怎么设计?

滑动窗口最小样本

常用滑动窗口(最近 10s)或计数窗口(最近 N 个)。关键参数:失败率阈值(40-60%)与最小样本量(≥20)——低流量 2 个失败就是 100%,无样本下限会误熔断。Sentinel 有慢调用比例/异常比例/异常数三策略。

Q2 半开状态为什么要限制探测请求数?

防雪崩式回归

依赖刚恢复时承载能力未知,全量涌入可能再次压垮——half-open 只放少量探测(如 5 个),全部成功才逐步恢复。"恢复比熔断更谨慎":熔断保护自己是硬需求,恢复可以慢。

Q3 熔断和限流的区别?

视角相反

限流站在服务端视角:拒绝多余的流量,保护自己不被打垮(主动防御);熔断站在调用方视角:发现某个依赖持续失败后主动停止调用,保护自己的资源并给依赖喘息(被动止损)。一个系统两者都要:进来的限流、出去的熔断。

Q4 重试和熔断会冲突吗?怎么共存?

顺序敏感预算共享

顺序:熔断判断在重试之外层(熔断打开时连第一次请求都不发,更不重试)。计数:重试的成功/失败要计入熔断统计,否则统计失真;重试预算与熔断失败率共享窗口。实现上 gRPC service config 的 retryPolicy + 熔断拦截器组合时,注意别把"重试 3 次都失败"算成 3 次独立错误放大失败率。

Q5 什么是自适应熔断(adaptive circuit breaking)?

SRE 算法梯度概率

go-zero 采用 Google SRE 的过载保护算法:不用固定失败率阈值,而是根据请求量与成功率计算丢弃概率——丢弃概率 = max(0, (requests - K×accepts) / (requests + 1))。请求量越高越接近 K 倍接受数时,拒绝概率越平滑上升,避免固定阈值在临界点抖动。

Q6 熔断打开期间流量去哪了?

降级承接快速失败

两条去向:① 降级逻辑承接(缓存/默认值/简化流程)——这是设计目标;② 没有降级逻辑时快速失败(错误立刻返回)——至少不再消耗调用方资源。所以"熔断必须配降级"是设计规范;只熔断不降级等于把慢错误换成快错误。

六题机制细节:滑动窗口与最小样本(防低流量误熔断)、半开限制探测数(恢复要谨慎)、熔断 vs 限流(服务端主动 vs 调用方被动)、重试与熔断共存(外层判断+计数不重复放大)、SRE 自适应丢弃概率公式、熔断期间流量去向(降级承接)。Q5 公式能背出来是显著加分项。

Interview QA · 2/2

高频 QA(二):综合设计

Q7 设计一个高可用下单链路的容错方案

依赖分级五件套组合

先分级:库存/支付=强依赖(快速失败+预案),优惠券/积分/通知=弱依赖(降级开关常备)。再上五件套:全链路 deadline 500ms 传播;库存超时 200ms 重试 1 次(预算 10%);支付失败率 50% 熔断+冷却 30s;熔断期走本地预扣+异步对账补偿;支付/库存/风控独立连接池隔离。最后接观测:熔断/降级指标+告警。

Q8 什么错误不该重试?举具体场景。

语义错误危险区

① 语义性失败:参数校验失败、余额不足、库存不够——重试结果必然相同;② 权限类:401/403;③ 非幂等写且状态未知:超时后的下单请求可能已成功,直接重试=重复下单,必须先查单或带幂等键;④ 上游 deadline 将耗尽:剩余预算不够一次完整尝试就不重试。

Q9 舱壁隔离的并发上限怎么估算?

Little's Law压测校准

理论值:并发 = 目标吞吐 × 平均 RT。例:依赖 RT 200ms、给它 500 QPS → 上限 100。实际用压测校准:找到该依赖的拐点(延迟开始上翘的并发数),上限设为拐点的 70-80%——留出缓冲。上线后按 P99 调整,并配告警观察"快速失败率"是否常触发。

Q10 全站故障演练(混沌工程)怎么设计?

受控注入假设先行

方法:假设(注入支付延迟 2s,订单服务应熔断+降级,不影响商品浏览)→ 注入(ChaosBlade/Chaos Mesh 注网络延迟/实例杀/依赖故障)→ 验证(指标:熔断打开时间、降级率、核心接口 SLO)→ 复盘改进。原则:从小爆炸半径开始、生产环境要有时段与一键终止开关。

Q11 慢调用熔断和异常熔断的区别?

RT 维度错误维度

异常熔断看错误率——抓"明确挂掉";慢调用熔断看 RT——抓"活着但在溺水"(如 RT 阈值 1s,慢调用比例超 50% 触发)。后者对"拖垮调用方资源"更有效,因为慢请求不报错误、只占资源。生产两者都要:Sentinel 三策略(慢调用比例/异常比例/异常数)覆盖全谱。

Q12 如何验证容错逻辑真的生效?

指标+演练

三层验证:① 单测:mock 依赖持续失败,断言熔断状态机迁移与降级返回;② 指标:熔断器状态(open/close 计数)、降级率、快速失败率看板;③ 演练:预发或生产注入依赖故障,验证自动恢复时间符合预期(MTTR)。没有演练过的容错代码,故障时大概率是新的故障点。

六题综合:下单链路容错设计(分级→五件套→观测的模板答案)、不可重试清单(语义错误/危险区/deadline 耗尽)、并发上限估算(Little's Law+压测拐点 70-80%)、混沌工程四步(假设→注入→验证→复盘)、慢调用 vs 异常熔断、容错验证三层(单测/指标/演练)。

Related & References

相关知识点与参考

本领域相关 deck

  • 限流 —— 与隔离/熔断并列的流量防护(服务端视角)
  • 幂等性设计 —— 重试安全的前提,超时后重试的护身符
  • 服务间通信与 RPC —— deadline 传播、重试策略的挂载点(拦截器)
  • 负载均衡 —— 重试与 LB 的一体设计(预算+失败降权)
  • 配置中心 —— 降级开关/熔断阈值的动态下发通道
  • 可观测性 —— 熔断/降级状态的指标化与告警

跨领域相关 deck

参考链接(一手来源)

收尾链接:领域内限流(服务端防御的另一面)、幂等(重试前提)、RPC(deadline/重试挂载点)、负载均衡(预算一体设计)、配置中心(开关下发)、可观测(状态指标);跨领域对照 OS 死锁与 goroutine 阻塞。参考一手来源:Nygard《Release It!》、Hystrix Wiki、SRE Workbook、gobreaker/sentinel、AWS Builders' Library 的 jitter 文章。