一次查询为什么可能变成三次调用

商品查询依赖一个偶尔失败的服务,外层增加重试后,调用方可能看到成功,服务端却执行了三次。再加熔断器,如果它观察每次尝试,第一次失败就可能阻止后续尝试;如果它只观察整个重试结果,最终成功又可能让它记录零失败。同样的组件和参数,装饰顺序已经改变了实际负载与统计含义。

Resilience4j 将 Retry、CircuitBreaker、RateLimiter、Bulkhead、TimeLimiter 分成独立模块,并用函数装饰器组合。组合能力本身不提供唯一正确的执行顺序。评审应先写出调用嵌套和每层观察对象,再决定配置,而不是列出启用了哪些开关。官方组合讨论

本章固定 Resilience4j 2.3.0,源码提交 c2c6575114fc0650177fb21e1ff967f14acde39c。它进入系列的 modern 工程,编译目标 Java 17,实测 JDK 21;不放入 Java 8 实验矩阵。实验是本机可控失败函数与真实线程任务,没有连接外部服务,也不把计数器实验声称为真实网络取消验证。固定版本工程

为每层定义观察单位

逻辑调用是业务发起的一次商品查询,物理调用是某次真正进入被保护函数的执行。重试可以令一个逻辑调用包含多个物理调用。熔断器可以包住其中任一层,因此“失败率”只有连同观察位置才有意义。

速率许可则是时间窗口中的准入次数;并发槽是尚未释放的在途数量。超时表示等待某个结果已经超过限制,不证明底层操作没有发生,也不证明它已经停止。这五种概念互相关联,但不能互相替代。

组件 本章测量对象 不能据此推断
Retry 包装函数的再次执行次数 请求自动幂等
CircuitBreaker 所处层看到的成功失败 下游整体健康真值
RateLimiter 本实例周期内许可 分布式全局额度
Bulkhead 本实例并发占用 每秒请求数
TimeLimiter 本地等待Future的期限 远端副作用被撤销

实验把默认复杂参数缩小成能手算的模型:重试最多三次,熔断窗口大小为一、最少调用一、失败阈值百分之五十。这个配置使一次失败即可打开熔断器,用于暴露顺序差异,不是生产建议。生产阈值必须根据流量和错误分布另行确定。

Retry 在外层时,每次尝试都经过熔断器

受保护函数使用 AtomicInteger 统计实际执行,前两次抛 IllegalStateException,第三次返回 ok。先组合 Retry(CircuitBreaker(call))。第一次物理调用失败,内层熔断器记录失败并进入 OPEN;外层 Retry 再次调用包装函数时,熔断器拒绝准入,真实函数不再执行。

测试结果是物理调用一次、熔断器 OPEN,最终抛 CallNotPermittedException。Retry 的尝试次数不等于物理调用次数:后续尝试可能只经过拒绝分支。这种区别在估算后端负载和解释监控时很重要。

固定 CircuitBreaker.decorateSupplier 先 acquirePermission,然后执行 supplier,成功或异常再上报统计。Retry.decorateSupplier 则在循环中调用被包装 supplier,根据结果或异常更新重试上下文。因此,谁在外层决定了被重复执行的代码范围。CircuitBreaker 装饰器源码、Retry 源码

实际项目通常还要决定是否重试拒绝异常。已经处于 OPEN 时反复立即重试,不能让下游恢复,可能只是增加本地处理和日志。本文保留默认可重试异常行为以清楚展示物理调用被截断的事实;生产代码应按错误类别配置重试条件与退避。

熔断器在外层时,只看到重试的最终结果

再创建独立计数器和熔断器,组合 CircuitBreaker(Retry(call))。外层只准入一次,内部 Retry 调用真实函数三次,第三次返回 ok。外层收到最终成功,记录一次成功、零次失败,状态保持 CLOSED。

两次内部失败仍真实发生,只是没有单独进入外层熔断统计。这个顺序可以表达“整个逻辑操作最终是否成功”,但会隐藏重试放大的下游失败与负载。监控应同时保留逻辑调用、物理尝试、拒绝和最终结果,避免只看外层成功率。

1
2
Retry(Breaker(call)):失败 -> OPEN -> 拒绝 -> 拒绝,物理调用1次
Breaker(Retry(call)):准入 -> 失败 -> 失败 -> 成功,物理调用3次

下面的完整示例只保留后一个组合,便于独立编译。测试附件另有前一个组合及精确计数断言。零等待和单条统计窗口均为实验配置,不能据此决定线上重试节奏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;

public final class RetryOrder {
public static void main(String[] args) {
AtomicInteger calls = new AtomicInteger();
Supplier<String> backend = () -> {
if (calls.incrementAndGet() < 3) { throw new IllegalStateException("temporary"); }
return "ok";
};
Retry retry = Retry.of("demo", RetryConfig.custom().maxAttempts(3)
.waitDuration(Duration.ZERO).build());
CircuitBreaker breaker = CircuitBreaker.of("demo", CircuitBreakerConfig.custom()
.slidingWindowSize(1).minimumNumberOfCalls(1).build());
String result = CircuitBreaker.decorateSupplier(breaker,
Retry.decorateSupplier(retry, backend)).get();
if (calls.get() != 3) { throw new AssertionError(); }
System.out.println(result + ", physicalCalls=" + calls.get());
}
}

许可消耗与并发占用分开验证

RateLimiter 实验每周期允许两个许可,刷新周期设为一小时,等待超时为零。在当前实验周期内,连续三次 acquirePermission 得到 true、true、false,剩余许可为零。长周期只是为了让短测试不依赖频繁刷新,不代表业务应采用一小时的瞬时批量额度。

这里选择的是 Resilience4j 的周期许可模型,不能直接套用 Guava RateLimiter 的平滑债务手算。两者都可以参与本地速率控制,但具体突发、等待和刷新规则不同。第 17 篇的许可债务属于固定 Guava 实现,比较时必须明确对象和算法。

Bulkhead 实验则设置最大并发为二、等待为零。两个真实工作线程通过装饰器进入任务,把 active 计数增加后等待 latch;测试线程确认两个任务都已进入,再执行第三次调用,得到 BulkheadFullException。释放 latch 后,两个任务完成,availableConcurrentCalls 回到二。

这个测试观察了进入、占用、拒绝与释放四个阶段,峰值 active 为二。仅断言第三次失败而不检查释放,会漏掉资源槽泄漏。异常或取消路径也应检查 finally 是否归还槽;本例通过装饰器正常收尾验证释放,其他调用类型需要对应测试。Bulkhead 源码

两种准入组合时,还需要确定被拒绝的调用是否已经消耗另一种许可。先拿速率许可再进入隔离器,隔离器拒绝时可能已经消耗本次速率预算;先占并发槽再等待速率,会在等待阶段占着槽。本文分别验证两种计数,没有把所有装饰排列都写成已测行为。

HALF_OPEN 是受限探测,不是全部恢复

熔断器第一次记录失败后进入 OPEN,tryAcquirePermission 返回 false。测试显式调用 transitionToHalfOpenState,配置只允许一个半开探测,第一次申请成功,第二次失败。探测上报成功后状态转为 CLOSED;另一次受控半开探测上报失败后回到 OPEN。

显式转换使测试不依赖真实等待期间的调度,也不伪装成验证了自动等待一小时后的状态迁移。它验证的是半开状态的许可限制与结果转换。自动时间驱动和并发探测完成顺序属于另外的测试范围,本文未执行。

生产监控应区分拒绝请求与真正进入下游的探测。OPEN 阶段没有物理请求并不能证明下游仍在失败;HALF_OPEN 的有限结果也不是整个业务范围的健康证明。探测样本、异常分类与请求分布都会影响状态,配置需要与实际调用特征一致。状态机源码

超时之后,副作用可能已经发生

超时实验使用两个真实线程执行 CompletableFuture supplier。每次 supplier 先把 sideEffects 增加一,再通知“已经开始”,最后等待测试释放响应 latch。调用方只有确认副作用已发生,才把 Future 交给 TimeLimiter 等待,因此不存在任务尚未启动的含混情况。

TimeLimiter 等待期限为二十五毫秒,cancelRunningFuture 为 true。外层 Retry 最多尝试两次,只重试 TimeoutException。两次都在响应 latch 未释放时超时,最终调用抛 TimeoutException;此时 sideEffects 已经为二,而两个 supplier 都尚未结束。

随后测试释放响应 latch,两个任务继续结束,中断计数为零。这个结果来自具体的 CompletableFuture 取消语义:cancel(true) 改变结果状态,不用中断控制 supplier。它不能推广为所有 Future 都不响应中断,也不能推出某个真实 HTTP 客户端一定如何取消。TimeLimiter 文档、JDK CompletableFuture.cancel

固定 TimeLimiterImpl 的 Future 路径先取得 Future,再调用带 timeout 的 get;捕获 TimeoutException 后按配置调用 cancel(true),然后抛出超时。它没有远端事务或副作用撤销协议,futureSupplier.get 本身的执行也发生在这次 get 计时之前。本实验用 start latch 控制这一阶段,不将二十五毫秒解释为包括提交和启动在内的完整请求期限。TimeLimiterImpl

把本机副作用计数对应到远程接口,可以推导出需要检查的风险:服务端已经写入成功,响应迟到,客户端因超时再次发送。避免重复写入需要幂等键、服务端去重或业务状态核对,不能只依赖本地取消 Future。这里是由实验得到的边界推论,远程服务的实际去重行为仍需单独验证。

从参数表回到调用预算

若每次尝试都独立享有二十五毫秒等待,最多两次尝试并不意味着整个逻辑调用上限仍是二十五毫秒;还要加上提交、重试间隔和其他包装开销。若把整个重试放在统一期限内,又要确保内部剩余预算能够传递,不能让已经失去调用方的尝试继续无界运行。

同样,重试数量三表示包括初次尝试在内的上限,不是初次调用外再增加三次。手算本章第一组顺序时,先列出每层是否允许进入真实函数,再累加实际调用数,比只计算 maxAttempts 更可靠。

实例范围也要明确。两个应用实例各持有一份 RateLimiter 或 Bulkhead,配额与并发计数分别维护,不会自动合并为全局限制。熔断状态也与实例记录的样本相关。需要共享配额或全局治理时,必须引入相应协调机制,不能把本机注册表理解成分布式控制面。

复现与迁移方式

四项完整测试在 JDK 21 运行通过,运行说明使用 modern 工程并列出五个模块。原始输出保留物理调用数、状态、许可和并发计数、重复副作用与中断计数;日志中没有 SLF4J 实现绑定的提示不影响断言,但也说明本次未验收生产日志管道。

本章没有远程服务、随机故障率或巨大资源分配。所有任务由 latch 释放,线程池在 finally 中关闭并等待终止,超时用于观察结果边界,五秒保护仅用于防止测试挂起。它们不是服务性能数据或生产参数建议。

改动练习:为每次逻辑调用传入固定幂等键,在本机副作用函数中用精确集合记录已经执行的键,再重复超时实验。分别记录尝试次数与实际写入次数,验证重试仍发生而副作用只发生一次。随后才能在真实服务上验证相同键协议。

可迁移做法 适用场景
用嵌套表达式与物理调用计数验收顺序 重试、熔断和限流组合
将结果超时与副作用完成分开观察 写接口重试、取消、幂等设计
分别验证许可占用与释放 并发隔离、排队、资源关闭

增加治理组件之前,先确定每层控制哪一类调用、失败如何分类、超时以后由谁收尾。组合后的行为应由小而可重复的失败实验验证,再根据真实负载调整参数。仅仅成功创建五个组件,无法说明它们形成了符合业务要求的调用策略。