1. 为什么需要重试机制?——从一次线上故障说起
那天下午我的手机被运维瞬间打爆,原因是线上订单服务大面积超时,连带支付回调也攒了整整五千多条。事后翻日志才发现罪魁祸首是下游的库存服务在做一次全量缓存重建,偶尔返回 503,而我们的调用方代码里没有任何重试保护,一次失败就直接把异常抛给了上层,最终导致整条链路雪崩。事后复盘时,组里的老大哥悠悠来了一句:“这个场景要是用上 Spring 的 @Retryable 就稳了。”那是我第一次真正正视 Spring 框架里的重试注解。
所谓重试,说白了就是当一次方法调用因为偶发的网络抖动、下游限流或瞬时故障而失败时,系统自动按照预设的次数和间隔策略重新发起调用,直到成功或达到最大尝试次数为止。它解决的根本矛盾是:分布式环境下,异常往往是“大概率事件”,你的代码必须假设上下游随时可能不给面子。对于 Spring 生态的开发者来说,@Retryable 与 @Recover 这对组合就是专门干这个的——前者标记“哪个方法需要重试”,后者定义“重试彻底失败后怎么兜底”。我还记得第一次看到这对注解时的感受:网上的帖子不是贴个 demo 就完事,就是参数解释得云里雾里,真到了自己要接入时,踩了一堆莫名其妙的坑。所以这篇就按我的实操路径来写,从原理讲到配置,从踩坑讲到排查,争取让你看完就能直接落地到项目里。
这篇文章适合谁看?如果你在用 Spring Boot 搭服务、写接口,或者你的任务里恰好有“调用三方接口要稳定”这种需求,那这篇文章就是给你准备的。哪怕你目前还没遇到过重试场景,把这些知识点提前存着,也能让你以后处理故障时多一张底牌。带杠精属性的朋友可能会说“重试我自己用 for 循环写不行吗”,行,当然行,但当你需要处理退避策略、异常类型区分、回退兜底这些细节时,自己手写才知道维护成本有多高。接下来我们会从设计思路上聊聊为什么选择注解方案,再手把手把核心参数和恢复方法讲透,最后补充高频故障的排查经验。
2. 核心参数逐个拆:@Retryable 的选择逻辑与 @Recover 的兜底边界
2.1 @Retryable 注解参数:不是全写上就完事
先看 @Retryable 最基础的写法。在 Spring Boot 工程里,第一步需要引入 spring-retry 依赖,然后在启动类或配置类上加上 @EnableRetry 开关,之后才能在目标方法上使用注解。很多新手会漏了 @EnableRetry,结果注解写了半天完全没生效,这是最经典的入门坑。配上参数之后大概是这个样子:
@Service public class PaymentService { private static final Logger log = LoggerFactory.getLogger(PaymentService.class); @Retryable( retryFor = {RemoteAccessException.class, TimeoutException.class}, noRetryFor = {IllegalArgumentException.class}, maxAttempts = 4, backoff = @Backoff(delay = 1000, multiplier = 2, maxDelay = 10000) ) public String callPaymentGateway(String orderId) { // 模拟调用三方支付接口 return restTemplate.postForObject("/pay", orderId, String.class); } }这里面的参数各有各的门道。retryFor 指定哪些异常会触发重试,Spring 4.3 之后推荐用它替代已废弃的 include,它的底层逻辑是判断抛出的异常是否为指定异常类型或其子类。noRetryFor 则是黑名单,像参数校验异常这种“重试一万次也不可能成功”的业务异常,一定要塞进去,不然系统会做着无意义的等待。maxAttempts 是整个重试过程最多执行多少次方法调用,含第一次原始调用,写 4 意味着最多打四次。最后一个 backoff 是退避策略,delay 是第一轮失败后的等待毫秒数,multiplier 代表每一轮延迟时间的翻倍系数,maxDelay 则限制单次最大延迟,防止指数爆炸。
如果说参数是“规定动作”,那配置这些参数背后的逻辑才是真正的“自选动作”。选哪些异常重试、选哪些不重试,本质上是在回答一个问题:当前场景里,什么失败原因值得我多花时间等下去?网络抖动、下游超时这类是值得等等的,因为恢复了就没事了;可业务校验不通过、数据格式错误这类,你再怎么试结果都一样,唯一的结局就是白白占着线程资源。我在实际项目里通常会把“可重试异常”控制在比较窄的范围内,优先考虑 Spring 自带的 RemoteAccessException,再根据下游具体实现补充各自的超时异常类型。做这个约定之前一定要和团队对齐,光靠个人经验很容易出现“A 觉得可以重试、B 觉得不能重试”的拉扯。
另一个容易被忽略的点是:@Retryable 默认是同步阻塞的。它对当前线程进行 sleep 式的等待,并不是异步任务。如果你在一个高并发接口里对每个请求都配置了多次重试,线程池可能在高峰期被打满,进而引发响应延迟。所以参数不是越大越好,maxAttempts 和 backoff 的组合需要结合下游的恢复时间和自身接口允许的耗时上限来综合评估。比如我遇到过一兄弟把 maxAttempts 配了 10 次、delay 配了 5 秒,结果单次请求最长要等 40 多秒,前端早超时放弃了,后端线程却被死死占住,最后靠扩大线程池才勉强缓解,这就属于没有结合实际场景拍脑袋配参数。
2.2 @Recover 注解的场景边界与匹配机制
@Recover 看起来只是一个普通的兜底注解,但它内部有一套严谨的匹配规则。简单说,当 @Retryable 方法经过指定次数的尝试后仍然失败,Spring Retry 会把最后一次抛出的异常交回容器,容器在同一个类里扫描被 @Recover 标记的方法,按参数类型匹配找到能处理当前异常的那个恢复方法,执行它并返回结果。这个兜底方法最关键的限制是:参数列表的第一个参数必须是被捕获的异常类型,剩余参数则要和原方法的参数列表保持一致。见下面这个示例:
@Recover public String recoverCallPaymentGateway(RemoteAccessException e, String orderId) { log.error("支付网关重试耗尽,订单号:{},异常:{}", orderId, e.getMessage()); // 可以做降级、告警、落库等操作 return "fallback"; }这里有两个常见的误解。第一个误解是“只要写了 @Recover 它就会自动执行”,实际上如果可重试异常被 noRetryFor 排除掉了,@Retryable 会在第一次失败后立即抛出异常,根本不会走到 @Recover 这一步。第二个误解是“多个 @Retryable 方法可以共用一个恢复方法”,从代码层面不是绝对不行,前提是异常类型与参数列表恰好能匹配上,但这样会让恢复逻辑的上下文变得很模糊,我个人的建议是每个方法配一个语义清晰的恢复方法,宁可多写几个也别偷懒写成一个万能恢复方法,不然日志里分辨兜底来源都得看半天。
要知道 @Recover 的“兜底”到底写在哪个位置,还得理解 Spring Retry 的拦截器调用链路。@Retryable 方法被调用时,Spring 通过 AOP 拦截器创建了一次 RetryTemplate 的执行过程,重试判断由 RetryPolicy 和 BackOffPolicy 共同决定,一旦 RetryPolicy 判定“不再继续”,异常会被抛给拦截器,拦截器再去容器里寻找 @Recover 方法。理解这个执行链路以后,很多令人困惑的行为就说得通了:为什么 @Recover 能在注解方法内部没有 catch 的情况下接住异常?因为异常根本没在原方法里被消化,而是沿 AOP 链路向上传递给了恢复处理器。
我刚开始用这套机制时犯过一个错,把恢复方法写在另一个类里,还特地用 @Service 注入进来。结果 Spring 容器里扫描不到这个恢复方法,重试耗尽后异常直接抛给了调用方,我盯着日志查了半天没明白错误出在哪。后来翻官方文档才注意到:恢复方法必须和被重试方法定义在同一个类中,且该类需要被 Spring 管理。原理上其实也好理解,@Recover 的解析依赖方法签名所在的上下文,跨类配置会让代理对象无法建立准确的映射。相比之下,如果你希望恢复逻辑更解耦,可以考虑直接使用 RetryTemplate 自行调用,或者利用默认的 @Recover 规则在同一个类内组合职责相近的多个重试方法。
3. 工程落地实操:Spring Boot 中从依赖引入到业务封装的完整步骤
3.1 环境准备与依赖引入细节
如果你用的是 Spring Boot 2.x 或 3.x,引入 spring-retry 依赖并不复杂,关键在于版本选择。Spring Boot 的 BOM 已经帮我们管理好了 spring-retry 的版本,所以在 pom.xml 里直接写即可:
<dependency> <groupId>org.springframework.retry</groupId> <artifactId>spring-retry</artifactId> </dependency>启动类或配置类上加上 @EnableRetry 注解,然后项目里所有用了 @Retryable 的 Bean 都会被自动代理。有一点值得提醒,Spring Boot 2.x 默认不引入 AOP 相关依赖,而 @Retryable 的底层实现强依赖 Spring AOP,所以建议同时引入 spring-boot-starter-aop:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>从 Spring Boot 2.6 左右的版本开始,如果少了这个 starter,运行时会报出无法找到 Advisor 相关类的异常。我个人通常会在依赖里同时看到 spring-retry 和 aop 两个身影才放心。对于只想快速尝试注解效果的新人,也别忘了 Spring Boot 的自动配置只负责扫描 Bean,并不会自动开启重试功能,这也就是为什么第一步必须是 @EnableRetry。
一个让很多同学困惑的问题是:为什么我引入了依赖、加了 @EnableRetry,但调用还是不会重试?这时请你先确认自己是不是在同一个类里“自调用”——也就是内部方法之间互相调用。比如 PaymentService 的 createOrder 方法里 this.callPaymentGateway(...) 这种写法,AOP 代理根本不会生效。因为 Spring AOP 的代理对象只能拦截外部对它的调用,类内部 this 调用直接走的是原始对象,拦截器无从插入。解决方式无非两种:要么把被重试的方法抽到另一个 Spring Bean 里再注入调用,要么用 AopContext.currentProxy() 获取当前代理对象进行调用。我更推荐前者,代码更清晰也更容易被同事看懂。
3.2 实操准备:先从一个库存扣减的模拟场景开始
为了更接近真实场景,我设计了一个微服务里非常常见的业务:调用库存服务扣减库存。这个接口偶尔会因数据库锁等待或网络超时而失败,所以我们希望它在遇到小幅波动时能自己恢复,同时在重试耗尽后能走一条降级路线,把失败信息记录下来。先定义一个实体和接口抽象:
public class InventoryResult { private boolean success; private int remainStock; private String message; // 构造器、getter/setter 省略 }然后定义库存服务客户端类:
@Component public class InventoryClient { private static final Logger log = LoggerFactory.getLogger(InventoryClient.class); @Retryable( retryFor = {RemoteAccessException.class}, noRetryFor = {InventoryIllegalStateException.class}, maxAttempts = 3, backoff = @Backoff(delay = 500, multiplier = 2) ) public InventoryResult deductStock(Long skuId, Integer count) { log.info("尝试扣减库存 skuId={}, count={}", skuId, count); // 这里模拟真实调用,可能抛出 RemoteAccessException return doRpcDeduct(skuId, count); } @Recover public InventoryResult recoverDeductStock(RemoteAccessException e, Long skuId, Integer count) { log.error("库存扣减重试耗尽,执行降级逻辑 skuId={}, count={}, error={}", skuId, count, e.getMessage()); return new InventoryResult(false, -1, "库存服务暂不可用,请稍后重试"); } private InventoryResult doRpcDeduct(Long skuId, Integer count) { // 模拟远程调用 if (skuId % 3 == 0) { throw new RemoteAccessException("库存服务连接失败"); } return new InventoryResult(true, 50, "扣减成功"); } }这个例子虽然简化,但已经覆盖了核心链路。如果 skuId 是 3 的倍数,首个调用会抛 RemoteAccessException,进入重试;重试两次后如果仍失败,@Recover 方法会被执行,返回一个降级结果给上层调用者。这里有个细节值得注意:InventoryIllegalStateException 被配置进 noRetryFor 后,即使这个异常继承了 RemoteAccessException,也会被排除出重试集合。这恰好说明 retryFor 和 noRetryFor 的优先级问题——noRetryFor 的判定在异常过滤中拥有更高优先级,它会先于 retryFor 做白名单排除。Spring Retry 内部会先将方法抛出的异常与 noRetryFor 列表比对,命中即立刻放弃重试,然后才看是否匹配 retryFor。理解这个顺序能帮你规避一些“我明明重试了怎么没走 backoff”的迷惑。
3.3 恢复方法的返回值、参数与业务约定
在业务类里定义 @Recover 方法时,要注意它的参数设计是与原方法参数一一对应的。Spring Retry 从方法签名上定位恢复方法,除了第一个异常参数外,其余参数的顺序、类型必须和 @Retryable 方法保持一致。这会带来一条隐性的约定:你在写原方法参数时,要尽量让入参具备“可以组装兜底上下文”的信息,比如订单号、商品 ID、用户 ID。否则恢复方法里想要记录一条有意义的日志,你会发现手头什么业务标识都没有,只能记一个笼统异常类型。我在设计上面的示例时,特意传了 skuId 和 count,就是为了让降级日志可以直接串起单据信息。
还有一个返参类型问题。@Recover 方法的返回值类型必须跟 @Retryable 方法一致。你可以想象一下,调用方并不知道你这个方法内部重试过多少次,它只希望拿到一个类型安全的返回结果,如果恢复方法返回类型不一致,轻则抛类型转换异常,重则直接破坏接口契约。曾经有同事为了省事在恢复方法里直接返回 null,短时间看不出问题,但下游拿到 null 后 NPE 接连出现,这种坑排查起来非常费劲。正确做法是尽量返回一个包装好的降级结果对象,体现业务语义而非简单的空值。
关于@Recover 的兜底日志,我的习惯是不止记录异常栈,还要记录当时的调用参数、耗时和重试次数。很多时候重试后的失败原因比首次失败更有诊断价值,比如第一次是短暂网络超时,第二次变成了下游服务直接拒绝连接,这中间的变化恰恰暴露了下游的健康状态。如果只记录最终异常,这类转发细节就完全丢失了。针对这个需求,Spring Retry 官方其实提供了 RetryContext 里的内部状态,但注解方式拿不到上下文,所以我通常会把关键实参拼进日志,再用 MDC 塞一个 traceId,便于全链路检索。这也是我强调恢复方法别只写一句话的原因——它是问题根因的最后一道防线。
4. 提升业务的容错深度:RetryTemplate、BackOff 策略与重试治理
4.1 为什么推荐在复杂场景中使用 RetryTemplate
@Retryable/@Recover 这对注解适合大多数常规场景,但当你需要更灵活的控制力时,直接使用 RetryTemplate 反而更顺手。比如,重试策略可能依赖上游返回的某种特殊状态码,或者重试过程中想把每次失败的业务指标打点统计出来,注解的声明式风格就有点力不从心了。我们在项目中经常用 RetryTemplate 处理这一类“带状态感知”的重试逻辑,它把重试策略、退避策略和恢复动作拆分成多个可组合的组件,你可以随时在代码里调整参数,不用维护一堆分散的注解属性。
用 RetryTemplate 实现同样功能的方式是:显式构造 RetryTemplate,设置 RetryPolicy 和 BackOffPolicy,再调用 execute 方法。看一个例子:
@Component public class OrderClient { @Bean public RetryTemplate retryTemplate() { RetryTemplate template = new RetryTemplate(); // 只对特定异常重试,最多尝试 4 次 SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(4, Collections.singletonMap(RemoteAccessException.class, true)); template.setRetryPolicy(retryPolicy); // 指数退避:初始 1 秒,每次乘以 2,最大 8 秒 ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy(); backOffPolicy.setInitialInterval(1000); backOffPolicy.setMultiplier(2.0); backOffPolicy.setMaxInterval(8000); template.setBackOffPolicy(backOffPolicy); // 设置一个监听器,便于记录每次重试的情况 template.registerListener(new RetryListener() { @Override public <T, E extends Throwable> boolean open(RetryContext context) { return true; } @Override public <T, E extends Throwable> void onError(RetryContext context, E throwable) { log.warn("第 {} 次重试失败:{}", context.getRetryCount() + 1, throwable.getMessage()); } @Override public <T, E extends Throwable> void close(RetryContext context, E throwable, T result) { // 结束时可根据 throwable 或 result 判断是否重试成功 } }); return template; } }RetryTemplate 通过 RetryListener 能拿到每次尝试的细节状态,包括 RetryContext 里的重试次数、抛出的异常,甚至可以控制 open 方法向后传递的条件。如果你需要把每次重试的耗时常驻到监控面板,注解方式是做不到的,但模板方动就这么简单。另外一个差异点是:RetryTemplate 的 execute 是编码式调用,异常处理完全由你控制,不需要依赖 Spring 的异常翻译器去黑盒操作,排错时思路会清晰很多。
对我来说,选注解还是选模板,主要看重试逻辑的“可变复杂度”。如果一个方法只有“失败了重试几次”这种简单诉求,注解是最佳解;如果重试过程中还需要配合告警、动态参数、跳过某次尝试、集成自定义监听器这些复合需求,模板反而更省心。团队里做技术方案时,我通常会给一个比较粗的规则:凡是重试参数不会频繁变动的入口方法,用注解;凡是重试行为要接入内部监控或带有动态获取 token 这类现场逻辑的,用模板。
4.2 BackOff 退避策略:不同策略的取舍与参数计算
backoff 是重试机制里最容易被轻视却最影响效率的部分。请求失败后的下一次尝试不是越快越好,而是要结合下游恢复时间的经验值设计。Spring Retry 提供了三种主要策略:FixedBackOffPolicy(固定延迟)、ExponentialBackOffPolicy(指数延迟)和 ExponentialRandomBackOffPolicy(指数+随机抖动)。
先说固定延迟,它适合下游故障恢复时间相对稳定的场景,比如数据库连接池被临时打满,通常几百毫秒就能恢复。延迟时间可以这样估算:假设下游平均恢复时间为 500 毫秒,要让 90% 的异常情况都能在第二轮被消化,delay 设置为恢复均值的 1 到 1.5 倍较为合适,也就是 500 到 750 毫秒。太短会导致重试请求在下游还没缓过劲时就再次冲击,太长则浪费宝贵的接口时延预算。其次是指数退避,适用于不确定故障时间窗口的场景,比如第三方 API 不稳定时,先等 1 秒,再等 2 秒、4 秒、8 秒,以倍数递增。指数退避的核心假设在于:如果下游故障不是瞬时的,短时间内连续重试大概率都还会失败,所以间隔越拉越大,避免对下游形成持续的流量压力。
带随机抖动的指数退避则是为了防止“惊群效应”。比如你同时有一百个请求都在做指数退避,第一轮延迟后可能约等于同一时刻发起下一轮重试,这会让下游服务刚喘口气又被流量拍晕。ExponentialRandomBackOffPolicy 在每个延迟时间上叠加一个随机偏移量,使得触发重试的请求分散开。我服务的系统在下游是集群节点时,通常会适量混入随机值,实测下来高峰期下游的 5xx 错误率下降了不少。用代码配置也就是一行 setter 的事:
ExponentialRandomBackOffPolicy policy = new ExponentialRandomBackOffPolicy(); policy.setInitialInterval(1000); policy.setMultiplier(2.0); policy.setMaxInterval(30000);再补充一句参数计算经验:maxAttempts 和 backoff 的总耗时一定要小于调用方允许的超时时间。假设网关超时是 5 秒,你配置了 4 次尝试、初始延迟 2 秒、倍数 2,那么最坏情况耗时为 2 + 4 + 8 = 14 秒,显然远远超出请求预算。这种情况下必须调小延迟或减少尝试次数。更稳妥的做法是再配一个 TimeoutRetryPolicy 或使用带超时控制的 RestTemplate,让重试在上层超时前及时收手,否则重试反而会拖垮整个接口的响应能力。关于这个计算步骤,其实可以用一张表格简单对比一下:
| 策略 | 使用场景 | 常见参数参考 | 注意点 |
|---|---|---|---|
| FixedBackOffPolicy | 下游恢复时间稳定 | delay=500~1000ms | 如果恢复时间波动大,容易频繁失败 |
| ExponentialBackOffPolicy | 不确定故障窗口 | initial=1000ms, multiplier=2.0 | 总耗时需按等比数列估算,防止超预算 |
| ExponentialRandomBackOffPolicy | 高并发、集群下游 | initial=1000ms, multiplier=2.0, max=30s | 随机因子能有效打散扎堆请求 |
4.3 用重试拦截器和指标埋点看清楚重试行为
很多团队把 @Retryable 接进系统后,完全不清楚重试到底发生过多少次、在哪些接口上发生、每次恢复耗时几何。这其实丧失了最重要的可观测性。Spring Retry 在注解方式下提供一个扩展切入点:自定义 RetryListener 并注入到容器中,配置在 RetryTemplate 里可以被全局感知。但如果是注解驱动的默认重试,想拿到回调,常规做法是利用监听器工厂或者直接注册一个全局拦截器。
我这边常用的方案是在系统里统一封装一个 RetryListener 工厂类,把日志和指标全部收敛到一个地方。比如借助 Micrometer 给重试事件打 counter,标记 tags:接口名称、异常类型、是否最终失败。这样后续做 Grafana 监控时,一眼就能看出”哪个接口重试次数异常攀升“。代码层面类似这样:
@Component public class RetryMetricListener implements RetryListener { private final MeterRegistry meterRegistry; public RetryMetricListener(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; } @Override public <T, E extends Throwable> void onError(RetryContext context, E throwable) { meterRegistry.counter("retry.attempts", "method", context.getAttribute("retry.method") == null ? "unknown" : context.getAttribute("retry.method").toString(), "exception", throwable.getClass().getName() ).increment(); } }这个思路的价值在于,重试机制不应该是黑盒。线上真正出现问题的时候,你需要迅速区分是“重试不足导致失败”还是“重试过多导致过载”。有了指标以后,判断就变成了数据说话。比如下游开始抖动时,重试次数本身成为一个敏感信号:如果单接口在几分钟内重试次数飙升,多半是下游健康状态出问题了,应该触发告警让值班人介入。相反,如果重试次数很低但最终失败率很高,则说明异常类型可能没有正确命中 retryFor,需要检查配置。这里顺带补充一个通过指标排查问题的思路:不能只看最终异常,还要关注重试链路中的中间异常分布,它们往往比结果更能说明问题。
5. 高频问题与避坑指南:这些年我踩过的重试“雷区”
5.1 雷区一:@Recover 不生效,异常直接抛给调用方
遇到@Recover 不生效时,先检查恢复方法是否和原方法在同一个类中。我之前踩过的最隐蔽的一次,是同事把 @Retryable 方法写在了一个类里,把 @Recover 方法写在了该类的私有内部类中,结果运行时恢复逻辑从未被触发。另外检查一下恢复方法是否被正确的 Spring 代理拦截到——如果调用方绕过 Spring 容器,直接 new 了一个业务对象,那别说 @Recover 了,@Retryable 也不会生效。再然后看异常类型:恢复方法的第一个参数类型必须匹配重试最终抛出的异常类型,精确匹配或父类匹配都存在,但尽量不要用 Exception 做万能兜底,不然你无法在不同异常上构建差异化的降级逻辑。最后检查是否因为 noRetryFor 把异常排除后直接抛出了,这种情况下,异常在第一次失败时就脱离了重试流程,@Recover 自然无法介入。
我给团队成员做审查时有个固定问题问得最多:“你这个重试方法真的完成了三次调用,还是只做了一次就进兜底?”很多人在重试方法内部自己 catch 了异常并做了转换,把 RemoteAccessException 转为 BizException 后抛出,结果导致 retryFor 无法匹配到预期异常,重试直接形同虚设。这里的关键纪律是:重试方法内部不要捕获异常后自行包装,让异常沿着调用栈自然往外抛,交给 Spring Retry 的拦截器去判断。如果确实需要包装,记得把包装后的异常类型也放进 retryFor 列表。
5.2 雷区二:没考虑重试的幂等性,造成重复扣款或重复入账
重试机制本质上会让同一个操作执行多次,因此被重试的方法必须满足幂等性要求。举个最典型的例子,你的业务代码里如果先“检查余额”再“扣款”,重试并发时可能两次请求都通过了检查,最后扣了两次款。我在设计库存扣减案例时,特意强调 doRpcDeduct 需要具备业务幂等性:传入事务消息的唯一业务 ID,每次调用先把 ID 写入去重表,如果已存在就直接返回上次的扣减结果。这里的核心原则是:重试只能重放请求,不能重放副作用。
具体的实现思路有很多,比如在数据库层面增加唯一约束,或者用 Redis SETNX 做请求去重。但要注意,重试时的幂等和分布式事务的幂等还不完全一样。@Retryable 的重试粒度是一个方法,它的外部并没有统一的事务边界,所以不要在方法内部直接开一个事务并期待回滚能力。假设你的扣减方法内部已经执行了 update 语句,随后调用远程发送 MQ 失败触发重试,方法再次进入时第一次的数据库修改可能已经提交了。遇到这种情况,我的选择是给整个处理逻辑加一个业务级的状态机,从“初始”到“已扣减”再到“已通知”,重试前先查状态,命中终态就直接返回成功。
5.3 雷区三:重试导致线程池耗尽和线程阻塞压倒服务
重试本身是占用当前线程的,在高并发服务里一次重试可能让一个线程多存活几秒,QPS 上来以后线程池很容易被打满。我遇到过某核心接口在高峰期突然 RT 飙升,去查线程栈发现大量线程都阻塞在 RestTemplate 的调用等待上,细看每一行都带着重试链路,这就是典型的“重试风暴”。规避方式除了上面说的缩短退避时间外,还可以借助异步化隔离重试线程,比如把 @Retryable 方法放入独立的线程池执行,重试等待不再占据请求线程。但异步又会引入结果回调的复杂性,所以我的经验是先估算并发峰值下的最大占用线程数,如果只是偶发故障场景,同步重试还是能接受的;如果高频、高并发,最好改造为 MQ 异步消费加重试。
另外需要留意的是超时时间与重试耗时的叠加效应。假设你用了 OpenFeign 调用下游,Feign 默认连接超时为 10 秒,读取超时为 60 秒,一旦下游卡住,单次调用就会消耗很长时间,若再配置多轮重试,整个请求的时间线会被拉得极其恐怖。处理思路是优先调低下游调用的超时时间,让重试在更小的时间片内完成。同时可以结合 @Recover 做快速失败降级,避免调用方无限等待。
5.4 排查技巧速查表:一眼定位重试配置问题
下面这张表是我在团队wiki里维护的速查表,遇到重试异常时先按这个顺序排查,能省掉很多翻阅源码的时间:
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 注解写了但完全不重试 | 缺少 @EnableRetry;方法未被 Spring 代理;自调用 | 加开关;确认 Bean 被容器管理;拆类或 AopContext |
| 重试后异常直接抛给上层 | @Recover 不存在或不被识别;异常类型不匹配 | 检查恢复方法位置、参数顺序、异常类型 |
| 无延迟地疯狂重试 | BackOff 策略未配置或配置错误 | 检查注解 backoff 参数;模板里检查 BackOffPolicy |
| 重试次数超过预期 | maxAttempts 理解有误(含首次调用) | 确认计数规则;按业务需要缩小次数 |
| 重试过程中产生了重复数据 | 未做幂等处理 | 增加唯一约束或状态机;在重试入口做去重 |
| 线程池阻塞,接口 RT 飙升 | 同步重试耗时过长或重试风暴 | 缩短退避时间;降级为异步重试;设置外部超时 |
| 某些异常不该重试却一直在重试 | retryFor/noRetryFor 配置不完整或异常类型过泛 | 精确配置异常类型;将已知业务异常加入 noRetryFor |
这个表我一直建议团队贴在项目文档里,比看长篇大论顺手得多。要知道重试机制本身不复杂,但一旦在生产环境出问题,定位的成本往往很高。把这些常见坑提前固化下来,也算是一种团队资产。
5.5 补充一条:动态重试参数与配置中心的联动
最后再分享一个稍微进阶一点的技巧。现实场景中,重试参数很少有真正一成不变的。比如下游服务稳定性变差时,你可能想让 maxAttempts 从 3 变成 5;又比如大促期间你希望延迟时间缩短以换取吞吐量。这时候注解上的常量就无法满足动态需求了。我个人会在项目中把重试参数抽成一个专门的配置类,通过 ConfigurationProperties 从配置中心读取,并在使用时构造 RetryTemplate:
@ConfigurationProperties(prefix = "app.retry") public class RetryProperties { private int maxAttempts = 3; private long initialDelay = 1000; private double multiplier = 2.0; // getters and setters }然后封装一个工厂方法,每次构建新的 RetryTemplate 时把配置中心的最新值传入。这种方式配合 Nacos 或 Apollo,可以做到不重启服务实时调整重试力度。相比注解方案,它虽然在代码上多了一点点模板味道,但换来的是运维层面的灵活度。如果你所在的团队对参数调优频率较高,值得往这个方向演化。
6. 写在最后:重试是保障,但绝不是万能药
我见过不少团队把 @Retryable 当成一把万能钥匙,任何下游报错都想着重试几下解决,这是非常危险的思路。重试只能掩盖一部分暂时的故障,如果下游已经进入持续不可用状态,反复重试只会加剧资源消耗,甚至让系统进入到“愈重试愈崩溃”的恶性循环。重试必须配合熔断、限流、降级这整套容错手段一起使用,才能既保证偶发故障时的自愈能力,又在持续性故障时快速止损。以我在生产环境摸爬滚打的经验,这个组合的价值远超其中任何一个单独组件。
@Retryable 与 @Recover 虽然只是两支注解,但它们的完成度还是很高的。用好了,代码里几乎看不到任何重试逻辑的痕迹,业务方法保持干净,容错优雅地埋藏在 AOP 链中;用歪了,则可能把简单的调用变得错综复杂,甚至让你的系统在故障时表现得比没有重试更糟糕。建议你在自己的项目里先挑一个“低风险但容易抖动”的下游接口练手,把参数开小,观察指标,逐步调优,再推广到核心链路上。这种渐进式的落地方式,比一上来就给所有接口加上重试要稳妥得多。
我的日常实践里还有一个小习惯:每次为服务接入新的下游渠道时,先问三个问题——这个失败是否值得重试?重试的最大总耗时是否在预算内?重试时是否会产生业务副作用?这三个问题过完脑子后,再决定用注解还是模板、配几次重试、要不要自定义恢复逻辑。做技术方案时多留一分克制,线上就能少一分慌乱。希望这篇基于真实踩坑的分享,能帮你少走一些弯路。