跳到正文

熔断器模式:状态、阈值、重试与降级

熔断器如何阻止级联故障:关闭、打开和半开三种状态,合理的阈值,与超时和重试的配合,以及好的降级方案。

· 创始人兼开发者

发布于

一张包含三个状态的状态图。当最近 100 次调用的失败率达到 50% 时,Closed 转为 Open。Open 等待 60 秒后转为 Half-open。试探调用成功时 Half-open 回到 Closed,只要有一次失败就回到 Open。下方是一条调用路径。

一家支付服务商的响应时间从 50 毫秒变成了 10 秒。你的结账服务仍在不停地调用它,每个请求在等待期间都占着一个工作线程,不到一分钟,结账服务就没有空闲的工作线程了,连根本用不到支付服务商的请求也无法处理。接着,调用结账服务的那些服务也开始超时。一个缓慢的依赖,演变成了波及多个团队的故障。

熔断器打断这条链的方式是:在一段时间内拒绝调用明显正在失败的依赖,转而立即给出响应。本文借助一张状态图讲解三种状态,介绍决定熔断器何时跳闸的阈值、它如何与超时、重试和舱壁隔离配合、什么样的降级方案才算好,以及熔断器该放在哪里:放在代码中还是服务网格里。

故障为什么会级联

这笔账简单而无情。一个有 200 个工作线程的服务,调用一个 50 毫秒就能响应的依赖时,这条路径每秒大约能处理 4,000 个请求。如果依赖慢到触发 10 秒超时,同样的 200 个线程每秒只能处理 20 个请求。你的服务本身什么都没坏,只是它的处理能力被等待耗尽了。

重试会让情况更糟。如果三层服务各自重试三次,一个用户请求就可能变成对最底层那个苦苦支撑的依赖发起的 27 次调用,而且恰好发生在它最承受不住的时候。

Michael Nygard 在 2007 年出版的一本讲述如何构建面向生产环境软件的书中描述了熔断器,Martin Fowler 的介绍则使它成为服务设计的标准组成部分:包装对某个依赖的调用,统计失败次数,一旦超过阈值,就暂停调用一段时间。

三种状态

状态 调用会怎样 转换到
关闭(Closed) 调用正常通过,结果记录在滑动窗口中 当失败率或慢调用率超过阈值时转为打开
打开(Open) 调用不触及依赖,立即失败,由降级方案应答 等待一段时间后转为半开
半开(Half-open) 放行少量试探调用,其余调用仍然快速失败 试探成功则转为关闭,失败则转为打开

打开状态同时做了两件事。它保护你的服务,因为快速失败能立即释放工作线程。它也保护了依赖,让依赖在恢复期间得以从流量中喘口气,而不是面对越堆越多的重试。

之所以要有半开状态,是因为熔断器不尝试就无法知道依赖是否已经恢复。只放行少量调用,而不是全部流量,就能在不把刚刚恢复的依赖再次压垮的前提下检验它是否恢复。

什么算作失败

这一点要慎重决定,因为选错了,熔断器就会因为错误的原因而打开:

  • 要计入: 超时、连接错误、5xx 响应,以及比慢调用阈值还慢的调用。
  • 不要计入: 400 或 404 这样的 4xx 响应。它们说明的是请求有误,而不是依赖出了故障;一个行为异常的客户端不应该导致所有人都被切断。
  • 视具体情况而定: 429 Too Many Requests。它表示依赖正在自我保护,这往往正是该退避的理由。

慢调用和错误同样重要。一个能成功响应每个请求、却要花八秒钟的依赖,对交互式 API 来说就是一个出了故障的依赖。

如何选择阈值

Resilience4j 是 JVM 上广泛使用的熔断器库,它自带一组保守的默认值。很多团队从不修改这些默认值,所以值得了解:

配置项 默认值 含义
failureRateThreshold 50% 已记录的调用中有一半失败时打开
slidingWindowType / slidingWindowSize 基于计数,100 根据最近 100 次调用判断
minimumNumberOfCalls 100 记录满 100 次调用前不做判断
slowCallDurationThreshold 60,000 ms 比这更慢的调用算作慢调用
slowCallRateThreshold 100% 只有所有调用都慢时才打开
waitDurationInOpenState 60,000 ms 保持打开一分钟
permittedNumberOfCallsInHalfOpenState 10 由十次试探调用决定是否恢复

对交互式 API 来说,慢调用的默认值实际上等于关闭:没人会为一个响应等上 60 秒。合理的起点是:慢调用阈值接近你的延迟目标,慢调用率设为 50% 到 80%,打开状态的等待时间设为 10 到 30 秒。让 minimumNumberOfCalls 保持有意义:在流量很小的端点上,三次调用失败两次只是噪声,而不是故障。如果一天中流量波动很大,基于时间的窗口比基于计数的窗口表现更好。

超时、重试与舱壁隔离

熔断器是四种工具之一,它们只有配合使用才能发挥作用:

  • 超时是第一位的。 没有超时,慢调用就永远不会失败,熔断器也就没有任何可统计的东西。把每个超时设得比调用方自身的超时更短,并沿调用链传递截止时间,避免有人继续处理一个早已被放弃的请求。
  • 重试需要限制。 只重试幂等操作,按照 AWS Architecture Blog 介绍的方法使用带抖动的指数退避,只在一层而不是每一层都重试,并把重试限制在流量的一定比例之内。Envoy 的重试预算在启用后,默认是活跃请求的 20%。
  • 永远不要对已打开的熔断器重试。 Resilience4j 默认把重试放在熔断器外层,因此每次尝试都会经过熔断器;应把重试配置为遇到“call not permitted”错误就停止,而不是反复冲击一个已经做出判断的熔断器。
  • 舱壁负责隔离。 为每个依赖分配独立的连接池或并发上限,这样一个缓慢的支付服务商只能耗尽它自己的池,而不是服务里的所有工作线程。

合理的降级方案

熔断器打开时,总得有东西来应答。合适的降级方案取决于数据的用途:

功能 好的降级方案 糟糕的降级方案
推荐 展示热门商品,或隐藏该区块 在循环里反复重试推荐服务
用户头像 一张占位图 阻塞页面,直到图片服务响应
配送时间预估 一个带说明的缓存预估 一个编造的送达日期
价格或支付 清晰的错误提示和重试入口 猜一个价格,或悄悄跳过扣款

降级方案必须开销很低,而且不能依赖刚刚失败的那个东西。如果诚实的答案是“现在这个功能用不了”,就用清晰的错误快速失败;这仍然远比超时要好。

熔断器放在哪里

进程内库 服务网格或代理
示例 Resilience4j(Java)、Polly(.NET)、gobreaker(Go)、opossum(Node.js) Envoy 的熔断和异常点检测
能看到 异常、业务层面的失败、降级方案 连接、请求、响应码
降级方案 可以,写在你的代码里 不行;调用方收到的是错误
跨团队一致性 每个团队各自配置 一套策略,处处生效

Envoy 的模型值得了解,因为它和教科书上的模型不同。它的熔断器是按上游集群设置的并发上限,默认是 1,024 个连接、1,024 个待处理请求、1,024 个活跃请求和 3 个并发重试。按主机的故障检测是另一项单独的功能,即异常点检测:默认在某台主机连续返回五次 5xx 响应后将其剔除 30 秒,重复剔除时时间更长,而且被剔除的主机永远不会超过集群的 10%。服务网格架构图展示了这些代理所处的位置。

许多团队两者兼用:用网格来实现一致的上限和剔除故障主机,在应用需要真正降级方案的地方再用库。让这一模式流行起来的 Netflix Hystrix 自 2018 年起已进入维护模式;在 JVM 上,Resilience4j 是常见的替代品。

监控与测试

悄无声息地打开的熔断器,会让故障变成一团谜。把每次状态变化都作为日志事件和指标输出,熔断器保持打开时发出告警,并把熔断器状态与依赖的延迟放在同一个仪表盘上。然后进行测试:在预发布环境中向依赖注入延迟和错误,观察熔断器打开、降级方案接管,以及熔断器再次关闭。可观测性架构图介绍了指标和链路追踪方面的内容。

熔断器示意图

一张包含三个状态的状态图。当最近 100 次调用的失败率达到 50% 时,Closed 转为 Open。Open 等待 60 秒后转为 Half-open。试探调用成功时 Half-open 回到 Closed,只要有一次失败就回到 Open。下方是一条调用路径。

11 个组件 · 7 个连接

在 Archboard 中打开
图表内容
  • CLOSED
  • OPEN
  • HALF-OPEN
  • Checkout service (caller)
  • Payment provider
  • Fallback: clear error, retry later
  • failure rate ≥ 50% of the last 100 calls
  • after a 60 s wait
  • a trial call fails
  • trial calls succeed
  • charge(), 2 s timeout, when closed or half-open
  • when open: no call, fail fast
把熔断器看作一个状态机。关闭状态放行调用并统计失败;打开状态快速失败并返回降级结果;半开状态放行少量试探调用,检验依赖是否已经恢复。

这张图的读法如下:

  • 三种状态。 关闭是正常状态,打开时快速失败,半开时检验是否恢复。圆点标记的是初始状态。
  • 状态转换上标注了条件:最近 100 次调用失败率达到 50% 时熔断器打开,等待 60 秒后进入半开,试探调用的结果决定它关闭还是再次打开。
  • 下方的调用路径展示了调用方、位于熔断器后面的支付服务商,以及熔断器打开期间负责应答的降级方案。
  • Markdown 块列出了各项配置及其默认值。

把弹性模式画清楚

  • 把熔断器画成状态机。 文字描述会模糊打开和半开之间的区别,图则能让它一目了然。
  • 把数字写在状态转换上。 “失败时打开”会引发争论,“最近 100 次调用失败率达 50% 时打开”才便于评审。
  • 画出降级路径,包括用户看到的内容。
  • 把超时和重试标在调用路径图上,让评审者能把各层的重试次数相乘。

关于状态机记法的更多内容,请参阅状态图示例。微服务架构图展示了熔断器在服务之间所处的位置。

在 Archboard 中构建

  1. 使用 Google 或 GitHub 登录,并新建一张图。
  2. 添加三个 UML 节点包中的 状态 节点:Closed、Open 和 Half-open,分别涂成绿色、红色和琥珀色。
  3. 添加一个基础节点包中的小 椭圆 形状作为初始状态,并把它连接到 Closed。
  4. 把状态转换画成带标签的连线:失败阈值、等待时长、试探成功和试探失败。在每个状态旁边添加一个基础节点包中的 文本,说明调用在该状态下会怎样。
  5. 在状态机下方,添加一个基础节点包中的 方框 表示调用方,一个 C4 模型节点包中的 外部系统 表示支付服务商,再添加一个 方框 表示降级方案,并用一条虚线连线表示打开状态下的路径。
  6. 添加一个基础节点包中的 Markdown 块,写上各项配置及其默认值。

这个示例共用 8 个图表节点,在 Free 套餐的限制之内。

常见问题

熔断器的三种状态是什么? 关闭状态下调用正常通过并统计失败;打开状态下,在等待期内调用立即失败;半开状态下,由少量试探调用决定熔断器是重新关闭还是再次打开。

熔断器和重试有什么区别? 重试会重复一次失败的调用,寄希望于它这次成功。熔断器则在失败频繁时彻底停止调用。两者一起使用时,重试应当有上限,并且在熔断器打开时停止。

4xx 错误应该触发熔断器打开吗? 通常不应该。400 或 404 说明请求有误,而不是依赖出了故障。应改为统计超时、连接错误、5xx 响应和慢调用。

用了服务网格,还需要熔断器吗? 网格无需改动代码就能提供并发上限和故障主机剔除。但在需要真正降级方案的地方,比如返回缓存值或降级功能,你仍然需要进程内的熔断器。

打开状态的等待时间设多长合适? 要足够长,让苦苦支撑的依赖有时间恢复;又要足够短,让用户在依赖恢复后不会看到长时间的故障。对交互式 API 来说,十到三十秒是常见的起点。

免费开始 — 1 个席位,无需信用卡。

相关文章

← 返回博客

一分钟打开画布

使用 Google 或 GitHub 登录。单席位免费,团队加入后再升级。