下游挂了一分钟,熔断器始终没有打开
一个后台接口调用的下游服务宕机了。接口上配了熔断器,本该在几次失败之后快速失败,实际上每个请求仍然等到超时才返回,线程被占满。事后看监控,熔断器在整个故障期间一直是关闭状态。配置用的是默认值。
熔断器不是「失败几次就断开」的开关。它按一个统计窗口里的失败比例做决定,窗口多大、攒够多少次才开始算、什么算失败、打开之后怎么恢复,每一项都有默认值。这些默认值适合调用量大、失败就意味着下游故障的接口;换一种接口,同一份配置要么不动作,要么误动作。
本文用 Resilience4j 2.4.0 的 CircuitBreaker 把默认配置下的行为逐项跑出来。实验见文末。
一、默认配置是什么
滑动窗口 COUNT_BASED,大小 100,最少调用数 100
失败率阈值 50.0%,慢调用阈值 60 秒,慢调用比例阈值 100.0%
打开后等待 60 秒,到时自动转半开 = false
半开时放行 10 次,半开状态最长停留 0 秒(0 表示不限)三个状态:关闭(正常放行并统计)、打开(直接拒绝)、半开(放少量调用过去试探)。下面每一节对应其中一项默认值。
二、窗口没攒够,全部失败也不打开
默认配置,连续失败 99 次:CLOSED(窗口内 99 次,失败 99,慢 0,失败率 -1.0)
第 100 次失败:OPEN(窗口内 100 次,失败 100,慢 0,失败率 100.0)失败率是 -1,表示还没有开始计算。最少调用数是 100:窗口里不足 100 次调用时,熔断器不做任何判断。
开头那个接口就是这种情况。一个每秒调用一两次的后台接口,下游完全不可用时要过一两分钟才攒够 100 次失败,这期间每一次调用都要等到超时。调用量更小的接口,可能直到下游恢复都没有攒够。
最少调用数要按接口的调用量来定:它决定了「下游挂掉之后,最多要失败多少次才开始保护」。调用量小的接口设成 10 或 20,同时改用基于时间的窗口(最近若干秒),避免很久以前的调用结果留在窗口里。
窗口里已经有成功记录时,情况类似:
先成功 100 次,随后连续失败 49 次:CLOSED(窗口内 100 次,失败 49,慢 0,失败率 49.0)
第 50 次连续失败:OPEN(窗口内 100 次,失败 50,慢 0,失败率 50.0)从一切正常到打开,需要连续失败 50 次,把窗口里的成功记录挤掉一半。
三、打开之后
打开期间调用 50 次:下游实际被调用 0 次,50 次抛 CallNotPermittedException打开状态下调用不会到达下游,调用方立刻拿到一个异常。这个异常需要专门处理:返回降级结果、读缓存,或者明确告诉用户稍后再试。把它当成普通的调用失败去重试,是在对一个确定会被拒绝的调用重试。
四、从打开到恢复
等待时间到了,状态不会自己变。
等待时间(200 ms)过去 400 ms 后、没有新调用时的状态:OPEN默认不自动转到半开,而是等下一次调用到来时才转。这本身没有问题,但监控面板上看到的状态会一直是打开,直到有流量进来。需要状态及时反映在监控上时,打开 automaticTransitionFromOpenToHalfOpenEnabled。
半开时只放行 10 个。
此时同时来 15 个调用(都还没返回):放行 10 个,拒绝 5 个,状态 HALF_OPEN恢复不是瞬间放开全部流量,而是先让 10 个调用过去,看结果:
10 个试探调用里 5 个失败:OPEN
再次半开,10 个试探调用里 4 个失败:CLOSED这 10 个调用不返回,就一直停在半开。
这 10 个调用一直不返回,再过 400 ms:状态 HALF_OPEN,新的调用被放行 = false半开状态要等 10 个试探调用都有了结果才做决定,默认没有时间上限。下游处于「接受连接但不响应」的状态时,这 10 个调用挂多久,熔断器就在半开状态停多久,期间其他调用全部被拒绝。两件事要配套:调用本身必须有超时(超时会被记为失败,让状态机走下去),以及设置 maxWaitDurationInHalfOpenState 作为兜底。超时怎么设,见一次 HTTP 调用超时了,对方到底执行了没有。
五、什么算失败
默认配置,100 次调用里 60 次抛业务异常(余额不足),下游本身正常:OPEN(窗口内 100 次,失败 60,慢 0,失败率 60.0)默认情况下,任何异常都记为失败。「余额不足」「参数不合法」「资源不存在」这类业务结果如果以异常的形式抛出来,会被算进失败率。一批用户连续触发了余额不足,熔断器就把一个完全健康的下游断开了,其他用户的正常请求也被拒绝。
ignoreExceptions(BusinessException):CLOSED(窗口内 40 次,失败 0,慢 0,失败率 -1.0)
recordExceptions(IOException):CLOSED(窗口内 100 次,失败 0,慢 0,失败率 0.0)两种配法的含义不同:ignoreExceptions 让这些调用完全不进入统计(窗口里只有 40 次);recordExceptions 只把列出的异常记为失败,其余记为成功。HTTP 调用同理:4xx 是调用方的问题,不应当计入;5xx、连接失败、超时才说明下游有故障。
六、慢而不报错
默认配置,100 次调用每次 30 秒才成功返回:CLOSED(窗口内 100 次,失败 0,慢 0,失败率 0.0)慢调用的默认阈值是 60 秒。一个调用 30 秒才返回,只要最后成功了,熔断器认为它既不失败也不慢。下游变慢而不报错,是比直接宕机更常见、也更容易拖垮调用方的故障,默认配置对它没有反应。
慢调用阈值 2 秒、比例阈值 50%:OPEN(窗口内 100 次,失败 0,慢 100,失败率 0.0)慢调用阈值要设成比正常耗时高、比调用超时低的值。更直接的保护是调用超时本身:超时设成 2 秒,30 秒的调用会在 2 秒时被打断并记为失败。
七、一个熔断器管多大的范围
两个接口共用一个。
导出接口全部失败、查询接口全部成功,各 50 次,共用一个熔断器:OPEN(窗口内 100 次,失败 50,慢 0,失败率 50.0);查询接口的下一次调用被放行 = false
各用各的熔断器,各 100 次:导出 OPEN,查询 CLOSED按下游服务名建一个熔断器,是最省事的做法。同一个服务里有一个慢的导出接口和一个快的查询接口时,导出接口的失败会把查询接口一起断开。熔断器的粒度应当与「会一起出故障的东西」一致:通常是下游的一个接口或一组同类接口。
十个实例里坏了一个。
按服务名建的熔断器,10 个实例里 1 个全部失败:CLOSED(窗口内 100 次,失败 10,慢 0,失败率 10.0)失败率 10%,到不了阈值。调用方一侧的熔断器看到的是整个服务的平均失败率,对单个坏实例无能为力,10% 的请求会一直失败。这是负载均衡层的事:把连续失败的实例摘掉,见十个实例里一个变慢,十分之一的请求都超时了。
八、检查清单
- 最少调用数与窗口大小按接口的调用量定,能回答「下游挂掉后要失败多少次才开始保护」。
- 业务异常与 4xx 不计入失败。
- 调用有超时;慢调用阈值低于超时、高于正常耗时。
- 半开状态有最长停留时间。
- 熔断器按接口或接口组划分,不是按服务。
CallNotPermittedException有明确的降级处理,不参与重试。- 熔断器的状态变化有指标和告警。它打开说明下游有问题,它该打开而没打开同样是问题。
- 用故障注入验证过:让下游返回错误、变慢、不响应,各看一次熔断器是否按预期动作。
以上是 Resilience4j 核心模块、以编程方式配置时的默认值。通过框架的 starter 使用时,默认值可能被框架改过,以实际生效的配置为准;其他熔断器实现的状态机与默认值各不相同。重试与熔断叠加时的放大问题,见每层都重试三次,最底下的服务收到二十七倍请求。
配套实验
- codesphere-labs/distributed/circuit-breaker-defaults:Resilience4j 2.4.0(JDK 25.0.4.1):默认配置的取值,最少调用数、打开后的拒绝、半开的放行数与卡住、哪些异常算失败、慢调用、多个接口共用、十个实例坏一个(验证记录)
参考资料