Spring Boot 4 底下的 Spring Framework 7 自带了 @Retryable 和 @ConcurrencyLimit,不引 spring-retry 也能重试和限并发。真正用起来最容易踩的是三件事:不加 @EnableResilientMethods 注解直接不生效,maxRetries = 2 实际会调 3 次,自调用照样绕过代理。下面每一条都是我在 Spring Boot 4.1.1 上跑出来的结果。
环境:Spring Boot 4.1.1,依赖解析出来的 Spring Framework 是 7.0.9,分别用 JDK 21 和 JDK 25 跑了一遍,结论一致。项目只引了 spring-boot-starter,没有 spring-retry,也没有 AspectJ starter。程序启动后打印的第一行:
Java 21.0.12.1+1-1-deb13u1-Debian, Spring Boot 4.1.1, Spring Framework 7.0.9
Java 25.0.4.1+1-LTS, Spring Boot 4.1.1, Spring Framework 7.0.9
以前用 spring-retry 的写法可以对照看看 一文学会在Spring Boot中使用Spring Retry。官方文档在 Spring Framework Reference: Resilience Features。
加了 @Retryable 为什么一次都没重试
Framework 7 把这两个注解当成元数据,要有人去处理它们才算数。处理它们的是 @EnableResilientMethods,或者手动注册 RetryAnnotationBeanPostProcessor、ConcurrencyLimitBeanPostProcessor。Boot 4.1.1 不会替你打开:spring-boot-autoconfigure 4.1.1 的自动配置清单里没有任何 resilience 相关的配置类,Boot 各个 4.1.1 jar 里也找不到对 @EnableResilientMethods 或这两个 BeanPostProcessor 的引用。
我把开关做成配置项,开和关各跑一次:
@Configuration
@EnableResilientMethods
@ConditionalOnProperty(name = "demo.resilience", havingValue = "on", matchIfMissing = true)
static class ResilienceConfig {}
打开时,bean 是 CGLIB 代理:
client class = com.example.res.PaymentClient$$SpringCGLIB$$0
传 --demo.resilience=off 关掉以后,bean 就是原始类,charge 只调了 1 次:
client class = com.example.res.PaymentClient
== 1. IOException, maxRetries = 2 ==
charge attempt 1 at 08:09:31.102810849
caller got: java.io.IOException: gateway timeout #1
并发限制也一起没了,6 个调用同时进了方法:
== 4. @ConcurrencyLimit(2) BLOCK, 6 callers ==
report 1 done
report 2 done
report 3 done
report 4 done
report 5 done
report 6 done
max in flight = 6
说到底这是注解驱动的 AOP,要靠 @EnableResilientMethods 开启,没开的时候启动不报错,注解静默失效。
maxRetries 到底调几次,最后抛的是什么
被测方法每次都抛 IOException:
@Retryable(includes = IOException.class, maxRetries = 2, delay = 200, multiplier = 2)
public String charge(String orderId) throws IOException {
int n = calls.incrementAndGet();
System.out.println(" charge attempt " + n + " at " + LocalTime.now());
throw new IOException("gateway timeout #" + n);
}
运行结果:
== 1. IOException, maxRetries = 2 ==
charge attempt 1 at 08:09:28.389315458
charge attempt 2 at 08:09:28.602756666
charge attempt 3 at 08:09:29.003489636
caller got: java.io.IOException: gateway timeout #3
maxRetries 数的是重试次数,文档写的是总调用数等于 1 次初始调用加 maxRetries 次重试,所以这里一共 3 次。退避也生效了,两次间隔约 213ms 和 401ms,对应 delay=200、multiplier=2。调用方拿到的是最后一次的原始 IOException,消息是 #3,没有被包一层。
前两次的异常并没有丢。文档的说法是每个异常都会发一个 MethodRetryEvent,想记全量就监听这个事件;编程式的 RetryTemplate.execute 会抛受检的 RetryException,能拿到所有尝试的异常。invoke 则跟注解一样只抛最后那个原始异常。
哪些异常不该重试
@Retryable(excludes = IllegalArgumentException.class, maxRetries = 3, delay = 100)
public String validate(String input) {
System.out.println(" validate called");
throw new IllegalArgumentException("invalid input: " + input);
}
== 2. IllegalArgumentException is excluded ==
validate called
caller got: java.lang.IllegalArgumentException: invalid input: bad
只调了一次就抛出去了,参数错了重试多少遍都是错的。文档里还提到 includes 和 excludes 会匹配嵌套的 cause,所以被包成 RuntimeException 的 IOException 也能命中。
下单这类写接口加 @Retryable 要格外小心。文档写明默认对任何异常都重试,写操作不幂等的话,超时后重试可能扣两次钱,所以得限定异常类型或者先让接口幂等。
同类里 this 调用还能重试吗
public String chargeViaThis(String orderId) throws IOException {
calls.set(0);
return charge(orderId);
}
== 3. self invocation ==
charge attempt 1 at 08:09:29.005099359
caller got: java.io.IOException: gateway timeout #1
外部调用 chargeViaThis 走了代理,但它内部用 this.charge,没经过代理,所以只调了 1 次。这跟 @Transactional 自调用失效是同一个原因。修法也一样:拆到另一个 bean,或者注入自己的代理再调。
@ConcurrencyLimit 是限流还是限并发
它限的是同一时刻在方法里的线程数,不管每秒多少次。策略由 policy 属性指定,枚举是 ConcurrencyLimit.ThrottlePolicy,7.0.9 里只有 BLOCK 和 REJECT 两个值。默认 BLOCK,超出的调用会等着;REJECT 直接拒绝:
@ConcurrencyLimit(2)
public String export(int n) throws InterruptedException { return work(n); }
@ConcurrencyLimit(limit = 2, policy = ConcurrencyLimit.ThrottlePolicy.REJECT)
public String exportOrReject(int n) throws InterruptedException { return work(n); }
每次调用 sleep 300ms,用虚拟线程同时发 6 个请求:
== 4. @ConcurrencyLimit(2) BLOCK, 6 callers ==
report 1 done
report 2 done
report 3 done
report 4 done
report 5 done
report 6 done
max in flight = 2
== 5. @ConcurrencyLimit(2) REJECT, 6 callers ==
report 1 done
report 2 done
rejected: org.springframework.resilience.InvocationRejectedException
rejected: org.springframework.resilience.InvocationRejectedException
rejected: org.springframework.resilience.InvocationRejectedException
rejected: org.springframework.resilience.InvocationRejectedException
max in flight = 2
BLOCK 下 6 个全部完成,同时在跑的最多 2 个。REJECT 下只有 2 个进去了,剩下 4 个拿到 InvocationRejectedException。JDK 25 那次 REJECT 放进去的是 1 号和 3 号,谁抢到名额看调度:
== 5. @ConcurrencyLimit(2) REJECT, 6 callers ==
report 1 done
rejected: org.springframework.resilience.InvocationRejectedException
report 3 done
rejected: org.springframework.resilience.InvocationRejectedException
rejected: org.springframework.resilience.InvocationRejectedException
rejected: org.springframework.resilience.InvocationRejectedException
max in flight = 2
开了虚拟线程照样需要限并发。虚拟线程没有池子上限,并发上来以后压力全落到下游,比如数据库连接池或者第三方接口的配额,文档也专门说了这个注解在虚拟线程下特别有用。另外注解上没有等待超时的属性,BLOCK 下调用方会一直等到拿到名额。
和 spring-retry、Resilience4j 的边界
Framework 7 内置的这一套只覆盖方法级重试和并发上限,熔断、按时间窗口限流这些在 org.springframework.resilience 包里没有,那是 Resilience4j 的范围。spring-retry 有 @Recover 兜底方法,而 7.0.9 的 @Retryable 注解上没有指定兜底方法的属性,重试耗尽后异常直接抛给调用方,由调用方自己 catch。
一起交流
分享你的思考,让讨论更进一步。