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 包,这是解包后的目录实证:

1
2
3
4
5
6
7
org/springframework/resilience/annotation/Retryable.class
org/springframework/resilience/annotation/ConcurrencyLimit.class
org/springframework/resilience/annotation/EnableResilientMethods.class
org/springframework/resilience/annotation/ConcurrencyLimit$ThrottlePolicy.class
org/springframework/resilience/annotation/RetryAnnotationBeanPostProcessor.class
org/springframework/resilience/annotation/ConcurrencyLimitBeanPostProcessor.class
org/springframework/resilience/retry/MethodRetryEvent.class

编程式入口在 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 都在其中:

1
2
3
4
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>

一个 starter 两个注解都能用,不用为弹性能力单独引包。

弹性注解的生效机制是 AOP 代理。@EnableResilientMethods 导入 ResilientMethodsConfiguration,注册 RetryAnnotationBeanPostProcessor 与 ConcurrencyLimitBeanPostProcessor,两个类都继承 AbstractBeanFactoryAwareAdvisingPostProcessor,往匹配的 bean 上挂通知器,重试走 SimpleRetryInterceptor,并发限制走 ConcurrencyLimitInterceptor。

我翻了 spring-boot-autoconfigure 的 AutoConfiguration.imports 文件,Boot 4.1.1 没有为弹性能力准备专属 starter,grep 不到任何 Resilience 条目,所以开关要自己声明:

1
2
3
4
5
6
7
8
@SpringBootApplication
@EnableResilientMethods
public class ResilienceApplication {

    public static void main(String[] args) {
        SpringApplication.run(ResilienceApplication.class, args);
    }
}

不加这个开关,@Retryable 与 @ConcurrencyLimit 只是元数据,不会被处理,方法调用一切照旧,而且没有任何报错。我第一次跑就在这里闹了个笑话:注解贴了、服务起了,故意制造失败却发现一次重试都没发生,回头才看到主类上漏了开关。也可以不用总开关,单独声明两个 PostProcessor bean,按需只开重试或只开并发限制。

https://static.xiongneng.me/resilience-annotation-components-20260922063404.png

两个注解都能标在两个位置:方法级只管这一个方法,类型级管这个 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 精细判定
maxRetries3最大重试次数,总尝试次数等于 1 + maxRetries
timeout无超时整轮时间上限,覆盖初试、全部重试与所有退避延迟
delay1000首次重试前的等待毫秒数
jitter0随机抖动,避免重试风暴齐步走
multiplier1.0退避乘数,大于 1 时按指数增长
maxDelayLong.MAX_VALUE退避上限
timeUnitMILLIS时间单位

没有 maxAttempts,那个词属于 spring-retry;这里叫 maxRetries,且语义是「额外重试几次」,初试永远先来一次。backoff 也不再是嵌套注解,摊平成 delay、jitter、multiplier、maxDelay 四个属性。每个属性还有对应的 String 变体(maxRetriesString、delayString),支持属性占位符与 SpEL。

业务场景是支付扣款。PaymentService 的 charge 方法标注重试策略,只对通道不可用异常重试,最多 3 次,100ms 起步按 2 倍指数退避、500ms 封顶:

1
2
3
4
5
6
7
8
9
@Retryable(
        includes = ChannelUnavailableException.class,
        maxRetries = 3,
        delay = 100,
        multiplier = 2,
        maxDelay = 500)
public String charge(String orderId) {
    return gateway.call(orderId);
}

这个方法签名里没有异常声明,ChannelUnavailableException 是 RuntimeException,代理层捕获后按 includes 匹配决定是否重试。

下游是工程内的模拟通道,arm(failTimes) 之后前 failTimes 次调用抛异常,之后恢复正常,每次调用计入 callCount,callCount 就是代理循环调用的尝试次数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public void arm(int failTimes) {
    armedFailures.set(Math.max(0, failTimes));
    callCount.set(0);
}

public String call(String orderId) {
    callCount.incrementAndGet();
    int remaining = armedFailures.getAndUpdate(v -> v > 0 ? v - 1 : 0);
    if (remaining > 0) {
        throw new ChannelUnavailableException("支付通道超时, orderId=" + orderId);
    }
    return "txn-" + txSeq.incrementAndGet();
}

控制器装填 failTimes 后调用门面,门面负责计时与结果组装。我写的端到端验证脚本跑出的第一组原始记录:

1
2
3
POST /api/payments/charge?orderId=order-1&failTimes=2
{"orderId":"order-1","success":true,"attempts":3,"elapsedMs":323,
 "fallback":false,"message":"txn-1001"}

两次失败加一次成功,attempts 是 3,总尝试次数公式 1 + maxRetries 与实测一致。挺有意思的是耗时的构成:前两次失败后各等 100ms 与 200ms 再重试,共 300ms 退避,加上三次调用的开销,323ms 对得上账。

https://static.xiongneng.me/resilience-retry-sequence-20260922063408.png

退避曲线里的 jitter 是防重试风暴的关键参数。下游恢复的瞬间,同一批超时的客户端按同样的 delay 齐步重试,下游刚站起来就被第二波打倒,这是重试风暴的成因。jitter=100 表示每次实际等待在退避值附近随机偏移 100ms 以内,把整齐的重试打散。另一个特殊组合是 delay=0 加正 jitter:multiplier 失效,每次等待直接取 0 到 min(jitter, maxDelay) 之间的随机值,适合「立刻重试但别齐步走」的场景。

includes 与 excludes 只做类型匹配,想按异常细节决策要写自定义谓词。MethodRetryPredicate 接口只有一个 shouldRetry(Method, Throwable) 方法,按方法与异常对象两个维度判断:

1
2
3
4
5
6
7
@Retryable(
        includes = ChannelUnavailableException.class,
        predicate = ChannelRetryPredicate.class,
        maxRetries = 3)
public String charge(String orderId) {
    return gateway.call(orderId);
}

谓词在 includes 与 excludes 之后执行,三者叠加时先过类型筛,再过谓词筛,顺序固定。按异常 message 里是否包含「timeout」决定重试与否,就靠这一层。

把失败配额拉到 5,超过重试能力,我单独跑了一轮看耗尽后的行为:

1
2
3
POST /api/payments/charge?orderId=order-2&failTimes=5
{"orderId":"order-2","success":false,"attempts":4,"elapsedMs":736,
 "fallback":true,"message":"degraded: 支付通道超时, orderId=order-2"}

attempts 到 4 就停了,1 次初试加 3 次重试全部失败。736ms 我拆开对过账:退避占 700ms(100 + 200 + 400),多倍乘数被 maxDelay=500 压住前的最后一跳是 400ms。

每次失败 Framework 7 都会发布一个 MethodRetryEvent(继承 MethodFailureEvent,带 getFailure 与 isRetryAborted 两个读法),监听器打一行日志就能看到代理内部的完整过程:

1
2
3
4
5
6
@EventListener
public void onMethodRetry(MethodRetryEvent event) {
    System.out.printf("[retry-event] method=%s, failure=%s, retryAborted=%s%n",
            event.getMethod().getName(), event.getFailure().getClass().getSimpleName(),
            event.isRetryAborted());
}

验证脚本的应用日志里收了 12 行 retry-event:两次成功的演示各 2 个失败事件,耗尽的演示 4 个失败事件外加 1 个 retryAborted=true 的中止事件,failure 字段是 RetryException。调用方只能看到最后一次异常,这个事件序列是观测每一次失败的窗口。

不装兜底的裸调用走另一条路:重试耗尽后 ChannelUnavailableException 直接冒出代理,控制器捕获后转成 502 返回:

1
2
3
POST /api/payments/charge-raw?orderId=order-3&failTimes=9  [HTTP 502]
{"error":"ChannelUnavailableException","success":false,"fallback":false,
 "message":"支付通道超时, orderId=order-3"}

耗尽后的异常类型就是目标方法抛出的原始类型,代理不做包装,这一点与 spring-retry 的 ExhaustedRetryException 不同。

编程式重试

注解管不到的代码块(工具类静态方法、第三方客户端内部、main 方法里的胶水逻辑)走 RetryTemplate。这个类在 spring-core 的 org.springframework.core.retry 包,实例很轻,我在调用点随手建:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var retryPolicy = RetryPolicy.builder()
        .includes(ChannelUnavailableException.class)
        .maxRetries(3)
        .delay(Duration.ofMillis(100))
        .multiplier(2)
        .maxDelay(Duration.ofMillis(500))
        .build();

String txn = new RetryTemplate(retryPolicy).invoke(
        () -> flakyGateway.call(orderId));

invoke 变体的行为与 @Retryable 一致:耗尽后把最后一次原始异常抛给调用方。execute 变体多一层信息,抛出的 RetryException 带 getExceptions() 与 getLastException(),整轮所有失败都拿得到:

1
2
3
4
5
6
7
try {
    String txn = retryTemplate.execute(() -> flakyGateway.call(orderId));
} catch (RetryException ex) {
    // ex.getExceptions() 是这一轮全部失败的列表
    log.warn("扣款失败 {} 次, 最后一次: {}", ex.getExceptions().size(),
            ex.getLastException().getMessage());
}

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:

1
2
3
4
@ConcurrencyLimit(limit = 2, policy = ConcurrencyLimit.ThrottlePolicy.REJECT)
public String rejectReport(int seq) {
    return enterBody(seq, 200);
}

控制器把 InvocationRejectedException 转成 HTTP 429,并维护接受与拒绝两个计数器。验证脚本用 6 个后台 curl 同时打这个接口,我拿到手的原始记录长这样:

1
2
3
4
5
6
7
8
seq=2 HTTP 429 0.005622s
seq=5 HTTP 429 0.004018s
seq=0 HTTP 200 0.220105s
seq=1 HTTP 200 0.205970s
seq=3 HTTP 200 0.269292s
seq=4 HTTP 200 0.214764s
GET /api/hotspot/stats
{"rejected":2,"accepted":8,"maxInFlight":2}

6 个请求里 2 个被拒,4 个通过。被拒的请求 4 到 6 毫秒就返回了,拒绝发生在进入方法体之前,等待成本接近零;通过的 4 个请求各自耗时 200ms 出头,分成两批串行过闸。maxInFlight 记录方法体内的并发峰值,全程没超过 2,limit 的约束成立。

https://static.xiongneng.me/resilience-concurrency-semantics-20260922063412.png

429 的选择也过一遍。InvocationRejectedException 映射成 HTTP 429 Too Many Requests 还是 503 Service Unavailable,决定的是客户端行为约定:429 配 Retry-After 头,客户端按服务端给的节奏退避;503 表达服务整体不可用。并发限流属于前者,工程里在 429 响应体里带上 reason 字段,前端据此展示「当前排队人数多,稍后再试」而不是「系统故障」。

BLOCK 策略换一个接口,同样 limit=2:

1
2
3
4
@ConcurrencyLimit(limit = 2, policy = ConcurrencyLimit.ThrottlePolicy.BLOCK)
public String blockReport(int seq) {
    return enterBody(seq, 200);
}

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:

1
2
3
4
@ConcurrencyLimit(limitString = "${payment.hotspot.limit:2}")
public String blockReport(int seq) {
    return enterBody(seq, 200);
}

值在代理创建时解析一次,运行期不重读注解。配置中心改了 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 没有这个机制,耗尽后异常原样抛出,降级要靠调用方显式处理。我在工程里加了一层门面,把重试调用与降级收在同一个方法里:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public ChargeResult chargeWithFallback(String orderId, int failTimes) {
    gateway.arm(failTimes);
    long t0 = System.nanoTime();
    try {
        String txn = paymentService.charge(orderId);
        return new ChargeResult(orderId, true, gateway.getCallCount(),
                elapsedMs(t0), false, txn);
    } catch (ChannelUnavailableException ex) {
        return new ChargeResult(orderId, false, gateway.getCallCount(),
                elapsedMs(t0), true, "degraded: " + ex.getMessage());
    }
}

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 的结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
com.xncoding.resilience
├── ResilienceApplication          # @SpringBootApplication + @EnableResilientMethods
├── controller
│   ├── PaymentController          # /api/payments 重试演示接口组
│   └── HotspotController          # /api/hotspot 并发限制演示接口组
├── service
│   ├── FlakyPaymentGateway        # 模拟抖动下游,arm 控制失败配额
│   ├── PaymentService             # @Retryable 标注点
│   ├── PaymentFacade              # 计时、降级兜底门面
│   └── HotspotService             # @ConcurrencyLimit 两个策略各一个方法
├── listener
│   └── RetryEventLogger           # MethodRetryEvent 监听
├── domain
│   └── ChargeResult               # 尝试次数/耗时/是否降级的结果单
└── exception
    └── ChannelUnavailableException

测试覆盖五条主链路,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 个请求同一毫秒起跑:

1
2
3
4
5
6
7
8
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < total; i++) {
    futures.add(pool.submit(() -> {
        gate.await();
        return http("POST", path + "?seq=" + seq).split("\\|", 2)[0];
    }));
}
gate.countDown();

验证脚本一轮跑完全部场景,我把打出来的原始记录整段留了底:

1
2
3
4
5
6
POST /api/payments/charge?orderId=order-1&failTimes=2
attempts=3 elapsedMs=323 success=true
POST /api/payments/charge?orderId=order-2&failTimes=5
attempts=4 elapsedMs=736 fallback=true
GET /api/hotspot/stats
accepted=8 rejected=2 maxInFlight=2

一轮端到端跑下来的汇总我记在这里:重试成功 323ms、耗尽兜底 736ms、裸调用 502、REJECT 2 个 429、BLOCK 总耗时 833ms、热点统计 accepted=8 / rejected=2 / maxInFlight=2、retry-event 12 行、ERROR 0 行、WARN 0 行。日志里的 retry-event 行是预期内的重试记录,属于这套机制的正常输出,我特意核对过,不是告警。

https://static.xiongneng.me/resilience-verify-panel.png

脚本本身不到一百行,核心结构三段:起服务、取数、关服务。起服务用 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 的验证场景再展开。

参考链接