那次凌晨的线上事故我印象特别深:同步任务调用外部接口连续超时,我的代码在重试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秒,就必须降低次数或缩短超时。
- 下游恢复的合理预期。外部接口如果是偶发抖动,通常几秒内能恢复;
