SpringBoot4系列25 - 使用内置弹性重试与并发限制
前段时间我给支付服务对接下游通道,通道偶尔抖一下:前两秒请求全超时,第三秒自己恢复。老处理办法是引 spring-retry 或 Resilience4j,加依赖、加 @EnableRetry、按各自词表写注解,一套下来 POM 多两组坐标,依赖树多两层传递。这次我把 Spring Framework 7 收进框架本体的方案完整跑了一遍:@Retryable 和 @ConcurrencyLimit 两个注解加一个 @EnableResilientMethods 开关,重试与并发限制开箱即用,spring-retry 进入维护模式,Resilience4j 的注解体系在纯重试场景也省了。
这篇文章记录我在 Spring Boot 4.1.1(Spring Framework 7.0.9)上从 jar 取证开始的完整过程:注解在哪个包、有哪些属性、怎么启用,然后把「前两轮失败第三轮成功」的下游抖动和「limit=2 的热点接口」各实测一遍,重试尝试次数、退避耗时、429 拒绝数、方法体并发峰值,全部出自我写的端到端验证脚本,每一轮的原始记录我都留了底,没有手工拼数字。选内置方案,我算的是这笔账:跨团队项目里 spring-retry 的版本漂移与 Resilience4j 的注册表配置各自要维护一套理解,注解直接进 Framework 意味着升级 Spring 就同步升级弹性能力,POM 少两组坐标,依赖树少两层传递,新成员上手只需记住两个注解和一个开关。
注解与启用
从 spring-retry 或 Resilience4j 迁移过来,我建议先对照一遍词汇表。spring-retry 的 @Retryable(value = …, maxAttempts = 3, backoff = @Backoff(delay = 100, multiplier = 2)) 加 @EnableRetry,翻译成 Framework 7 的写法是 @Retryable(includes = …, maxRetries = 3, delay = 100, multiplier = 2) 加 @EnableResilientMethods。名字换了一轮:value 从异常类型变成了 includes 的别名,maxAttempts 变成 maxRetries 且语义改为额外重试次数,嵌套的 @Backoff 摊平成 delay、jitter、multiplier、maxDelay 四个属性,@Recover 消失。Resilience4j 的 @Retry(name = “x”) 与 @RateLimiter 走注册表配置,迁移时把 yml 里的限流参数拆到 limitString 与占位符里。
再把坐标钉下来。我一开始按官方文档的章节路径猜注解在 spring-core,import 直接报错,后来把本地仓库的 spring-context-7.0.9.jar 解开才算实锤:弹性注解在 spring-context 的 org.springframework.resilience.annotation 包,这是解包后的目录实证:
| |
编程式入口在 spring-core 的 org.springframework.core.retry 包,RetryTemplate、RetryPolicy、RetryListener 都在那里。两个 jar 都由 spring-boot-dependencies 4.1.1 管理,Framework 版本锁 7.0.9,工程里不需要写任何版本号。
依赖只需要一个 webmvc starter,spring-context 与 spring-aop 都在其中:
| |
一个 starter 两个注解都能用,不用为弹性能力单独引包。
弹性注解的生效机制是 AOP 代理。@EnableResilientMethods 导入 ResilientMethodsConfiguration,注册 RetryAnnotationBeanPostProcessor 与 ConcurrencyLimitBeanPostProcessor,两个类都继承 AbstractBeanFactoryAwareAdvisingPostProcessor,往匹配的 bean 上挂通知器,重试走 SimpleRetryInterceptor,并发限制走 ConcurrencyLimitInterceptor。
我翻了 spring-boot-autoconfigure 的 AutoConfiguration.imports 文件,Boot 4.1.1 没有为弹性能力准备专属 starter,grep 不到任何 Resilience 条目,所以开关要自己声明:
| |
不加这个开关,@Retryable 与 @ConcurrencyLimit 只是元数据,不会被处理,方法调用一切照旧,而且没有任何报错。我第一次跑就在这里闹了个笑话:注解贴了、服务起了,故意制造失败却发现一次重试都没发生,回头才看到主类上漏了开关。也可以不用总开关,单独声明两个 PostProcessor bean,按需只开重试或只开并发限制。

两个注解都能标在两个位置:方法级只管这一个方法,类型级管这个 bean 层次里所有被代理调用的方法。@EnableResilientMethods 自带两个属性,proxyTargetClass 控制代理是 CGLIB 还是 JDK 接口代理,order 控制这组通知器在代理链上的位置。工程里有事务注解时,重试在事务开启之前还是之后执行由这个 order 决定,重试包在事务外面才能让每次尝试各自开事务,嵌在事务里面则整轮重试共享一个事务,回滚语义完全不同。
对返回 Reactive 类型的方法,@Retryable 同样生效,代理把重试装饰进 Reactor 管线,底层走 Reactor 的 retry 能力,退避参数换算成对应的 FluxInterval 等操作符。同步方法的重试是阻塞式的,调用线程原地等待退避间隔过去;异步方法的重试不占线程,这是同一套注解在两种编程模型下的执行差异。
再确认两个不存在的注解。我把 spring-context 7.0.9 全 jar 内 grep recover,零命中:Framework 7 没有 spring-retry 里 @Recover 的等价物,也没有 @RetryWith 这个名字;重试耗尽后最后一次原始异常直接抛给调用方。降级兜底要靠外层自己接,后面「兜底恢复」一节展开。
重试实测
@Retryable 的属性表我从 javap 取证,与 spring-retry 的词汇表差异不小:
| 属性 | 默认值 | 说明 |
|---|---|---|
| value / includes | 空 | 触发重试的异常类型,value 是 includes 的别名,子类与嵌套 cause 都参与匹配 |
| excludes | 空 | 不重试的异常类型 |
| predicate | 默认实现 | MethodRetryPredicate,按 Method 与 Throwable 精细判定 |
| maxRetries | 3 | 最大重试次数,总尝试次数等于 1 + maxRetries |
| timeout | 无超时 | 整轮时间上限,覆盖初试、全部重试与所有退避延迟 |
| delay | 1000 | 首次重试前的等待毫秒数 |
| jitter | 0 | 随机抖动,避免重试风暴齐步走 |
| multiplier | 1.0 | 退避乘数,大于 1 时按指数增长 |
| maxDelay | Long.MAX_VALUE | 退避上限 |
| timeUnit | MILLIS | 时间单位 |
没有 maxAttempts,那个词属于 spring-retry;这里叫 maxRetries,且语义是「额外重试几次」,初试永远先来一次。backoff 也不再是嵌套注解,摊平成 delay、jitter、multiplier、maxDelay 四个属性。每个属性还有对应的 String 变体(maxRetriesString、delayString),支持属性占位符与 SpEL。
业务场景是支付扣款。PaymentService 的 charge 方法标注重试策略,只对通道不可用异常重试,最多 3 次,100ms 起步按 2 倍指数退避、500ms 封顶:
| |
这个方法签名里没有异常声明,ChannelUnavailableException 是 RuntimeException,代理层捕获后按 includes 匹配决定是否重试。
下游是工程内的模拟通道,arm(failTimes) 之后前 failTimes 次调用抛异常,之后恢复正常,每次调用计入 callCount,callCount 就是代理循环调用的尝试次数:
| |
控制器装填 failTimes 后调用门面,门面负责计时与结果组装。我写的端到端验证脚本跑出的第一组原始记录:
| |
两次失败加一次成功,attempts 是 3,总尝试次数公式 1 + maxRetries 与实测一致。挺有意思的是耗时的构成:前两次失败后各等 100ms 与 200ms 再重试,共 300ms 退避,加上三次调用的开销,323ms 对得上账。

退避曲线里的 jitter 是防重试风暴的关键参数。下游恢复的瞬间,同一批超时的客户端按同样的 delay 齐步重试,下游刚站起来就被第二波打倒,这是重试风暴的成因。jitter=100 表示每次实际等待在退避值附近随机偏移 100ms 以内,把整齐的重试打散。另一个特殊组合是 delay=0 加正 jitter:multiplier 失效,每次等待直接取 0 到 min(jitter, maxDelay) 之间的随机值,适合「立刻重试但别齐步走」的场景。
includes 与 excludes 只做类型匹配,想按异常细节决策要写自定义谓词。MethodRetryPredicate 接口只有一个 shouldRetry(Method, Throwable) 方法,按方法与异常对象两个维度判断:
| |
谓词在 includes 与 excludes 之后执行,三者叠加时先过类型筛,再过谓词筛,顺序固定。按异常 message 里是否包含「timeout」决定重试与否,就靠这一层。
把失败配额拉到 5,超过重试能力,我单独跑了一轮看耗尽后的行为:
| |
attempts 到 4 就停了,1 次初试加 3 次重试全部失败。736ms 我拆开对过账:退避占 700ms(100 + 200 + 400),多倍乘数被 maxDelay=500 压住前的最后一跳是 400ms。
每次失败 Framework 7 都会发布一个 MethodRetryEvent(继承 MethodFailureEvent,带 getFailure 与 isRetryAborted 两个读法),监听器打一行日志就能看到代理内部的完整过程:
| |
验证脚本的应用日志里收了 12 行 retry-event:两次成功的演示各 2 个失败事件,耗尽的演示 4 个失败事件外加 1 个 retryAborted=true 的中止事件,failure 字段是 RetryException。调用方只能看到最后一次异常,这个事件序列是观测每一次失败的窗口。
不装兜底的裸调用走另一条路:重试耗尽后 ChannelUnavailableException 直接冒出代理,控制器捕获后转成 502 返回:
| |
耗尽后的异常类型就是目标方法抛出的原始类型,代理不做包装,这一点与 spring-retry 的 ExhaustedRetryException 不同。
编程式重试
注解管不到的代码块(工具类静态方法、第三方客户端内部、main 方法里的胶水逻辑)走 RetryTemplate。这个类在 spring-core 的 org.springframework.core.retry 包,实例很轻,我在调用点随手建:
| |
invoke 变体的行为与 @Retryable 一致:耗尽后把最后一次原始异常抛给调用方。execute 变体多一层信息,抛出的 RetryException 带 getExceptions() 与 getLastException(),整轮所有失败都拿得到:
| |
RetryListener 回调挂在 template 上,能收到每轮执行的开始、每次失败与耗尽三种时刻,适合把编程式重试的观测接进日志或指标。注解路径的事件叫 MethodRetryEvent,编程式路径的回调叫 RetryListener,两套观测口名字不同、位置不同,按场景各取所需。
中止信号
耗尽之外还有两种提前结束:timeout 到点,或者线程被中断。这两条路径的对外表现与自然耗尽一致,最后一次原始异常照常抛出;差异在事件流,isRetryAborted 为 true 的 MethodRetryEvent 会先于异常发出,failure 字段带 RetryException。timeout 属性按整轮计时(从第一次调用开始算),不是按单次尝试计时,delay 乘 multiplier 的退避曲线必须和 timeout 一起规划,否则 400ms 封顶的三连退避可能撑不到第三次重试就被 timeout 掐断。
并发限制实测
@ConcurrencyLimit 的属性表短得多:limit(value 是它的别名)、limitString(占位符与 SpEL)、policy。常用的就这三样,其余交给默认值。policy 取两个值,BLOCK 是默认值,满员后后来的线程排队等待;REJECT 满员后立刻抛 InvocationRejectedException,这个异常继承 java.util.concurrent.RejectedExecutionException,语义与线程池拒绝任务一致。
热点接口 limit=2 加 REJECT 策略,方法体固定耗时 200ms:
| |
控制器把 InvocationRejectedException 转成 HTTP 429,并维护接受与拒绝两个计数器。验证脚本用 6 个后台 curl 同时打这个接口,我拿到手的原始记录长这样:
| |
6 个请求里 2 个被拒,4 个通过。被拒的请求 4 到 6 毫秒就返回了,拒绝发生在进入方法体之前,等待成本接近零;通过的 4 个请求各自耗时 200ms 出头,分成两批串行过闸。maxInFlight 记录方法体内的并发峰值,全程没超过 2,limit 的约束成立。

429 的选择也过一遍。InvocationRejectedException 映射成 HTTP 429 Too Many Requests 还是 503 Service Unavailable,决定的是客户端行为约定:429 配 Retry-After 头,客户端按服务端给的节奏退避;503 表达服务整体不可用。并发限流属于前者,工程里在 429 响应体里带上 reason 字段,前端据此展示「当前排队人数多,稍后再试」而不是「系统故障」。
BLOCK 策略换一个接口,同样 limit=2:
| |
4 个并发请求全部成功,总耗时 833ms。200ms 的任务分成两批过闸,第二批等第一批让坑,谁都没有被牺牲。两种策略的选型我的判断是:前台交互接口用 REJECT,快速失败把压力交回调用方;内部批处理或必须成功的调用用 BLOCK,代价是排队线程占着线程池的坑位。
官方文档给了 @ConcurrencyLimit 一个特殊场景:虚拟线程。虚拟线程没有池上限,方法内部一旦同步等待,平台线程可能被批量占满,@ConcurrencyLimit(1) 等价于给 bean 实例上一把锁,在 Boot 4 的 spring.threads.virtual.enabled=true 环境下是少见但管用的保险。
这个注解的前身挺有意思:Framework 1.0 时代就有 ConcurrencyThrottleInterceptor,一个可以手工挂进 AOP 代理链的并发节流拦截器,语义与今天的 @ConcurrencyLimit 相同。Framework 7 做的事是把它注解化,声明成本从「装配 ProxyFactoryBean」降到「贴一行注解」。
limit 的动态化用 limitString,支持属性占位符与 SpEL:
| |
值在代理创建时解析一次,运行期不重读注解。配置中心改了 payment.hotspot.limit,要等 bean 重建才生效,热更新场景按这个时机做预期管理。
并发压测的方法也值得展开。我的验证脚本用 6 个后台 curl 同时发请求,Shell 层面就是 curl ... & 六连加一次 wait,每个请求拿到各自的 HTTP 状态码与 time_total。这个压法没有专业压测工具的吞吐控制,但复现并发重叠足够:方法体 200ms,6 个请求在 20ms 内全部发出,重叠窗口远大于闸门宽度,429 必然出现。自动化断言用同样的思路写进了测试,ExecutorService 固定 6 线程,CountDownLatch 做起跑闸,收集状态码列表后断言 accepted 加 rejected 等于总数、maxInFlight 不超过 2。
兜底恢复
spring-retry 有一套 @Recover:重试耗尽后自动路由到同签名加 @Recover 的方法。Framework 7 没有这个机制,耗尽后异常原样抛出,降级要靠调用方显式处理。我在工程里加了一层门面,把重试调用与降级收在同一个方法里:
| |
catch 里返回降级的 ChargeResult,调用方拿到统一的结果对象,fallback 字段标记是否走了兜底。这个写法比 @Recover 多三行,但降级逻辑显式可见,不需要翻注解才知道耗尽后去哪。
兜底返回什么,按接口语义分三档:查询类接口回缓存里的旧值,多带一个 fetchedAt 时间戳让前端展示「数据非实时」;写操作回「已受理,稍后补偿」,把请求落进本地表由定时任务补投;没有替代品的操作直接抛出,让调用方收到明确失败,别为了不抛异常编一个假的返回值。兜底值与正常值混在同一个返回类型里时,标记字段不可省略,排查问题时「这笔单据是降级产物」这一条信息值一次复盘。
降级与重试的分工也在这个方法里:重试负责可恢复的瞬时故障,降级负责重试救不回来的持续故障,两道防线各管一段,耗尽时间点就是分界线。这段代码里藏着一个代理陷阱:charge 必须从另一个 bean 的引用调用。PaymentFacade 注入 PaymentService,注入进来的是代理对象,重试拦截器在代理层生效;假如 PaymentService 自己的方法内部 this.charge(),this 是原始对象,绕过代理,重试一次都不会发生。这也是「重试标注在哪个 bean、从哪里调用」值得在 code review 里盯一眼的原因。
方法级的兜底还有一种组合:RetryTemplate(org.springframework.core.retry 包)编程式重试,execute 变体抛出的 RetryException 带 getExceptions() 与 getLastException(),比注解的「只见最后一次异常」多一层信息,适合需要在降级时区分失败原因的场景。
完整案例
工程 springboot4-resilience 的结构:
| |
测试覆盖五条主链路,5 个用例全部通过:
| 用例 | 断言 |
|---|---|
| failTimes=2 重试后成功 | success=true、attempts=3、fallback=false、2 个失败事件 |
| failTimes=5 耗尽走兜底 | success=false、attempts=4、fallback=true、4 失败事件 + 1 中止事件 |
| 裸调用耗尽冒泡 | HTTP 502、error 为 ChannelUnavailableException、rawAborts=1 |
| REJECT 并发 6 请求 | 出现 429、accepted+rejected=6、maxInFlight=2 |
| BLOCK 并发 4 请求 | 全部 200、maxInFlight=2 |
测试里最能说明事件机制的是这条断言:RETRY_EVENTS 列表在测试上下文里收集 MethodRetryEvent,重试成功的用例收到 2 个失败事件、耗尽的用例收到 4 个失败事件加 1 个中止事件,事件里能直接拿到 isRetryAborted 与 failure 的类型。并发用例我没用 RestTestClient,用 JDK 的 HttpClient 加线程池驱动真并发,CountDownLatch 保证 6 个请求同一毫秒起跑:
| |
验证脚本一轮跑完全部场景,我把打出来的原始记录整段留了底:
| |
一轮端到端跑下来的汇总我记在这里:重试成功 323ms、耗尽兜底 736ms、裸调用 502、REJECT 2 个 429、BLOCK 总耗时 833ms、热点统计 accepted=8 / rejected=2 / maxInFlight=2、retry-event 12 行、ERROR 0 行、WARN 0 行。日志里的 retry-event 行是预期内的重试记录,属于这套机制的正常输出,我特意核对过,不是告警。

脚本本身不到一百行,核心结构三段:起服务、取数、关服务。起服务用 java -jar 拉后台进程,循环 curl 一个轻接口等就绪;取数就是前面那些 curl 加 6 连发压;收尾 kill 进程后打印全部结果。脚本开头显式 export SERVER_PORT=18250,因为 CI 环境会注入自己的 SERVER_PORT,不固定下来端口断言全部落空。这套脚本与单元测试互补:测试在随机端口验证行为正确性,脚本在固定端口复现一次完整操作路径,正文引用的每个数字都出自同一份脚本一口气打出来的原始记录。
避坑指南
坑一,注解包名在 spring-context 不在 spring-core。 官方文档章节挂在 core/resilience.html,@Retryable 等注解的实体却在 spring-context 的 org.springframework.resilience.annotation 包,RetryTemplate 才在 spring-core 的 org.springframework.core.retry。我按文档路径猜包名,import 当场失败。
坑二,忘了 @EnableResilientMethods 注解静默失效。 没有开关时注解只是元数据,方法照常执行,重试与限流一次都不会发生,且没有任何报错。判断代理是否生效,最直接的办法是看注入的对象类型里有没有 CGLIB 标记,或者干脆制造一次失败数一数尝试次数。
坑三,maxRetries 是额外重试次数,总次数要加一。 maxRetries=3 的方法最多被执行 4 次。从 spring-retry 迁移时按 maxAttempts 的记忆填数,重试次数悄悄少一次。
坑四,同类内部调用绕过代理。 @Retryable 方法被同一个类的 this 调用时,重试拦截器不在场。门面模式或自注入(ObjectProvider 拿自己)都能解决,我的工程用 PaymentFacade 隔离调用方。
坑五,Framework 7 没有 @Recover。 spring-retry 的耗尽自动路由在原地不存在,耗尽后最后一次原始异常直接抛出。降级兜底要么调用方 try/catch,要么换 RetryTemplate 的 execute 拿完整的失败列表。
坑六,耗尽异常不被包装。 调用方 catch 的是目标方法声明的原始异常类型(ChannelUnavailableException),catch ExhaustedRetryException 会落空。跨场景写通用异常处理时按原始类型匹配。
坑七,REJECT 与 BLOCK 的选择影响线程池。 REJECT 快速失败不占资源,BLOCK 排队线程都挂着等坑。BLOCK 限流值设得比线程池大几倍的场景下,高峰期所有工作线程都可能堵在排队上,连带无关接口一起饿死。
坑八,@ConcurrencyLimit 的限流是 per bean 实例。 限流状态挂在代理实例上,多实例部署时每个 JVM 各限各的,全局并发是实例数乘 limit。容量规划按这个乘法算。
坑九,重试要带上幂等前提。 下游收到请求但响应超时,重试就是二次扣款。includes 里只放可安全重试的异常,写操作先做幂等键,注解管不住业务语义。
坑十,退避参数直接决定线程占用。 阻塞式重试期间调用线程原地等待,delay=1000 multiplier=2 的三连击要空等 7 秒。Web 请求链路上大延迟退避会把 Tomcat 线程耗光,同步接口的退避上限要按线程池容量倒推,或改用 reactive 返回类型让 Reactor 的 retry 接管。
坑十一,MethodRetryEvent 只在注解路径发布。 @Retryable 的每次失败都有事件,RetryTemplate 走的是 RetryListener 回调,两套观测口不通用。混用时监听器各配各的。
坑十二,String 变体属性才能用配置中心热调。 maxRetries 是 long 字面量,改一次要重新编译;maxRetriesString 与 limitString 支持 ${…} 占位符,运行时从 Environment 解析,限流值做成配置项要用这个变体。
坑十三,maxRetries 配 0 表示一次都不重试。 builder 与注解都接受 0,语义是只执行初试,出错直接抛。这个值适合做环境的降级开关:测试环境把重试关掉,失败路径的用例不用等退避时钟走完。
坑十四,InvocationRejectedException 继承 RejectedExecutionException。 catch RejectedExecutionException 的通用处理器会把并发限流的拒绝一并接走,@Async 提交链路里它会被当成线程池拒绝处理。区分两种来源要么 catch 子类先于父类,要么在日志字段里补方法名。
坑十五,BLOCK 的排队过程没有观测点。 REJECT 有异常有计数器,BLOCK 的等待线程卡在代理层,没有事件也不进任何指标,排队时间只能从接口耗时里反推。要观测排队长度,得自己在方法入口出口埋 inFlight 计数,我的 HotspotService 里 maxInFlight 就是这个用法。
小结
Framework 7 把重试与并发限制做成了框架本体的两个注解:@Retryable 管「失败了再试几次、隔多久再试」,@ConcurrencyLimit 管「同一时刻最多几个人进方法体」,@EnableResilientMethods 一开就生效,依赖就是工程里已有的 spring-context 加 spring-aop。
选型建议按场景走:存量工程已经在用 spring-retry 或 Resilience4j 且用得深,不必为迁移而迁移,等自然升级窗口再换;新工程或想给依赖树减负的,直接上内置注解,迁移时记住三件事,词汇表换了(maxRetries 与 includes,没有 maxAttempts 与 retryFor)、@Recover 不存在(兜底自己写门面)、限流是单实例语义(全局容量按实例数乘 limit 算)。写操作接重试之前先把幂等键做了,这个前提注解帮不了你。
没解决的事也有两件。一是 BLOCK 策略的排队过程依然没有观测点,排队长度只能自己埋计数,要不要在工程里补一个通用的 inFlight 切面,我还没想好。二是 Reactive 路径的重试我只确认了机制(代理把重试装饰进 Reactor 管线),退避在事件循环上的实际表现没有实测数字,这块留到我补齐 reactive 的验证场景再展开。
参考链接
- Spring Framework 7.0 Resilience Features:@Retryable、@ConcurrencyLimit 与 RetryTemplate 官方章节
- @Retryable javadoc(7.0.9):注解属性与默认值的 API 定义
- Spring Boot 4.1 Reference:Boot 4.1 配套文档,弹性注解无自动配置的出处
- spring-context 7.0.9(Maven Central):本文注解包名的制品实证来源