结论
不要让两层都用 slowCall 来"比谁先触发"——这是把两个独立触发器并联在同一个故障信号上,必然打架。 正确的架构是:
- 内层(离故障源最近):用 slowCall 熔断(它最先感知 DB 变慢,fallback 最便宜)
- 外层(编排层):不用 slowCall,改用
timeout + bulkhead(限制并发 + 硬截止,不做延迟判定)
这样根本不存在"谁先触发"的问题——外层没有 slowCall 触发器,它只做两件事:① 并发满了就快速拒绝(bulkhead);② 等超过 deadline 就 cancel(timeout)。内层该熔断就熔断,外层只负责"别被拖死"。
一、为什么"两层都配 slowCall"必然打架
你压测里三种配法失败的根因是同一个:外层看到的延迟 = 内层延迟 + Δ(网络 + 序列化 + 线程调度)。
A 观测延迟 = B 的 RT + Δ_network(≈10ms) + Δ_serialize(≈5ms) = B 的 RT + 15ms
- 如果 T_A < B 的 RT + 15ms → A 先触发(保护逻辑反了)
- 如果 T_A > B 的 RT + 15ms 且 T_B < B 的 RT → B 先触发(正确,但 A 在 B half-open 期间被占线程)
- 如果 T_A 和 T_B 都 > B 的 RT → 都不触发(你第一轮压测的情况)
不存在一个 T_A 能同时满足"比 B 晚触发"和"在 B half-open 时不占线程"这两个约束——因为 B half-open 探测时,A 看到的延迟 = 探测请求的 RT(= DB RT,可能 450-600ms),这恰好落在 T_A 的阈值附近。
二、推荐架构:内层 slowCall + 外层 bulkhead/timeout
2.1 内层(服务 B):slowCall 熔断
resilience4j:
circuitbreaker:
instances:
dbAccess:
slowCallDurationThreshold: 150ms # 3 × P99_normal(50ms)
slowCallRateThreshold: 60%
slowCallVolumeThreshold: 20
slidingWindowSize: 50
slidingWindowType: COUNT_BASED
waitDurationInOpenState: 5s
permittedNumberOfCallsInHalfOpenState: 2
automaticTransitionFromOpenToHalfOpenEnabled: true
阈值推导:T_B = 3 × P99_normal = 3 × 50ms = 150ms。取 3 倍确保正常波动(P99.9 约 70-80ms)不误触发,但 DB 真正变慢(>150ms)时 50 个调用窗口内就能触发。
2.2 外层(服务 A):timeout + bulkhead
resilience4j:
timelimiter:
instances:
callServiceB:
timeoutDuration: 300ms
cancelFutures: true
bulkhead:
instances:
callServiceB:
maxConcurrentCalls: 40
maxWaitingDuration: 0ms
maxConcurrentCalls = 40 的推导:A 线程池 200,有 3 个下游,调 B 的并发上限 = 200/3 × 0.6 ≈ 40。核心原则:A 同时占在 B 上的线程数 ≤ 线程池的 1/3。
timeout = 300ms 的推导:150(B 的 slowCall threshold)+ 15(网络)+ 135(余量)= 300ms。这是"无论如何最多等这么久"的硬上限,和 B 的 slowCall 是独立的两道防线。
2.3 为什么这样就不打架了
| 场景 | B(内层)行为 | A(外层)行为 | 结果 | |------|-------------|-------------|------| | DB P99 = 450ms | slowCall 触发 → fallback 5ms | 收到 5ms 响应,不超时 | B 保护了 A ✓ | | DB P99 = 600ms + B half-open | 探测请求 600ms | 300ms timeout 触发 → cancel | A 没被拖死 ✓ | | 网络抖动 100ms | 不触发 | 165 < 300 不超时 | 正常 ✓ |
核心区别:A 不再"判断 B 慢不慢"(那是 B 的事),A 只判断"我等多久就放弃"。两层各司其职,没有"谁先触发"的竞争。
三、如果坚持两层都配 slowCall(不推荐但给公式)
充要条件:T_A > T_B + Δ + T_fallback_B = 150 + 15 + 5 = 170ms → 取 T_A = 250ms。
但即使满足这个公式,half-open 期间仍然失效:B 进入 half-open 后,探测请求的 RT = DB 真实 RT(450-600ms),A 看到 465-615ms > T_A(250ms) → A 的 slowCall 也触发。纯 slowCall 方案在 half-open 期间必然失效,外层必须配 bulkhead 兜底。
四、完整配置(可直接用)
服务 B
resilience4j:
circuitbreaker:
instances:
db:
slowCallDurationThreshold: 150ms
slowCallRateThreshold: 60%
slowCallVolumeThreshold: 20
slidingWindowSize: 50
waitDurationInOpenState: 5s
permittedNumberOfCallsInHalfOpenState: 2
automaticTransitionFromOpenToHalfOpenEnabled: true
服务 A
resilience4j:
timelimiter:
instances:
serviceB:
timeoutDuration: 300ms
cancelFutures: true
bulkhead:
instances:
serviceB:
maxConcurrentCalls: 40
maxWaitingDuration: 0ms
circuitbreaker:
instances:
serviceB:
failureRateThreshold: 50%
slidingWindowSize: 20
waitDurationInOpenState: 5s
网关层
resilience4j:
timelimiter:
instances:
serviceA:
timeoutDuration: 500ms
bulkhead:
instances:
serviceA:
maxConcurrentCalls: 200
maxWaitingDuration: 0ms
五、踩坑清单(我线上实际踩过的)
-
cancelFutures: true对 HTTP 调用不是真 cancel:对同步的RestTemplate/WebClient调用,cancel 只是断开本地等待,远端 B 的线程仍然在跑。bulkhead 是真正保护线程池的手段,timeout 只是"我不再等"。 -
permittedNumberOfCallsInHalfOpenState别设太大:设 5 意味着 5 个请求打到 B,每个占 600ms 线程 = 3s 线程占用。设 2 是最小化探测占用的底线。 -
slowCallVolumeThreshold别设太低:设 5 的话,低 QPS 时段 5 个慢调用就触发熔断,可能只是 GC 毛刺。设 20 给窗口足够的样本量。 -
监控告警要区分"slow call 触发"和"failure 触发":
resilience4j_circuitbreaker_state里 OPEN 不区分触发原因。加自定义指标记录触发时的slowCallRatevsfailureRate。 -
half-open flapping(翻转风暴)比持续 OPEN 更危险:
automaticTransition: true+permittedCalls: 2组合下,DB 恢复后若偶尔仍有 1 个慢调用(>150ms),half-open 的 2 个探测中 1 个慢 → 触发率 50% < 60% → 回 CLOSED;下一窗口又出现 1 个慢 → 又 OPEN → 又 half-open。这个循环比直接保持 OPEN 更危险,因为每次 half-open 都放请求到慢 DB 上反复占线程。我们线上一次 DB 主从切换后持续 flapping 了 4 分钟,期间 A 的 P99 从 200ms 抖到 1200ms。解法:waitDurationInOpenState设 5s 拉长探测间隔,并加告警「5 分钟内 half-open→closed 循环 ≥ 3 次」说明 DB 没有真正恢复,应保持 OPEN 并触发人工介入。