超时重试不是熔断——Circuit Breaker 的三个状态你一个都没用对

📅 2026/6/23 10:23:52
超时重试不是熔断——Circuit Breaker 的三个状态你一个都没用对
超时重试不是熔断——Circuit Breaker 的三个状态你一个都没用对你的服务调用下游超时了你加了个重试。超时三次了你加了降级。然后有一天凌晨三点下游恢复了但你发现——你的服务还在降级。因为你用的不是熔断器是超时加重试。超时 重试 ≠ 熔断超时和重试解决的是单次调用失败怎么办。熔断器解决的是连续失败怎么办。这俩的区别听起来像废话但在生产环境里差别巨大超时重试的逻辑每次调用都尝试失败了等一会儿再试。不管之前失败了多少次下一秒的请求照样发出去。熔断器的逻辑连续失败到一定阈值后直接拒绝后续请求不再尝试调用。过一段时间后放一个探测请求试试成功了才恢复正常调用。区别在哪超时重试模式下下游已经崩了你的每个请求还在傻等超时时间比如 3 秒100 个并发请求就卡住了 300 秒的线程资源。线程池耗尽上游也开始超时雪崩就这么来的。熔断器模式下下游崩了第 N 个请求触发熔断后后续请求直接走降级逻辑响应时间从 3 秒降到 10 毫秒。线程池没事上游没事雪崩链断了。三个状态大多数人只用了一个Circuit Breaker 有三个状态Closed、Open、Half-Open。Closed闭合正常状态所有请求直接通过。同时统计失败率。Open断开熔断状态所有请求直接走降级不调用下游。Half-Open半开试探状态放少量请求通过试探下游是否恢复。大多数人配置了 Closed → Open 的转换条件比如失败率 50% 触发熔断),然后就没管了。Open 之后什么时候恢复大部分人配了个固定时间比如 30 秒后自动转回 Closed。问题在哪30 秒后下游还没恢复你又全量放请求进去又超时又熔断又等 30 秒又全量放进去——你的服务在熔断-恢复-熔断之间反复横跳下游永远得不到真正的恢复窗口。这就是 Half-Open 存在的原因。Open 状态持续一段时间后不是直接转回 Closed而是转入 Half-Open。Half-Open 只放 1-5 个请求试探下游如果这些请求成功了才转回 Closed如果还是失败转回 Open继续等待。Half-Open 是熔断器最关键的状态也是被忽略最多的状态。没有它熔断器就退化成了超时降级定时恢复跟手动配一个降级开关没有本质区别。状态转换的四个参数配错一个就崩熔断器的状态转换由四个参数控制failureRateThreshold失败率阈值Closed → Open 的触发条件。比如 50%意味着统计窗口内超过一半的请求失败就熔断。slidingWindowSize滑动窗口大小统计失败率的请求数量窗口。比如 100意味着看最近 100 次请求的失败率。waitDurationInOpenStateOpen 状态持续时间熔断后保持多久再转入 Half-Open。比如 30 秒。permittedNumberOfCallsInHalfOpenStateHalf-Open 允许的试探请求数放多少个请求去试探。比如 5。这四个参数最常见的配置错误滑动窗口太大。配了 1000意味着要凑够 1000 次请求才能算失败率。如果你的 QPS 只有 10得等 100 秒才能触发熔断——在这 100 秒里每个请求都在傻等超时。窗口应该跟你的 QPS 匹配QPS 低的时候用时间窗口而不是计数窗口。失败率阈值太低。配了 10%意味着 10 次请求里 1 次失败就熔断。偶发的超时、网络抖动都会误触发。阈值应该设在 50% 以上偶尔的抖动不应该触发全局熔断。Open 持续时间太短。配了 5 秒下游还在重启你就开始试探了。应该配到下游的平均恢复时间以上——数据库故障可能需要 30-60 秒恢复你的 waitDuration 也应该在这个量级。Half-Open 试探数太多。配了 50Half-Open 变成了半恢复50 个请求同时冲进去可能把刚恢复的下游又打崩。试探数应该是 1-5够判断恢复状态就行。Resilience4j vs Sentinel同一个模式两种实现Java 生态里最主流的两个熔断器实现是 Resilience4j 和 Sentinel。它们的差异不在理念在统计模型。Resilience4j用滑动窗口统计。窗口分两种 - 计数窗口Count-based看最近 N 次请求的失败率 - 时间窗口Time-based看最近 N 秒内请求的失败率计数窗口适合高 QPS时间窗口适合低 QPS。低 QPS 场景下如果用计数窗口窗口填充太慢熔断触发延迟严重——这就是前面说的100 秒才能熔断的问题。Sentinel用滑动窗口 LeapArray。LeapArray 是 Sentinel 自研的统计结构把时间切成一个个 Bucket比如每秒一个 Bucket每个 Bucket 记录该秒内的成功/失败数。统计时取最近 N 个 Bucket 聚合。这两种实现各有坑Resilience4j 的坑默认是 Count-based 窗口低 QPS 场景下必须手动换成 Time-based否则熔断触发太慢。很多人用默认配置上线低 QPS 的服务根本熔断不了。Sentinel 的坑Bucket 粒度是秒级如果你的 QPS 极低比如 1 QPS单个 Bucket 里只有 1 个请求失败率要么 0% 要么 100%统计精度不够。Sentinel 的应对方式是设一个 minRequestAmount请求量不够时不触发熔断——但这又意味着极低 QPS 的服务永远不会熔断。什么时候不该用熔断器熔断器不是万能的。有几种场景用了反而添乱读操作的重试成本很低。查询下游缓存超时了重试一次大概率就成功了。熔断器的代价是直接拒绝请求走降级降级返回的数据可能是空的或过时的。对于读操作重试比熔断更合适。下游的故障是瞬态的。网络抖动导致的偶发超时不应该触发全局熔断。如果失败率阈值设得太低瞬态故障会被误判为持续故障正常请求被无端降级。你的服务没有可用的降级逻辑。熔断后走降级降级返回什么如果降级就是抛异常那熔断的意义只剩快失败——线程池不会耗尽了但用户体验一样烂。没有有意义的降级逻辑时熔断器只是把慢失败换成了快失败治标不治本。调用频率极低。一天调 10 次的接口加熔断器是过度工程。这种场景下手动降级开关更实用——你不需要自动化统计和状态转换因为故障的影响范围本身很小。一个可运行的配置模板如果你决定用熔断器Resilience4j 的推荐起步配置yaml resilience4j.circuitbreaker: instances: orderService: failureRateThreshold: 50 slidingWindowSize: 100 slidingWindowType: COUNT_BASED waitDurationInOpenState: 30s permittedNumberOfCallsInHalfOpenState: 5 minimumNumberOfCalls: 10 slowCallRateThreshold: 80 slowCallDurationThreshold: 3s注意两个额外参数minimumNumberOfCalls: 10窗口内至少 10 次请求才开始计算失败率。防止启动阶段零星请求就触发熔断。slowCallRateThreshold slowCallDurationThreshold慢调用也算失败。不是只有异常才算失败超过 3 秒的调用也算。Sentinel 的等价配置java DegradeRule rule new DegradeRule(orderService) .setGrade(CircuitBreakerStrategy.RATIO.getGrade()) .setCount(0.5d) // 50% 失败率 .setTimeWindow(30) // Open 持续 30 秒 .setMinRequestAmount(10) .setStatIntervalMs(10000); // 统计窗口 10 秒Sentinel 的 Half-Open 试探是自动的waitDuration 结束后放一个请求试探成功就恢复失败就继续 Open。permittedNumberOfCallsInHalfOpenState 这个参数在 Sentinel 里不需要手动配。熔断器的正确使用姿势只用在写操作和高 QPS 读操作上。低 QPS 读操作用重试就行。必须配 Half-Open。没有 Half-Open 的熔断器不配叫熔断器。滑动窗口类型根据 QPS 选择高 QPS 用计数窗口低 QPS 用时间窗口。失败率阈值 ≥ 50%。低阈值在瞬态故障下误触发概率太高。必须有降级逻辑。没有降级的熔断器只是换了一种失败方式。Half-Open 试探数 ≤ 5。刚恢复的下游扛不住大批量请求。监控状态转换。熔断→恢复的频率太高说明下游有持续性问题需要根治而不是靠熔断器兜底。熔断器是一个安全装置不是性能优化手段。它的目的是在下游故障时保护上游不跟着崩给下游恢复的空间。如果你把熔断器当成让服务更稳定的手段那你大概率只是在掩盖问题而不是解决问题。如果你对设计模式这种一看就懂、一用就错的内容感兴趣我在做一个用卡皮巴拉讲设计模式的微信小程序「爪爪代码冒险记」23 个模式用漫画加答题的方式讲搜一下就能找到。