Skip to content

下游挂了一分钟,熔断器始终没有打开 ​

一个后台接口调用的下游服务宕机了。接口上配了熔断器,本该在几次失败之后快速失败,实际上每个请求仍然等到超时才返回,线程被占满。事后看监控,熔断器在整个故障期间一直是关闭状态。配置用的是默认值。

熔断器不是「失败几次就断开」的开关。它按一个统计窗口里的失败比例做决定,窗口多大、攒够多少次才开始算、什么算失败、打开之后怎么恢复,每一项都有默认值。这些默认值适合调用量大、失败就意味着下游故障的接口;换一种接口,同一份配置要么不动作,要么误动作。

本文用 Resilience4j 2.4.0 的 CircuitBreaker 把默认配置下的行为逐项跑出来。实验见文末。

CLOSED正常放行,统计结果OPEN直接拒绝,不调用下游HALF_OPEN放行 10 个试探调用失败过半60 秒后10 个试探里失败不到一半失败达到一半在这个状态下在这个状态下在这个状态下连续失败 99 次:不打开先成功 100 次,再连续失败49 次:不打开30 秒才成功返回:不算慢业务异常:算作失败调用 50 次,下游收到 0 次两个接口共用一个熔断器:健康的那个也被拒绝等待时间到了、没有新调用:状态仍是 OPEN同时来 15 个:放行 10,拒绝 510 个试探调用一直不返回:停在半开,其余全部拒绝默认值是按「调用量大、失败就是下游故障」的接口定的。调用量小、失败含业务错误、慢而不报错的接口,都要改最少调用数、计入失败的异常类型和慢调用阈值
图 1 · 实测(Resilience4j 2.4.0 默认配置):熔断器按最近 100 次调用的失败率决定是否打开。窗口没攒够 100 次之前不计算失败率,所以流量小的接口全部失败也不会打开;打开 60 秒后不会自己变成半开,要等下一次调用到来;半开时只放行 10 个试探调用,这 10 个不返回,状态就停在半开。

一、默认配置是什么 ​

text
滑动窗口 COUNT_BASED,大小 100,最少调用数 100
失败率阈值 50.0%,慢调用阈值 60 秒,慢调用比例阈值 100.0%
打开后等待 60 秒,到时自动转半开 = false
半开时放行 10 次,半开状态最长停留 0 秒(0 表示不限)

三个状态:关闭(正常放行并统计)、打开(直接拒绝)、半开(放少量调用过去试探)。下面每一节对应其中一项默认值。

二、窗口没攒够,全部失败也不打开 ​

text
默认配置,连续失败 99 次:CLOSED(窗口内 99 次,失败 99,慢 0,失败率 -1.0)
第 100 次失败:OPEN(窗口内 100 次,失败 100,慢 0,失败率 100.0)

失败率是 -1,表示还没有开始计算。最少调用数是 100:窗口里不足 100 次调用时,熔断器不做任何判断。

开头那个接口就是这种情况。一个每秒调用一两次的后台接口,下游完全不可用时要过一两分钟才攒够 100 次失败,这期间每一次调用都要等到超时。调用量更小的接口,可能直到下游恢复都没有攒够。

最少调用数要按接口的调用量来定:它决定了「下游挂掉之后,最多要失败多少次才开始保护」。调用量小的接口设成 10 或 20,同时改用基于时间的窗口(最近若干秒),避免很久以前的调用结果留在窗口里。

窗口里已经有成功记录时,情况类似:

text
先成功 100 次,随后连续失败 49 次:CLOSED(窗口内 100 次,失败 49,慢 0,失败率 49.0)
第 50 次连续失败:OPEN(窗口内 100 次,失败 50,慢 0,失败率 50.0)

从一切正常到打开,需要连续失败 50 次,把窗口里的成功记录挤掉一半。

三、打开之后 ​

text
打开期间调用 50 次:下游实际被调用 0 次,50 次抛 CallNotPermittedException

打开状态下调用不会到达下游,调用方立刻拿到一个异常。这个异常需要专门处理:返回降级结果、读缓存,或者明确告诉用户稍后再试。把它当成普通的调用失败去重试,是在对一个确定会被拒绝的调用重试。

四、从打开到恢复 ​

等待时间到了,状态不会自己变。

text
等待时间(200 ms)过去 400 ms 后、没有新调用时的状态:OPEN

默认不自动转到半开,而是等下一次调用到来时才转。这本身没有问题,但监控面板上看到的状态会一直是打开,直到有流量进来。需要状态及时反映在监控上时,打开 automaticTransitionFromOpenToHalfOpenEnabled。

半开时只放行 10 个。

text
此时同时来 15 个调用(都还没返回):放行 10 个,拒绝 5 个,状态 HALF_OPEN

恢复不是瞬间放开全部流量,而是先让 10 个调用过去,看结果:

text
10 个试探调用里 5 个失败:OPEN
再次半开,10 个试探调用里 4 个失败:CLOSED

这 10 个调用不返回,就一直停在半开。

text
这 10 个调用一直不返回,再过 400 ms:状态 HALF_OPEN,新的调用被放行 = false

半开状态要等 10 个试探调用都有了结果才做决定,默认没有时间上限。下游处于「接受连接但不响应」的状态时,这 10 个调用挂多久,熔断器就在半开状态停多久,期间其他调用全部被拒绝。两件事要配套:调用本身必须有超时(超时会被记为失败,让状态机走下去),以及设置 maxWaitDurationInHalfOpenState 作为兜底。超时怎么设,见一次 HTTP 调用超时了,对方到底执行了没有。

五、什么算失败 ​

text
默认配置,100 次调用里 60 次抛业务异常(余额不足),下游本身正常:OPEN(窗口内 100 次,失败 60,慢 0,失败率 60.0)

默认情况下,任何异常都记为失败。「余额不足」「参数不合法」「资源不存在」这类业务结果如果以异常的形式抛出来,会被算进失败率。一批用户连续触发了余额不足,熔断器就把一个完全健康的下游断开了,其他用户的正常请求也被拒绝。

text
ignoreExceptions(BusinessException):CLOSED(窗口内 40 次,失败 0,慢 0,失败率 -1.0)
recordExceptions(IOException):CLOSED(窗口内 100 次,失败 0,慢 0,失败率 0.0)

两种配法的含义不同:ignoreExceptions 让这些调用完全不进入统计(窗口里只有 40 次);recordExceptions 只把列出的异常记为失败,其余记为成功。HTTP 调用同理:4xx 是调用方的问题,不应当计入;5xx、连接失败、超时才说明下游有故障。

六、慢而不报错 ​

text
默认配置,100 次调用每次 30 秒才成功返回:CLOSED(窗口内 100 次,失败 0,慢 0,失败率 0.0)

慢调用的默认阈值是 60 秒。一个调用 30 秒才返回,只要最后成功了,熔断器认为它既不失败也不慢。下游变慢而不报错,是比直接宕机更常见、也更容易拖垮调用方的故障,默认配置对它没有反应。

text
慢调用阈值 2 秒、比例阈值 50%:OPEN(窗口内 100 次,失败 0,慢 100,失败率 0.0)

慢调用阈值要设成比正常耗时高、比调用超时低的值。更直接的保护是调用超时本身:超时设成 2 秒,30 秒的调用会在 2 秒时被打断并记为失败。

七、一个熔断器管多大的范围 ​

两个接口共用一个。

text
导出接口全部失败、查询接口全部成功,各 50 次,共用一个熔断器:OPEN(窗口内 100 次,失败 50,慢 0,失败率 50.0);查询接口的下一次调用被放行 = false
各用各的熔断器,各 100 次:导出 OPEN,查询 CLOSED

按下游服务名建一个熔断器,是最省事的做法。同一个服务里有一个慢的导出接口和一个快的查询接口时,导出接口的失败会把查询接口一起断开。熔断器的粒度应当与「会一起出故障的东西」一致:通常是下游的一个接口或一组同类接口。

十个实例里坏了一个。

text
按服务名建的熔断器,10 个实例里 1 个全部失败:CLOSED(窗口内 100 次,失败 10,慢 0,失败率 10.0)

失败率 10%,到不了阈值。调用方一侧的熔断器看到的是整个服务的平均失败率,对单个坏实例无能为力,10% 的请求会一直失败。这是负载均衡层的事:把连续失败的实例摘掉,见十个实例里一个变慢,十分之一的请求都超时了。

八、检查清单 ​

  • 最少调用数与窗口大小按接口的调用量定,能回答「下游挂掉后要失败多少次才开始保护」。
  • 业务异常与 4xx 不计入失败。
  • 调用有超时;慢调用阈值低于超时、高于正常耗时。
  • 半开状态有最长停留时间。
  • 熔断器按接口或接口组划分,不是按服务。
  • CallNotPermittedException 有明确的降级处理,不参与重试。
  • 熔断器的状态变化有指标和告警。它打开说明下游有问题,它该打开而没打开同样是问题。
  • 用故障注入验证过:让下游返回错误、变慢、不响应,各看一次熔断器是否按预期动作。

以上是 Resilience4j 核心模块、以编程方式配置时的默认值。通过框架的 starter 使用时,默认值可能被框架改过,以实际生效的配置为准;其他熔断器实现的状态机与默认值各不相同。重试与熔断叠加时的放大问题,见每层都重试三次,最底下的服务收到二十七倍请求。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权