系统资源吃紧的时候,熔断和限流到底谁先动手?我们翻了 Resilience4j 的源码排了个序,不然两套逻辑真会打起来
事情是这样的——上周四凌晨三点,我被报警电话炸醒。生产环境的订单服务线程池满了,CPU 飙到 92%,整个集群像多米诺骨牌一样往外崩。但诡异的是,我们的熔断器和限流器同时触发了,而且明显在打架。
先说结论:在 Resilience4j 里,如果你把熔断和限流都配在同一个入口,Bulkhead(舱壁/限流)会先于 CircuitBreaker(熔断)执行。 这个顺序写在 CircuitBreakerAspect 和 BulkheadAspect 的切面优先级里,前者默认 Ordered.LOWEST_PRECEDENCE,后者是 Ordered.LOWEST_PRECEDENCE - 1。也就是说,Bulkhead 的切面比 CircuitBreaker 多那么一丁点优先级,请求先进 Bulkhead 排队,排不进去直接甩 503,根本不会走到后面的熔断判断。
但如果你用的是函数式组合(decorateCallable 那一套),顺序就反过来了——谁先 decorate 谁先执行。这就是我们踩坑的地方。
那晚我们排查日志的时候发现,大量请求被熔断器以 CIRCUIT_OPEN 的理由拒绝,但线程池的监控显示活跃线程数早就超过了 Bulkhead 配置的 maxConcurrentCalls。按道理,Bulkhead 应该先把多余的请求拦在门外,熔断器根本不应该看到这些请求。可现实是,熔断器疯狂记录失败调用,错误率很快超过了 50% 的阈值,熔断器直接跳闸,整个服务的入口全被掐断。
当时配的代码大概是这样的:
// 这是我们原来的写法,两个 decorate 叠在一起
Supplier<String> supplier = () -> orderService.createOrder(order);
supplier = Decorators.ofSupplier(supplier)
.withCircuitBreaker(circuitBreaker)
.withBulkhead(bulkhead)
.decorate();
看到问题没?withCircuitBreaker 写在了 withBulkhead 前面。Resilience4j 的 Decorators 是按 decorate 顺序层层包裹的,最外层的先执行。这里熔断器包在最外层,所以请求先过熔断判断,再过 Bulkhead。而熔断器在判断的时候,线程池早就满了,每次调用都超时或者抛异常,熔断器把这些全算成了失败。
雪崩逻辑是这样的:线程池满 → 新请求在 Bulkhead 排队超时 → 但请求已经被熔断器包裹并记录为失败 → 失败率飙升 → 熔断器打开 → 所有请求直接短路,连排队都不排了 → 但线程池里卡住的那些请求还在,资源没释放 → 整个服务假死。
我们当时看着 Grafana 面板,熔断状态从 CLOSED 跳到 OPEN 只用了 47 秒,而 Bulkhead 的 maxWaitTime 设的是 200 毫秒。理论上 Bulkhead 应该在 200 毫秒内就把溢出的请求拒绝掉,但熔断器先一步把门锁死了。
解决这个问题只改了一行代码——把 withBulkhead 放到 withCircuitBreaker 前面:
Supplier<String> supplier = () -> orderService.createOrder(order);
supplier = Decorators.ofSupplier(supplier)
.withBulkhead(bulkhead) // Bulkhead 先包裹,先执行
.withCircuitBreaker(circuitBreaker) // 熔断器后包裹,后执行
.decorate();
改完之后,请求先过 Bulkhead:如果线程池满了,直接抛 BulkheadFullException,熔断器根本看不到这个请求。如果 Bulkhead 放行了,说明线程池有空位,这时候才进熔断器的判断逻辑。而且 BulkheadFullException 默认不会被熔断器计为失败(Resilience4j 1.7.x 之后可以通过配置 recordExceptions 来控制,但我们没配,所以它被忽略了),熔断器的失败率也就不会因为限流拒绝而虚高。
但事情没那么简单。我们用的是注解方式配合函数式组合的混合模式——Controller 层用 @Bulkhead 和 @CircuitBreaker 注解,Service 层又手动用 Decorators 包了一层。这就导致了两个切面和一个手动包裹的三层嵌套,执行顺序完全不可控。
翻了 Resilience4j 的源码(版本 1.7.1),在 BulkheadAspect 和 CircuitBreakerAspect 里,两个切面的 getOrder() 返回值确实有明确的先后关系。BulkheadAspect 实现了 Ordered 接口,返回 Ordered.LOWEST_PRECEDENCE - 1,也就是 Integer.MAX_VALUE - 1。CircuitBreakerAspect 没显式实现 Ordered,但 Spring 默认给没实现 Ordered 的切面分配 Ordered.LOWEST_PRECEDENCE,也就是 Integer.MAX_VALUE。所以在注解模式下,Bulkhead 切面确实比熔断切面先执行。
但我们的 Service 层手动 Decorators 包裹的时候,把熔断器放在了外层,相当于在 Bulkhead 切面放行之后、进入业务逻辑之前,又套了一层熔断判断。这层熔断判断是同步的、在业务线程内执行的,它记录的失败调用包含了因为下游慢导致超时的那些请求。而 Bulkhead 切面只关心线程池是否满了,它不管业务逻辑执行得怎么样。
所以真实的执行链路变成了:BulkheadAspect(限流)→ CircuitBreakerAspect(熔断切面)→ 手动 CircuitBreaker(又一层熔断)→ 业务逻辑。中间那层熔断切面和最里层的手动熔断器都在记录失败,失败率被双重计算,熔断阈值更容易被触发。
我们最后统一了策略:全局只用注解,干掉所有手动 Decorators 包裹。Controller 层配 Bulkhead,Service 层配 CircuitBreaker,让 Spring AOP 的切面优先级自然排序。同时把 Bulkhead 的 maxConcurrentCalls 从 50 调到了 80,maxWaitTime 从 200ms 调到了 500ms,给线程池留出足够的缓冲。
还有一个小细节:Bulkhead 的 fairCallHandlingEnabled 参数。默认是 false,也就是非公平模式,新请求可以插队。这在系统资源吃紧的时候会让一些老请求一直得不到执行,超时后又被熔断器记录为失败。我们把它改成了 true,让请求严格按 FIFO 排队,先来的先处理,超时的直接拒绝,不会被新请求插队搅局。
改完之后第二天压测,同样的并发量,熔断器再也没误触发过。Bulkhead 老老实实在前面挡着,熔断器只在真正有业务异常的时候才跳闸。
所以回到标题的问题——系统资源吃紧的时候,熔断和限流谁先动手?在 Resilience4j 注解模式下,限流(Bulkhead)先动手,这是切面优先级决定的。在函数式组合模式下,谁 decorate 在外层谁先动手,这取决于你怎么写代码。我们踩的坑就是把熔断器写在了外层,导致它比限流器先看到请求,然后因为线程池满导致的超时被它当成了业务失败,错误率虚高,熔断器误开,整个服务瘫痪。
常见问题
Resilience4j 注解模式下 Bulkhead 和 CircuitBreaker 的执行顺序到底是谁决定的?
是 Spring AOP 的切面优先级决定的。BulkheadAspect 的 getOrder() 返回 Ordered.LOWEST_PRECEDENCE - 1,CircuitBreakerAspect 没显式设置 order,默认是 Ordered.LOWEST_PRECEDENCE。数值越小优先级越高,所以 Bulkhead 切面先执行。如果你自己又加了 @Order 注解或者用 Decorators 手动包裹,这个顺序就可能被打乱。
为什么 BulkheadFullException 不会被熔断器计为失败?
Resilience4j 1.7.x 的 CircuitBreaker 默认只把 CallNotPermittedException(熔断器自己抛的)和某些特定异常排除在失败统计之外,但 Bulkhead 抛出的 BulkheadFullException 并不在默认忽略列表里。实际上它之所以没被计数,是因为请求根本没进到熔断器的判断逻辑——Bulkhead 在切面外层就把请求拦住了,熔断器压根没看到这个调用。如果你用函数式组合把熔断器包在 Bulkhead 外面,那 BulkheadFullException 就会被熔断器看到并计为失败,除非你显式配置 recordExceptions 排除它。
Bulkhead 的 fairCallHandlingEnabled 改成 true 会有什么性能影响?
公平模式会用一个 ArrayBlockingQueue 做排队,入队和出队都要获取锁,吞吐量大概会降 10%-15%(我们压测的结果,4核8G实例,500并发)。但换来的是请求严格按照到达顺序处理,不会出现先来的请求被后来的一直插队导致超时的情况。如果你的业务对延迟敏感,公平模式反而能减少超时率,整体吞吐量可能不降反升。