news 2026/10/15 16:49:12

Spring @Retryable注解实现重试:退避策略、异常过滤与兜底方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring @Retryable注解实现重试:退避策略、异常过滤与兜底方案

作为后端开发,我几乎每天都在和各种“偶发失败”打交道:第三方接口超时、数据库偶发死锁、网络抖动返回个5xx。这种问题最磨人,你重跑一遍往往就成功了,可用户那次请求已经失败了。手写for循环重试?我在不少项目里见过,也自己写过,但越写越觉得不对劲。后来真正吃透了spring Retryable注解实现重试这套方案,才意识到声明式重试远不只是“加个注解”那么简单:退避策略怎么配、哪些异常需要重试、重试全部失败后怎么兜底、和事务放在一起会不会冲突,这些才是生产环境能不能放心用的关键。这篇文章我就围绕spring Retryable注解实现重试这件事,把从依赖引入到参数配置、从实战案例到常见坑点一次讲清楚,适合正在用Spring Boot写后端接口、又不想为了一点抖动就引入重中间件的同学参考。

1. 业务代码里最常见的手写重试为什么总让人不放心

1.1 那些看起来能用的 for 循环重试

我接手过不少老项目,里面最典型的重试写法长这样:

public OrderState queryOrder(String orderId) { int maxRetry = 3; for (int i = 0; i < maxRetry; i++) { try { return paymentClient.query(orderId); } catch (Exception ex) { if (i == maxRetry - 1) { throw ex; } try { Thread.sleep(500); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new RuntimeException(ie); } } } return null; }

这段代码确实能让一部分偶发失败被掩盖过去,但问题非常明显:sleep时间是写死的,没有退避概念;重试循环和业务代码混在一起,方法越写越长;异常被吞掉又抛出来,堆栈信息往往指向重试代码本身而不是真正的故障点;最致命的,是整个重试过程没有任何记录,线上出了问题你只能看到最后一次异常,前面几次为什么失败、隔了多久重试的,全是黑盒。

我在审查某个交易查询模块时就碰到过这种写法,表面上超时率确实降了一点,但每次排查都要靠猜,时间成本翻了好几倍。后来我意识到,手写重试的人不是偷懒,而是“跳过设计直接写代码”——他默认只要重试就能解决问题,却没想过重试本身也是一套需要设计的技术方案。

1.2 手写循环缺失的三个关键设计维度

要理解重试的设计重点,你得先接受一个观念:重试本质上是一种让步策略,目的是给瞬时故障一个恢复窗口。既然是让步,就要回答三个问题:让多久、让几次、让什么异常让步。

  • 让多久:即延迟策略。没有退避策略的话,每次重试都在同一时间点发起,如果下游刚好在重启窗口里,你连续3次请求大概率都会撞在故障期上,不仅浪费,还会给下游增加压力。正确做法是按指数退避拉大间隔,给下游留出恢复时间。
  • 让几次:即重试上限。上限设多少不是拍脑袋的,要看下游的平均恢复时间和业务对延迟的容忍度。后面我会专门聊配置思路。
  • 让什么异常:即异常过滤。这是手写循环里最容易被忽略的。参数校验错误、业务状态冲突这类异常,你重试100次也是同样的结果,只会把接口响应时间人为拖长三倍,还可能在日志里刷出一堆无意义的报错。

这三个维度,手写for循环基本只能靠程序员当时的临时感觉,每个人写出来都不一样,换个人维护更是灾难。而声明式重试的价值,就是把这些策略集中、显式地描述出来。

1.3 声明式重试带来的思维转变

把重试策略从业务代码里剥离出来,思维层面会发生一个变化:业务方法只关心自己该做的事,重试是方法之外的一层“加固”。你可以在一个注解里同时回答“让多久、让几次、让什么异常”,而且这个配置是统一的、可审查的。

不过我也得先把丑话说在前面:注解不是万能的,它基于Spring AOP,天然有代理边界,后面的坑基本都从这里来;它也不是消息队列里的可靠投递,重试次数耗尽之后仍然需要你的业务兜底。想清楚这两条,再去动手配,心里就有底了。

2. Spring Retry 的激活方式:依赖、开关与自动配置

2.1 依赖坐标与版本匹配

用Maven的话,最少需要加两个依赖:

<dependency> <groupId>org.springframework.retry</groupId> <artifactId>spring-retry</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>

Gradle对应这样写:

implementation 'org.springframework.retry:spring-retry' implementation 'org.springframework.boot:spring-boot-starter-aop'

有人可能不理解为什么非要aop这个starter。因为Spring Retry本身是基于Spring AOP动态代理实现的,它通过一个方法拦截器把“重试逻辑”织入到被@Retryable标注的方法外面。没有AOP能力,拦截器就织不进去,注解自然不生效。这是理解后面一切代理问题的基石。

版本上,Spring Boot 2.x和3.x项目都可以直接用Spring Retry 2.x;具体到某个版本建议以Spring Boot的依赖管理为准,不要手动写死版本号。常见的依赖冲突症状是启动报NoClassDefFoundError,如果你同时引了多个版本的spring-retry,先检查依赖树,而不是去改业务代码。

2.2 别忘了 @EnableRetry 这个开关

依赖加好了,注解也标了,结果发现一点反应都没有。我遇到过好多次这种问题,最后原因都一样:启动类上漏了@EnableRetry。

@EnableRetry @SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }

@EnableRetry的作用是向容器注册一个Advisor,Spring才会去扫描被@Retryable标注的方法并生成代理。即使Spring Boot对spring-retry有自动装配,我也建议你显式把这个开关打开。把它当成一种约定俗成的显式声明,写在启动类或专门的配置类上,后面接手项目的人一眼就能看到“这里用了重试”。

顺便提一句,如果你的场景不想用注解,希望完全走编程式重试,可以直接注入RetryTemplate:

@Service public class DemoService { private final RetryTemplate retryTemplate; public DemoService(RetryTemplate retryTemplate) { this.retryTemplate = retryTemplate; } public OrderState query(String orderId) { return retryTemplate.execute(context -> paymentClient.query(orderId)); } }

Spring Boot检测到classpath里有spring-retry时,通常会帮你装配一个默认的RetryTemplate。在下面的表格里,我按实际使用经验给出注解和模板的选择建议。

比较维度@Retryable 注解RetryTemplate
使用方式声明式,策略写在方法上,直观编程式,策略在运行时组装,灵活
适用场景重试策略相对固定、方法边界清晰重试次数/延迟需要动态决定、或者需要很复杂的组合策略
可读性高,一眼看出哪些方法可重试一般,重试逻辑散落在执行处
推荐程度绝大多数业务场景推荐框架封装、中间件场景推荐

你只需要记住:注解最终也是通过RetryTemplate的底层机制来工作的,只不过把配置前移到了注解里。别把两者对立起来。

2.3 注解开关背后的AOP原理简介

@EnableRetry注册的Advisor会匹配所有带有@Retryable的方法。Spring AOP对这类Bean生成代理对象:如果你用的是JDK动态代理,代理对象实现接口;如果用的是CGLIB,代理对象是目标类的子类。外部Bean调用你的方法时,实际调用的是代理对象的方法,拦截器链把重试逻辑包在外面。

这里有个很容易混淆的点:只有当调用方持有的是“代理对象”时,重试才会生效。如果方法内部用this直接调用自身,或者静态方法、private方法,代理根本没机会介入。第5章我会用一个完整小节讲这个坑,这里先埋个伏笔。

3. @Retryable 参数拆解:maxAttempts、backoff 与异常过滤

3.1 核心属性总览

@Retryable注解的属性不算多,但每个都有讲究。我把最常用的整理成表格:

属性作用默认值备注
maxAttempts最大尝试次数(含首次)3设为4表示首次+3次重试
retryFor触发重试的异常类型空(默认对Exception重试)建议显式指定
noRetryFor排除哪些异常不重试空优先于retryFor
backoff退避策略配置@Backoff里面又有delay、multiplier、maxDelay
listeners指定重试监听器Bean名称空用于观测每次重试
recover兜底方法名空通常直接用@Recover注解替代
exceptionExpressionSpEL表达式按异常信息过滤空适合第三方异常消息里带业务错误码的场景

注意,很多老教程里的include/exclude属性已经被弃用了,新版本推荐使用retryFor/noRetryFor。代码里如果看到include/exclude,说明是较老版本的写法,功能上可能兼容,但新代码建议直接换成新写法。

这里还想强调一个容易踩的坑:retryFor不配置时,默认会对Exception及其子类重试。这个范围实在太大,相当于告诉系统“除了Error以外什么错都值得再试一次”。实际项目中我建议一定要显式收敛到那些“值得再试”的异常上。

3.2 固定延迟与指数退避,怎么选

backoff是重试策略的核心,直接决定“让多久”。看一个完整示例:

@Retryable( retryFor = {TimeoutException.class, RemoteAccessException.class}, noRetryFor = {IllegalArgumentException.class}, maxAttempts = 4, backoff = @Backoff(delay = 500, multiplier = 2, maxDelay = 8000) ) public OrderState syncOrderState(String orderId) { return paymentClient.queryOrder(orderId); }

这个配置的含义是:最多尝试4次,首次失败后等500ms再试第2次,第2次失败后等1000ms,第3次失败后等2000ms……但单次间隔最长不超过8000ms。指数退避的计算逻辑是:第N次重试前等待的时间等于delay乘以multiplier的N-1次方,再和maxDelay取较小值。

那什么时候用固定延迟,什么时候用指数退避?我的经验是:如果是数据库死锁、分布式锁短暂冲突这类秒级恢复的故障,固定延迟就够了;如果是下游服务重启、网络分区恢复这类可能需要几十秒甚至更久的故障,指数退避更合理。因为均匀重试很容易让你每次都撞在故障窗口上,而指数退避会逐渐“远离”故障时刻,给系统一个相对从容的恢复时间。

不过指数退避也有代价:等待时间可能很长,调用方的线程会被占住。所以maxDelay必须设,防止极端情况下重试一次要等几分钟。如果你担心的是高并发场景下的流量集中,还可以在@Backoff里加上random属性,让延迟在一个范围内抖动,避免大量请求同时发起重试。

3.3 异常过滤的常见误区与 SpEL 表达式

异常过滤这块,最常见的两个误区:

第一个误区是retryFor配得太宽。有人直接写retryFor = Exception.class,结果参数错误、业务校验失败、状态冲突这些永久性异常全部被重试,接口延迟被拖长好几倍,日志里全是无意义的重复异常。正确姿势是只列出“可能因为瞬间抖动而恢复”的异常:SocketTimeoutException、ConnectException、RemoteAccessException、部分DataAccessException等。

第二个误区是只靠异常类型过滤,没有按异常内容做更细的判断。比如第三方接口返回时,可能把业务错误码放在异常消息里,其中某个错误码代表“服务暂不可用”,值得重试;另一个错误码代表“订单状态非法”,重试也没用。这种时候可以用exceptionExpression:

@Retryable( retryFor = RuntimeException.class, exceptionExpression = "message != null && message.contains('TRY_LATER')", maxAttempts = 3, backoff = @Backoff(delay = 1000) ) public OrderState syncOrderState(String orderId) { return paymentClient.queryOrder(orderId); }

exceptionExpression是基于异常对象的message做SpEL判断,不是对堆栈全文判断。你可以在本地单元测试里先验证表达式的计算结果,再放到线上。这种精细化过滤,往往能避免一大半无意义的重复请求。

4. 实战案例:第三方订单状态接口超时的重试设计

4.1 场景切入与主配置

假设你现在有一个订单状态同步服务,核心逻辑是调用第三方支付平台的查询接口,把最新订单状态同步回来。真实环境里这个接口偶尔会超时,超时时间从几百毫秒到几秒不等,但不是每次都会失败。这种场景就非常适合@Retryable。

我把重试的代码单独抽成一个方法,放在专门调外部接口的Bean里:

@Component public class PaymentClientWrapper { private final PaymentClient paymentClient; public PaymentClientWrapper(PaymentClient paymentClient) { this.paymentClient = paymentClient; } @Retryable( retryFor = {SocketTimeoutException.class, RemoteAccessException.class}, noRetryFor = {IllegalArgumentException.class}, maxAttempts = 4, backoff = @Backoff(delay = 500, multiplier = 2, maxDelay = 6000) ) public OrderState syncOrderState(String orderId) { return paymentClient.queryOrder(orderId); } }

为什么这么配?maxAttempts=4,意味着首次请求加3次重试。对于一个查询类接口,4次尝试通常已经是合理上限,再多的话,单次请求的理论耗时可能要到十几秒,业务上很难接受。delay=500起步,是因为支付平台的超时原因通常是网络瞬断,500ms是一个不算长但也不至于太过密集的恢复窗口。multiplier=2的指数退避,让后续重试逐渐拉开距离。maxDelay=6000防止间隔无限拉大。

4.2 @Recover 兜底方法:重试耗尽后的最后防线

重试次数耗尽后,如果不做任何处理,异常会原样抛给上层。更好的做法是提供一个@Recover方法,在重试全部失败后执行降级逻辑。

@Component public class PaymentClientWrapper { @Retryable( retryFor = {SocketTimeoutException.class}, maxAttempts = 3, backoff = @Backoff(delay = 500, multiplier = 2) ) public OrderState syncOrderState(String orderId) { return paymentClient.queryOrder(orderId); } @Recover public OrderState recoverSyncOrderState(SocketTimeoutException ex, String orderId) { log.error("订单状态同步重试耗尽,orderId={}", orderId, ex); return OrderState.unknown(orderId); } }

@Recover有几个硬性规则,写错了一个都可能不生效:

  • 方法必须和@Retryable在同一个类中(较新版本支持外部类,但默认行为仍是同类,我建议保持同类最简单)。
  • 第一个参数是重试方法可能抛出的异常类型;如果重试方法声明了retryFor多个异常,就要写多个@Recover方法,每个方法对应一个异常类型。
  • 返回类型必须和原方法一致。
  • 参数顺序、类型和原方法保持一致,只是多了一个异常参数在最前面。

我见过有人把@Recover当成万能catch,在方法里悄悄返回一个默认对象然后什么都不记。这样做的隐患是:调用方以为数据是正常的,实际上订单状态可能是未知的,下游业务再用这个状态去做判断就会出大问题。我的建议是:兜底方法里至少要做三件事——记录异常和重试次数、返回一个明确暴露“本次未成功”语义的结果、把这类失败落到一个可以人工补偿的存储里。

4.3 监听器:让每一次重试都变得可观测

生产环境里,重试不能闷头执行,你得知道它重试了几次、每次失败原因是什么。Spring Retry提供了RetryListener接口,我通常在项目里实现一个日志监听器:

@Component("loggingRetryListener") public class LoggingRetryListener implements RetryListener { private static final Log log = LogFactory.getLog(LoggingRetryListener.class); @Override public <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) { return true; } @Override public <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) { String methodName = context.getAttribute("methodName") == null ? "unknown" : context.getAttribute("methodName").toString(); log.info("方法[{}]第{}次尝试失败,异常信息:{}", methodName, context.getRetryCount(), throwable.getMessage()); } @Override public <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) { if (throwable == null) { log.info("方法重试后成功执行,最终重试次数:{}", context.getRetryCount()); } } }

然后在注解上通过listeners属性指定这个监听器:

@Retryable( retryFor = SocketTimeoutException.class, maxAttempts = 4, backoff = @Backoff(delay = 500, multiplier = 2, maxDelay = 6000), listeners = "loggingRetryListener" ) public OrderState syncOrderState(String orderId) { return paymentClient.queryOrder(orderId); }

这里要注意listeners的值是Bean名称,不是类型,写错了会在运行时找不到Bean。如果想在监听器里拿到具体的业务参数,可以通过上下文attributes传递,或者干脆在日志里只记录方法名和异常信息,这通常已经够用。

如果你用了Micrometer这类监控指标库,还可以在onError里直接累加一个计数器,比如按方法名打tag:

Metrics.counter("retry.attempt", "method", "syncOrderState").increment();

这样重试数据就进入到监控系统,后面可以根据指标调参。

4.4 别忘了幂等:重试是重复执行,不是补偿机制

这是我最想强调的一点。重试意味着同一个请求可能会被发送多次,如果你的方法不只是查询,还涉及写操作、扣减库存、发送消息,那么重复执行就可能造成重大事故。

我曾经在一个优惠券发放模块里见过这种情况:发放逻辑挂在同步接口上,接口偶发超时后丢进重试,结果同一张券被发了好几次。后来加了一幂等键也没用,因为发的不是请求而是一整段写逻辑。

所以,在使用@Retryable之前,先确认这个方法的操作是幂等的。查询接口天然幂等,这点没问题;但写接口最好做到:请求带唯一业务幂等键、数据库对关键业务字段建立唯一索引、执行前先做状态机判断。如果这些条件不满足,我宁可不重试,而是把失败信息扔进消息队列里的重试表,由后台任务补偿。

5. 自调用失效、事务裹挟与代理边界:三个高频问题

5.1 同一个类里 this 调用为什么不会重试

这个坑我敢说十个人里至少六个人踩过。看这段代码:

@Service public class OrderSyncServiceImpl { @Retryable(retryFor = SocketTimeoutException.class, maxAttempts = 3) public OrderState syncOrderState(String orderId) { return paymentClient.queryOrder(orderId); } public void doSync(String orderId) { // 这里通过 this 调用,@Retryable 不会生效 this.syncOrderState(orderId); } }

原因就是Spring AOP代理。你用@Autowired注入OrderSyncServiceImpl时,拿到的是代理对象;但在方法内部用this调用syncOrderState,this是原始对象本身,不是代理。拦截器看到的是普通方法调用,直接执行了原始逻辑,重试自然不触发。

解决办法有三种,我按推荐程度排序:

  1. 把被重试方法挪到另一个Bean里,在需要重试的类中注入这个Bean,通过Bean调用代理方法。这是最清晰的方式。
  2. 往当前类注入ApplicationContext,用context.getBean(OrderSyncServiceImpl.class)获取代理对象再调用。
  3. 用AopContext.currentProxy(),但需要在启动类或配置里开启exposeProxy,而且代码里到处是AopContext也不太优雅。

真实项目里我几乎只用第一种方案。重试作为独立的横切关注点,把它隔离在专门的对象里,既解决了代理问题,也方便以后把这个对象换掉或者加Mock。

5.2 @Transactional 和 @Retryable 同框时的事务语义

这两个注解都是通过AOP实现的,一旦放在同一个方法上,事情会变得非常微妙。我先明确表达观点:不要轻易把@Transactional和@Retryable堆在同一个方法上。

问题出在advisor的执行顺序。假如执行顺序是“事务拦截器在外、重试拦截器在内”,第一次执行时事务已经开启,方法内抛了异常,事务被标记为rollback-only,但是异常被重试拦截器拦截,开始第二次调用。第二次调用时,事务拦截器如果检测到上下文存在一个rollback-only标记的事务,可能直接抛UnexpectedRollbackException,重试后的结果仍然不干净。假如顺序反过来,重试拦截器在外、事务拦截器在内,每次重试等于新开一个事务,但同一个方法内部连续执行多次写操作,符合你的业务预期吗?如果方法逻辑本身就是“扣库存、写订单、调外部接口”,重试一次就多扣一次库存。

最稳妥的设计原则是:重试和事务各自管好自己的一亩三分地。把事务操作放在一个方法里,把可重试的外部调用放在另一个方法里,然后用外层方法把两者串起来。如果要重试的恰恰是数据库操作本身(比如偶发死锁),也建议把这个数据库操作单独拆成一个无事务上下文的Bean方法进行重试,或者使用编程式事务,让每个重试周期内的事务独立提交、独立回滚。

5.3 内部吞异常与“假成功”问题

还有一种让@Retryable失效的情况,不是代理问题,而是方法内部把异常吞了:

@Retryable(retryFor = RemoteAccessException.class, maxAttempts = 3) public OrderState syncOrderState(String orderId) { try { return paymentClient.queryOrder(orderId); } catch (RemoteAccessException ex) { log.error("调用失败", ex); return null; // 异常被吞掉了,重试层根本感知不到 } }

方法永远返回null或者默认值,没有异常抛到拦截器层面,注解自然不知道这次调用失败了。这种写法发生在重构时尤其隐蔽:原本方法会抛异常,后来有人为了“不让外层报错”顺手catch了一下,重试就悄悄失灵了。

排查这类问题时,先看日志里有没有Spring Retry打出的“Retry: ... failed”之类的记录,再看目标类名里有没有包含$$SpringCGLIB$$或jdk.proxy等代理标识。如果类名没有代理特征,说明这个Bean压根没被代理。

5.4 重试粒度太粗:整个业务方法被重复执行

最后一个高频问题是重试粒度。有人图省事,把一整个业务方法加上@Retryable,里面可能包含写库、发消息、调外部接口,结果外部接口超时后整个方法重试,数据库状态被重复更新,消息发了多遍。

正确做法是让重试方法尽可能“小而专注”,只包住那些确实会发生瞬时失败的代码段。业务编排方法应该保持无重试语义,通过调用内层可重试方法来获得“重试能力”。换句话说,重试粒度应该等于失败点的粒度,而不是整个请求的粒度。

如果你担心外部调用失败后内层方法重试成功,但外层编排方法里前面已经写库的操作没有撤销,其实这不是重试的问题,而是整个调用链设计的问题。解决方案很简单:把可重试的调用放在业务事务之外,或者在重试之前先完成必要的写操作,再让重试只作用于外部调用本身,并保证外部调用成功之后的状态更新是幂等的。

6. 重试与熔断、监控:你的重试配置是否真的在给系统减负

6.1 重试不是无限重试:什么时候必须停手

重试和熔断经常被放在一起讨论,但它们的出发点是不同的。重试解决的是“下游瞬时抖动,我再试一次可能就好了”;熔断解决的是“下游已经明显故障,我不能再拼命打了,先短路保护自己和上游”。

用生活类比的话,电梯门卡住了,你按一次关门键是正常操作,连续按十次就是把门控系统往死里逼。对应到系统里,如果重试请求持续涌入一个已经过载的依赖,不仅救不回来,反而可能把对方彻底拖垮。

所以我对重试的边界有很明确的判断标准:

场景合适的处理方式
偶发超时、瞬时网络抖动有限次数重试,指数退避
下游持续报错超过一定时间熔断,直接降级
下游恢复但流量溢出限流,配合重试也要控制并发
永久性业务错误不重试,直接走异常流程或人工补偿

Spring Retry本身不提供熔断能力。如果你需要在Reactive或者更加容器化的环境里做复杂的重试+熔断组合,可以考虑引入专门做容错的库,比如Resilience4j,但优先把Spring Retry的自带能力用透,绝大多数场景就够了。

6.2 用指标说话,别拍脑袋调参数

重试配置不是一次定死的,我习惯给每个重试方法都接上指标,用数据来判断参数是否合理。至少关注四个指标:

  • 重试总次数:展示重试发生的频繁程度。
  • 重试成功数:重试之后调用成功的次数。
  • 重试耗尽数:重试全部失败、最终走了兜底逻辑的次数。
  • 重试成功率:重试成功数除以重试总次数,这个比例最值得长期观察。

如果重试成功率长期很高,说明当前backoff配置比较有效;如果重试成功率很低、重试耗尽数不断增长,那么问题就不只是参数了——可能下游确实长期不可用,这时候就算把maxAttempts调到10也没用,反而加重下游负担。这时候应该考虑的是熔断或者告警介入,而不是继续调高重试次数。

在代码里,监听器的onError和close回调是打点最合适的位置。用Micrometer示例:

Metrics.counter("retry.attempt", "method", "syncOrderState", "exceptionType", throwable.getClass().getSimpleName() ).increment();

在close回调里如果throwable不为空,说明最终失败,再累加一次retry exhausted计数。这样一套下来,每个方法的重试画像就清楚了。

6.3 几组我实际用下来的配置经验与调试技巧

配置经验这部分,我把自己踩过的坑整理一下。

第一次上线一个重试方案时,参数建议从保守值起步:maxAttempts=3,delay=500ms,multiplier=2,maxDelay=6000ms。千万别一上来就配5次、8次的重试次数,因为一旦配错,后果不只是延迟变高,还可能造成下游流量放大。保守配置运行几天,看监控数据再逐步调整。

重试方法如果并发量很大,要特别小心退避窗口内的线程堆积。比如delay=2000、multiplier=2,高峰期有100个请求同时失败,等待期间意味着有100个线程被占着不放。这种情况下,可以配合线程池隔离,或者考虑把重试操作放到异步线程里,但异步又会引入线程池耗尽、上下文传递等问题。我的态度是:先确认并发量,再决定要不要上异步重试。

最后分享一个调试技巧。想验证@Retryable到底有没有生效,最快的方法是临时把delay调成10ms、maxAttempts调成2,然后在被重试方法里打印一条日志。如果日志按预期打印了两次,说明代理生效、重试链路正常。配好之后再把参数调回去。这个方法我在测试环境用了很多次,比在断点里一步步跟代理逻辑要快得多。也可以直接在监听器的onError里打日志,生产环境开着这个日志就行,信息量足够又不至于刷爆存储。


说到底,@Retryable只是把重试这件事从“手写套路”变成了“声明式配置”,但这不意味着你可以无脑使用。我在用它的过程中最大的收获是:每次想给某个方法加重试时,先问自己三件事——这个异常是可恢复的吗?重试后有没有幂等保障?重试全部失败之后用户看到的是什么?这三个问题都回答清楚了,再加注解。否则,重试可能从一个解决偶发故障的好工具,变成放大故障和掩盖问题的帮凶。跟Spring Retry打交道这些年,我最深的体会就是:配置参数看似简单,背后每一步都是对业务稳定性边界的理解。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 16:46:52

Matlab多目标优化实战:gamultiobj与paretosearch从入门到避坑

简介&#xff1a;这份资源面向需要在 MATLAB 中处理多目标优化问题的学生、科研人员与工程技术人员&#xff0c;核心是借助遗传算法求解相互冲突目标之间的帕累托最优解集。压缩包共 15 个文件&#xff0c;以 8 个 m 脚本和 7 个 asv 自动备份文件为主&#xff0c;整体约 8KB&a…

作者头像 李华
网站建设 2026/10/15 16:46:46

51单片机UART串口通信详解:从原理、寄存器配置到调试实战

自学51单片机这一路&#xff0c;前面做了流水灯、数码管、按键、中断&#xff0c;东西越来越有意思。到了第11篇&#xff0c;终于开始碰通信了&#xff0c;也就是UART串口通信。说句实话&#xff0c;串口是自学单片机过程中一个分水岭&#xff1a;搞懂它之前&#xff0c;单片机…

作者头像 李华
网站建设 2026/10/15 16:46:19

x86-64实模式内核启动:从0x7c00到kernel_main的硬核准备知识

1. 这不是“写个玩具系统”的速成课&#xff0c;而是一次操作系统内核开发的实操切片你点开这个标题&#xff0c;大概率不是为了凑热闹看个乐子&#xff0c;而是真想摸一摸操作系统底层的脉搏——不是调用几个API、不是改几行配置&#xff0c;是亲手让一段代码在裸机上接管CPU、…

作者头像 李华
网站建设 2026/10/15 16:45:06

优化器实例测试:从原理到实践,快速定位训练配置问题

1. 从“优化器实例测试”说起&#xff1a;为什么这件事值得单独拎出来做但凡做过一段时间模型训练的人&#xff0c;大概率都经历过这样的场景&#xff1a;模型结构调了好几版&#xff0c;数据管道也反复清洗过&#xff0c;训练脚本跑起来 loss 就是不降&#xff0c;或者降着降着…

作者头像 李华
网站建设 2026/10/15 16:43:33

FPGA自制家用电脑:从芯片组设计到整机搭建的工程实践

1. 一台带FPGA的“家用电脑”到底意味着什么 第一次看到“Kromi One”这个项目标题的时候&#xff0c;我脑子里蹦出来的第一个念头是&#xff1a;又有人要做复古电脑了&#xff1f;但仔细一看关键词—— home computer 加上 its own FPGA chipset &#xff0c;这就不是简单…

作者头像 李华
网站建设 2026/10/15 16:43:22

ARDU-cade:在2KB内存中实现本地自适应AI的嵌入式掌机

1. 从一个“不务正业”的掌机说起&#xff1a;ARDU-cade 到底想解决什么问题第一次看到 ARDU-cade 这个名字&#xff0c;很多人会以为又是一个拿开发板拼出来的复古游戏机。市面上用单片机做掌机的项目太多了&#xff0c;硬件方案大同小异&#xff0c;软件无非是跑个模拟器或者…

作者头像 李华