那次凌晨的线上事故我印象特别深:同步任务调用外部接口连续超时,我的代码在重试3次之后把异常抛了出去,结果整个批处理程序瞬间中断,几万条数据停在中间态。事后复盘,问题根本不在接口本身,而在我对“重试3次全部失败”这个结果的错误处理——我把一个可预期的业务故障,变成了压垮程序的一根稻草。后来我彻底改了处理方式:即便重试3次全部失败,也绝不把异常抛给上层。这不是简单的“吞异常”,而是把“程序崩溃”这一条唯一通路,换成了一套能降级、能记录、能告警的兜底机制。这篇文章就把这套思路、取舍和可落地的代码实现完整讲一遍,适合做后端服务、接口调用、批处理任务的开发者参考。
1. “重试3次还失败”为什么不能直接抛异常:先算清异常上抛的账
1.1 程序崩溃的真正原因往往不是故障本身,而是故障后的“上抛链”
很多人写重试逻辑时,脑子里默认的流程是这样的:调用失败——重试——再失败——抛出异常给上层。听起来没毛病,失败本来就应该让上层知道。但问题在于,上层拿到这个异常之后怎么办?如果上层也没有兜底逻辑,它就会继续往外抛,最后到了框架层、到了主线程入口,表现就是任务中断、线程退出、用户看到错误页。整条调用链上,每一个环节都觉得“我处理不了,交给上面处理”,结果就是没人处理。
我那个定时任务就是典型例子。批量处理1000条数据,轮到第37条时外部接口超时,重试3次仍失败,我把RuntimeException抛出去,Spring的任务调度线程捕获后打印了一行异常日志,本次调度结束。结果就是:剩下963条数据没人处理,已经提交的部分数据与未提交的数据混在一起,修复时你还得先搞清楚哪些成功了、哪些没成功。如果当时我把这个异常吞在单条记录内部,最多就是这条数据标个“失败待补偿”,后面还能通过补单任务捞回来。一个异常上抛,把“局部失败”无限放大成“全局中断”。
再往深了说,异常上抛的成本不只是程序崩溃。它会让调用链状态变得模糊:调用方只知道“失败了”,但不知道失败的上文是什么、参数是什么、重试了几次、是不是幂等能解决。排查的人得翻好几层日志才能拼出当时发生了什么。所以现在很多团队已经在推“Result对象代替异常”的实践,用返回值表达业务上的失败,而不是用异常中断控制流。异常可以作为内部跳转信号,但跨层传递必须谨慎。
1.2 可重试错误与不可重试错误:先分清,再决定要不要走重试链路
“不抛异常”的第一步,其实是搞清楚哪些失败值得重试,哪些失败重试100次也没用。如果参数校验不过、业务规则不允许、认证授权失败,这种错误重试多少次都不会成功,反而只会放大无效请求。反之,网络超时、连接池暂时获取不到连接、下游返回503/504,这类临时性故障退避一会儿再试通常能恢复。
| 错误类型 | 典型场景 | 是否建议重试 |
|---|---|---|
| 网络超时 | 连接外部HTTP接口超时、读超时 | 建议重试,但须配合退避 |
| 临时资源不可用 | 连接池满、线程池拒绝 | 可重试,但先确认自身水位 |
| 下游过载 | HTTP 503、429 限流 | 谨慎重试,必须退避+抖动,否则加重故障 |
| 数据库死锁 | MySQL deadlock 报错 | 可重试,事务级别注意回滚 |
| 参数错误 | 400/422 参数校验失败 | 不重试,直接记录失败 |
| 业务规则不满足 | 余额不足、状态不允许 | 不重试,走业务兜底 |
| 认证失败 | 401/403,token过期 | 不重试,刷新凭证后可以考虑单次重试 |
我自己的习惯是写一个RetryJudge判断器,把异常类型映射到“可重试/不可重试”。比如超时异常返回true,参数异常返回false。这样重试逻辑就不会在不可重试的错误上做无用功。另外,关于类似CAS(Compare And Swap)那种循环重试,它和业务重试是两回事,CAS是在并发提交失败时重新读数据再试一次,通常次数很少,本质是乐观锁的补偿手段;而我们这里讨论的“重试3次”是针对外部服务调用的容错策略,两者不要混用。
1.3 异常是过程信号,不是结果协议
我在代码评审里经常问同事一个问题:“你把这个异常抛出去,希望上游做什么?”得到的回答往往是“希望他知道这里出错了”。但如果出错信息只有“call remote failed”这一行,上游知道了又能怎样?它既不知道重试过没有,也不知道有没有更合适的降级方案。所以我现在更倾向把异常理解为“内部过程信号”,用来触发重试、触发回滚这些内部动作;而跨方法、跨模块传递信息,用返回值更可靠。
这不代表绝对禁止抛异常。如果当前层确实无法完成兜底,比如一个事务方法里第二步操作失败必须让整个事务回滚,那还是要抛出异常让事务框架感知。但抛出之后,最外层入口必须有一个统一兜底,把异常转换成可读的提示、可追踪的错误码,而不是让它一路裸奔到程序崩溃。“不抛异常到上层”的正确姿势是:在每一层都消化自己该消化的故障,实在消化不了的,以受控的方式传给最近的兜底点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 重试策略选型:重试次数、退避间隔、超时预算缺一不可
2.1 重试次数不是拍脑袋:从总耗时预算倒推上限
先约定一下口径:本文说的“重试3次”,指总尝试次数为3次,即首次调用+2次重试。如果你习惯“首次调用+3次重试”,逻辑完全一样,把上限抽成配置就行。
选多少次才算合理,我的经验是从三个维度倒推:
- 业务容忍的等待时间。比如一个同步接口要求最长2秒返回,你每次请求超时上限300ms,中间等待间隔200ms、400ms,那么总耗时约1.7秒,3次已经比较极限。如果超时上限是1秒,3次总共至少3秒,就必须降低次数或缩短超时。
- 下游恢复的合理预期。外部接口如果是偶发抖动,通常几秒内能恢复;如果下游已经宕机,那再多的重试都是添乱。要评估的是对方服务降级的平均时长,而不是单纯“越多越安全”。
- 流量放大倍数。重试次数越多,下游承受的请求量越大。假设上游QPS是1000,每个请求失败都重试3次,下游瞬间多了2000个额外请求,这其实是在给故障雪上加霜。
我见过有些团队把重试次数配到5次、8次,理由是“不想丢数据”。出发点是好的,但在没有退避和有全链路熔断的情况下,这种配置往往让系统死得更快。3次是个相对平衡的值:覆盖瞬时的网络抖动,又不至于在持续故障时放大太多流量。
2.2 退避算法对比:固定间隔、指数退避、抖动
直接固定间隔1秒重试3次,实现最简单,但有一个隐患:如果同一时刻大量请求失败,它们会按同样的节奏重试,形成周期性共振,瞬间又一起打到下游。指数退避解决了节奏问题,第一次重试等200ms,第二次等400ms,第三次等800ms,给下游留了缓冲时间。再加一个抖动(Jitter),在计算出的间隔基础上乘以0.5到1之间的随机系数,防止同批次请求在完全相同的时刻发起重试。
| 策略 | 重试间隔 | 优点 | 缺点 |
|---|---|---|---|
| 固定间隔 | 1s, 1s, 1s | 实现简单 | 容易共振,下游流量尖刺明显 |
| 指数退避 | 1s, 2s, 4s | 节奏拉开,缓冲合理 | 同一批次仍可能同步 |
| 指数退避+抖动 | 0.5s-1s, 1s-2s, 2s-4s | 打破同步,实际效果最好 | 计算稍复杂,需要解释清楚 |
我在生产环境代码里用的是指数退避加抖动。计算方式很简单:基准间隔乘以2的(第N次重试)次方,然后乘一个0.5到1之间的随机系数。比如基准200ms,第一次重试等200 * 1 * random(0.5,1),第二次等200 * 2 * random(0.5,1)。别小看这个随机系数,没有它,你的重试流量依然是整齐划一的脉冲。
2.3 超时预算:重试的总时间必须封顶
单次超时和重试次数都定了,还得算一笔总账。假设单次请求超时上限是500ms,指数退避基准是200ms,总尝试次数是3,那么整个过程的总耗时是:
- 第1次调用:500ms
- 等待间隔:200ms × random(0.5,1),约100-200ms
- 第2次调用:500ms
- 等待间隔:400ms × random(0.5,1),约200-400ms
- 第3次调用:500ms
最坏情况约2秒。如果用户侧的请求超时是3秒,这个预算还扛得住;但如果上游网关超时是1.5秒,你的重试还没跑完网关已经断开了,后面第3次调用的结果根本没有意义。所以重试参数必须和全链路的超时预算联动,我一般会把“单次超时”“重试间隔”“重试上限次数”三个参数放到同一个配置类里,并且加一个maxTotalDurationMs,重试过程中累计耗时段一旦超出,立即终止。
3. 三次重试全部失败后:不抛异常,但要降级、要留痕、要告警
3.1 降级返回:用结果对象表达“失败了”,而不是靠异常中断
重点来了。三次重试全部失败,程序不能崩,那代码应该返回什么?
第一种做法是返回一个默认值。比如查询用户信息失败,返回null或者空列表,业务继续往下走,界面显示空态。这是最简单的降级,但有个坏处:调用方如果不做空判断,后面拿这个空值继续操作,很可能出现空指针,把问题从“外部调用失败”转变成“程序内部NPE”,更隐蔽。
所以更稳妥的做法是返回一个携带状态的结果对象,比如Result<T>、Optional<T>,把成功还是失败明确写出来。调用方拿到结果后可以决定:界面弹一个“稍后重试”的弱提示,或者把这条记录标记为“待补偿”,后续用定时任务重新处理。这种“受控的失败”既不会中断主流程,又给了业务方决策余地。
我在代码里常用的是类似这样的结构:
java复制public class RetryResult<T> {
private boolean success;
private T value;
private int attempts;
private Throwable lastError;
// getter/setter 省略
}
调用方只看RetryResult.isSuccess()就知道这次调用的最终结果,不再依赖异常传递。这里要特别说明:降级返回不等于“吞异常”。吞异常是捕获后什么都不干,把失败信息丢掉;降级返回是捕获后明确告诉调用方“我没成功,但程序还活着,业务可以走降级路径”。一个负责,一个不负责,差别巨大。
3.2 记录完整现场:日志里要有“案发现场”,而不是只有异常栈
重试失败之后,日志是唯一能还原过程的依据。我要求的字段至少包括:
- 全局追踪ID(traceId),把同一次请求串联起来
- 操作的方法名、入参摘要(敏感字段脱敏)
- 第几次失败、每次失败的耗时
- 最后一次异常的类型和消息
- 重试总耗时、最终是成功还是走了降级
为什么要入参摘要?因为外部接口的超时往往和入参大小有关,比如你传了2MB的报文,大概率会读超时;如果日志里没有入参信息,你只能猜。为什么要每次尝试的耗时?因为耗时能看出故障的发展趋势:如果第一次超时是500ms,第二次超时是800ms,第三次直接抛连接拒绝,说明下游不是网络抖动,而是已经彻底不可用,这就需要触发告警让运维介入。
日志格式建议用结构化日志,字段用JSON输出,这样后续聚合分析比较方便。一定不要只打一行e.printStackTrace(),那对排查几乎没有帮助。
3.3 异步上报:故障可以不爆炸,但不能没人知道
“不抛异常到上层”最容易被误解的一点是:是不是把错误藏起来了?不是。错误要暴露,只是换一种受控的方式暴露——异步上报监控系统。
我通常的做法是三层:
- 指标层:记录
fallback_total、retry_failed_total,如果降级量突增,说明下游处于不健康状态,监控曲线直接看得出来。 - 日志层:上面说的完整现场日志,进ELK等日志平台,可以由traceId串联排查。
- 告警层:连续降级超过N次,或者降级率超过阈值,发告警到值班群,人工介入查看。
这里特别提醒:异步上报本身也有可能失败。不要把监控上报做成同步强依赖,否则下游没故障,监控上报先把你拖死了。用一个有界队列或者简单的线程池异步发送,丢了也比阻塞主流程强。如果用的是RabbitMQ这类消息队列的场景,三次重试失败后可以把消息转存到死信队列或延迟队列,等下游恢复后再补偿,原理和异步上报一致:故障先隔离,再等待时机处理。
4. 可落地代码实现:从朴素循环到可扩展组件
4.1 第一版:用for循环控制重试,最后走兜底
先看一个最朴素但完整的实现。这个版本直接体现重试+兜底的控制流:
java复制public void processItem(String itemId) {
int maxAttempts = 3;
Exception lastException = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
String result = callExternalApi(itemId);
handleSuccess(result);
return; // 成功直接返回,不走兜底
} catch (Exception e) {
lastException = e;
log.warn("第{}次调用失败, itemId={}, error={}", attempt, itemId, e.getMessage());
if (attempt == maxAttempts) {
break; // 最后一次,跳出循环进入兜底
}
sleepQuietly(computeDelay(attempt));
}
}
// 3次全部失败,不抛异常,走降级
handleFallback(itemId, lastException);
}
这段代码的核心是:循环体内成功就return,最后一次失败后break,然后执行handleFallback。整个方法没有异常可以冒泡到上层。computeDelay实现指数退避加抖动,sleepQuietly捕获InterruptedException后重置中断标志位。要注意的是,handleFallback里不能让业务继续依赖这次调用的返回值,而是明确把这条数据处理成“待补偿”状态。
4.2 第二版:用状态对象记录每次尝试的细节
第一版能跑,但日志靠打点,调用方拿不到重试过程。我把它升级为携带完整信息的RetryResult对象:
java复制public class RetryResult<T> {
private boolean success;
private T value;
private int attempts;
private Throwable lastError;
private List<AttemptInfo> attemptInfos = new ArrayList<>();
public void addAttempt(int attempt, long costMs, Throwable error) {
attemptInfos.add(new AttemptInfo(attempt, costMs, error));
}
// getter/setter 省略
}
public class AttemptInfo {
private final int attempt;
private final long costMs;
private final Throwable error;
// 构造方法 getter 省略
}
public class RetryExecutor {
public static <T> RetryResult<T> execute(RetryAction<T> action,
int maxAttempts,
long initialDelayMs) {
RetryResult<T> result = new RetryResult<>();
Throwable lastError = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
long start = System.currentTimeMillis();
try {
T value = action.run();
result.setSuccess(true);
result.setValue(value);
result.setAttempts(attempt);
result.addAttempt(attempt, System.currentTimeMillis() - start, null);
return result;
} catch (Throwable t) {
lastError = t;
result.addAttempt(attempt, System.currentTimeMillis() - start, t);
if (attempt == maxAttempts) {
break;
}
sleepQuietly(computeDelay(attempt, initialDelayMs));
}
}
result.setSuccess(false);
result.setAttempts(maxAttempts);
result.setLastError(lastError);
return result;
}
}
这样调用方可以拿到非常完整的信息:第几次失败、每次耗时多少、最后异常是什么。重要的是,方法仍然正常返回RetryResult,没有异常外抛。我一般还会在里面填充一个tookMs总耗时字段,方便监控。
4.3 第三版:把“是否重试”和“失败后干什么”做成策略
第二版已经把重试状态管理起来了,但还差两个可扩展点:一是重试判断逻辑写死,二是失败后的降级逻辑散落在调用方。我习惯把这个工具收敛成一个带策略的组件:
java复制public interface RetryJudge {
boolean shouldRetry(Throwable error);
}
public interface FallbackHandler<T> {
T onExhausted(RetryResult<T> result);
}
public class RetryTemplate {
public static <T> T execute(RetryAction<T> action,
RetryConfig config,
RetryJudge judge,
FallbackHandler<T> fallback) {
RetryResult<T> result = RetryExecutor.execute(
action, config.getMaxAttempts(), config.getInitialDelayMs());
if (result.isSuccess()) {
return result.getValue();
}
if (!judge.shouldRetry(result.getLastError())) {
// 如果是不可重试错误,直接走fallback,不再消耗重试次数
log.warn("不可重试错误, type={}", result.getLastError().getClass().getName());
}
if (fallback != null) {
return fallback.onExhausted(result);
}
return null;
}
}
这套设计的好处是:接入新业务的时候,只需要自定义RetryAction和FallbackHandler,重试的间隔策略、次数限制、结果组装都在模板里,逻辑统一。我在项目里还会在RetryConfig里加一个maxTotalDurationMs,重试过程中累计耗时一旦超过就强制终止,防止重试时间失控。
5. 线上踩过的坑:幂等、重试风暴、线程池阻塞
5.1 幂等性陷阱:重试导致的“第二次成功”
重试最隐蔽的坑,不是重试失败,而是重试“成功”了,但产生了重复数据。典型场景:调用支付接口,请求超时了,代码发起第二次重试。第一次请求实际上已经在服务端处理成功,只是响应丢失,重试又扣了一次钱。这就是幂等性问题。
排查链路大概是这样的:
- 用户反馈被重复扣款,或者数据库里出现主键冲突、重复订单。
- 查日志,用traceId串起两次请求,发现两次都返回了成功。
- 看时间戳,第二次发生在重试窗口内,确认是超时重试导致。
- 再查服务端流水,发现第一次请求其实已处理成功,只是网关层响应超时。
修复方法无非是三种:一是客户端生成幂等键(Idempotency Key),同一个业务操作使用相同的key,服务端按key去重;二是数据库加唯一约束,用业务唯一编号做天然幂等;三是引入状态机,先查后改,避免在“已成功”的状态上再次执行。我现在写任何带副作用的接口,都会要求调用方传requestId,服务端先查requestId是否处理过,再决定是直接返回旧结果还是创建新任务。
5.2 重试风暴:当“每个服务都重试3次”时,下游会被放大
另一个坑是重试放大效应。假设网关层有100个请求同时失败,每个请求都按同样的退避策略重试3次,下游峰值流量就变成了原来的4倍。更糟糕的是,如果多个上游服务共用同一个下游,每个上游都做一次重试,下游可能直接被冲垮。这种情况我在故障复盘里见过不止一次,每一次都是“重试机制单独看没问题,组合起来就是雪崩”。
要破解重试风暴,光靠退避和抖动还不够:
- 给重试加一个并发控制,同一时间内只允许N个请求进入重试,其余直接走降级;
- 把退避的初始间隔调大,比如从1秒起跳,不要让重试请求来得太快;
- 配置一个动态开关,下游故障告警触发后,运维可以一键关闭重试,改为快速失败和降级。
重试机制一定要和熔断器配合。熔断器打开时,重试动作应该被有效抑制,否则一边熔断一边重试,等于故障还在往外扩散。
5.3 线程池阻塞排查:Future.get() 没有超时,线程全被占满
还有一个我在生产环境实际踩过的坑,跟线程池有关。当时用线程池提交了一批外部调用任务,主线程用Future.get()等待结果。外部接口超时后,代码在重试循环里等待,而Future.get()没有设置超时时间,导致主线程一直阻塞。下游故障持续了十几分钟,线程池里的线程全部卡在等待重试的过程中,后续任务全部进队列排队,接口响应时间直线上升。
排查方式是看线程dump,大量线程停在Future.get()和Thread.sleep()的重试逻辑上。修复方案有两条:一是给Future.get(long timeout, TimeUnit unit)设置超时,超时后直接放弃本次任务;二是重试内部的单次调用也设置读超时,从源头控制单次耗时。此外,还可以用Semaphore限制进入重试逻辑的并发数,避免故障时线程池被打满。核心思想是:重试本身是可控的,但它必须挂在整个并发边界之内,不能成为无底洞。
6. 重试兜底不是终点:熔断、可观测性和最终的个人建议
6.1 重试负责“抖动”,熔断负责“持续故障”
我前面反复提到熔断器,这里展开说说重试和熔断的分工。重试适合处理瞬时性的抖动,比如偶发的网络波动、连接池短时繁忙,重试一下大概率就成功了。但如果下游已经持续故障5分钟了,重试3次根本没有意义,每次都会失败,还白费资源。这种时候应该通过熔断器快速失败,直接走降级逻辑,等下游恢复后再放流量。
我给团队定的协作方式很简单:重试是熔断之前的第一道防线,熔断是重试失败之后的第二道防线。连续重试失败的次数达到阈值,比如一分钟内降级率超过20%,熔断器打开,后续请求不再重试,而是直接快速失败。这样既照顾了瞬态故障,又防止了持续故障时的无脑重试。
6.2 可观测性:把重试的每一次脚步都记下来
重试逻辑写好了,如果看不到运行指标,等于盲飞。我建议至少埋这样几个指标:
retry_total:重试发生总次数retry_failed_total:重试仍然失败的次数fallback_total:进入降级处理的次数retry_success_rate:重试成功率,可以看出重试策略是否有效
这些指标配上告警阈值以后,效果非常直接:比如某天fallback_total突然从每小时0涨到一万,基本可以确认下游服务出问题了,值班同学可以第一时间介入。我比较推荐用Prometheus这类监控体系,配合Grafana出图,把重试链路的健康度直接可视化。
日志部分也提一句:重试日志的级别建议用WARN,因为重试本身说明系统出现了异常,但它被处理掉了,还不到ERROR级别;只有降级次数超阈值或者兜底失败,才考虑ERROR告警。保持日志级别的语义清晰,后面查日志会省很多力气。
6.3 几个经验法则:参数化、故障演练、最外层兜底
如果一定要给一些实操建议,我会强调这几条。
第一,重试参数一定要可配置。次数、间隔、退避倍数、抖动范围、单次超时、总预算,全部放进配置中心,不要写在代码里。因为你没法预知线上真实的故障情况,等发现重试次数不够或者间隔太短的时候,改配置比重发版本快得多。
第二,做个故障演练。选一个低峰期,把下游接口用Mock方式强制返回超时,观察重试、日志、降级、告警是否按预期工作。我见过太多的重试逻辑是“写的时候觉得没问题,故障一到直接失效”,比如重试时事务已经回滚、幂等键没有传递、降级逻辑又抛了新的异常。演练一次,比看十遍代码都管用。
第三,最外层一定要有统一兜底。即便你控制了绝大多数层级的异常,也不能保证每个同事都遵守同样的规范。所以框架的入口层还需要一个全局异常兜底,把漏网的异常统一转换成错误码和提示信息,至少保证进程不崩、用户能看到可理解的响应。这样一来,“重试3次全部失败不抛异常到上层”就不是某一处的临时技巧,而是一整套容错设计落地的结果。
如果只让我提炼一条经验,我会说:把“重试3次失败后的行为”当成第一等公民来设计,而不是当成异常处理的边角料。次数、间隔、退避、降级结果、日志字段、告警阈值,每一个都提前定义好,线上大概率不会出现凌晨被叫醒的情况。我现在每写一个外部调用,都会顺手把这几样补齐。重试兜底不是什么高深技巧,它就是一个成熟工程师的基本盘。
