凌晨两点的告警通常不是从故障点开始的。数据库先出现慢查询,某个接口的 P99 从 120ms 涨到 2s,紧接着网关错误率抬头,然后是整条链路的线程池被打满。等你打开链路追踪,看到的第一个红点往往已经离根因隔了三层服务。这类越治越糟的故障,很大一部分不是业务逻辑写错了,而是出在三个被当成配置项随手填写的参数上:超时、重试、幂等。
单独看,每个参数都很朴素:超时防止无限等待,重试提高成功率,幂等保证重复执行安全。但它们是一组联立方程,任何一个取值不合理,另外两个的意义都会被改写。一个 30 秒的超时配上一个没有上限的重试,等于给下游准备了一次定向的流量攻击;一个没有幂等保护的写接口配上自动重试,等于在生产环境里批量制造重复订单。本文想回答的问题很具体:为什么一次普通的超时,最后会演变成整条链路的雪崩,以及该怎么把这三个参数当成一套系统来设计。
需要先说明信息边界:本次没有可引用的站内讨论样本,文中的数字和场景是按通用工程机制构造的示意,不是对某条帖子的复述;具体阈值需要读者按自己的链路实测后调整,不同业务的 QPS 和延迟分布差异很大,直接抄数值没有意义。
先说结论
超时和重试不是两个独立的可靠性开关,而是一个放大器和一减震器的组合。 放大器开得越大,减震器就必须越硬;如果两者都按“多给一点更保险”的直觉去配,结果一定是把局部故障放大成全局故障。
结论成立的边界在于:放大只发生在同步调用链上。如果下游是异步消费(消息队列、事件驱动),调用方不需要阻塞等待结果,重试的放大效应就被削掉了大半,问题会转移到消费端的幂等和消息积压上。所以判断要不要收紧重试,第一步是分清这条链路是同步等待还是异步投递,而不是直接去改 maxAttempts。
核心判断:重试的前提是幂等,超时的前提是能取消;两者缺一,重试就从保护机制变成了故障放大器。
机制:重试如何把局部故障放大成全局故障
放大是一个乘法过程,不是加法过程。假设一个请求在每一层最多重试 次,那么最坏情况下,一个用户请求到达最底层时会被放大为
网关、服务 A、服务 B 三层各重试 2 次,。也就是说,一个本来只是“有点慢”的数据库,瞬间要面对 27 倍的并发。更糟的是,这 27 个请求里绝大多数注定失败——它们之所以重试,恰恰是因为下游已经慢了或者挂了。在过载场景下,重试的实际效果是用有限资源处理注定失败的请求,把本可以自行恢复的下游彻底压垮。
流程图
渲染中...
第二个容易被忽略的放大器是线程和连接。同步调用里,一个请求在等待下游响应期间会一直占着线程。假设服务 A 的线程池是 200,超时配成 5 秒,正常 QPS 是 400:下游只要延迟超过 5 秒,200 个线程会在半秒内被占满,之后新请求全部在队列里排队。排队的请求又会触发调用方(网关)的超时和重试,于是 A 还没恢复,压力已经翻了三倍。这时候你会看到一个很反直觉的现象:下游 QPS 明明掉到了零,上游的 CPU 却跑满了——CPU 全花在创建线程、序列化超时异常、写错误日志上。
真正致命的组合是“长超时 + 无上限重试 + 无熔断”。熔断的价值不是省几次调用,而是在下游已经确定不可用时,立刻把线程还给上游。没有熔断,重试只会让线程占用时间乘以重试次数,而线程池的大小是固定的,这是一个只减不增的池子。所以调优的顺序应该是先熔断、再超时、最后才是重试——重试是锦上添花,不是兜底手段。
取舍:四种重试策略的代价对比
| 策略 | 优点 | 代价 | 适用场景 |
|---|---|---|---|
| 不重试,直接失败 | 放大系数为 1,故障隔离最好 | 偶发抖动会直接暴露给用户 | 写操作、非幂等接口、强一致链路 |
| 固定间隔重试 | 实现简单,行为可预测 | 重试风暴同步到达,容易形成脉冲 | 低频后台任务 |
| 指数退避 + 抖动 | 错开重试时间,给下游恢复窗口 | 延迟上限变高,需要控制总耗时 | 读接口、跨机房调用 |
| 熔断后快速失败 | 立刻释放线程,阻断级联 | 需要维护状态机,误熔断有成本 | 所有同步调用链的必备项 |
表格里最值得展开的是“抖动”。指数退避解决的是“重试间隔太短”,抖动解决的是“重试太齐”。如果 1000 个客户端在同一个时刻失败,即使都做了指数退避,第一次重试仍然会集中在 200ms 后同时到达,形成一个整齐的脉冲。加上随机抖动(比如在 [0, waitDuration) 内随机取值),才能把脉冲摊平成一条平缓的曲线。这两者缺一不可,只做退避不做抖动,等于把同步重试改成了“延迟后的同步重试”。
另一个取舍是总耗时预算。重试次数乘以单次超时,不能超过调用方愿意等待的总时长。如果网关给用户的总预算是 3 秒,服务 A 单次超时 2 秒、重试 2 次,那么最坏情况是 6 秒,用户早就断开连接了,后面的重试全是无效负载。正确的做法是让超时预算沿着调用链递减:网关 3 秒,服务 A 2.5 秒,服务 B 2 秒,每一层都给下游留出比自己更短的窗口,这样超时才会发生在真正慢的那一层,而不是在上游层层堆积。
幂等:重试的前提条件
重试能不能用,不取决于重试策略写得多漂亮,而取决于被重试的操作是否幂等。判断标准很简单:同一个请求执行一次和执行三次,业务状态是否完全一致。 查询天然幂等,UPDATE balance = balance - 100 天然不幂等,UPDATE balance = balance - 100 WHERE version = 5 又变回幂等。很多“重试导致重复扣款”的事故,根因不是重试配置,而是写接口从一开始就没打算被调用两次。
常见的幂等实现有三种,代价差别很大。唯一键约束最可靠,把业务请求号建成唯一索引,重复插入直接抛冲突,由数据库保证原子性;代价是需要一个全局唯一的请求号,而且冲突异常要能被正确识别成“已处理”而不是“系统错误”。状态机适合有明确生命周期的业务,比如订单只允许从“待支付”流转到“已支付”,重复请求会因为状态不匹配被拒绝;代价是状态设计要提前想清楚,后期加状态很痛苦。去重表 / Token 适合无法加唯一键的场景,用一张独立的表记录已处理的 key,配合过期清理;代价是多一次写和一份存储。
SQL
-- 唯一键方案:由数据库保证幂等,重复请求会命中唯一冲突
ALTER TABLE payment_order
ADD UNIQUE KEY uk_request_no (request_no);
需要提醒的是,幂等键的生成位置很关键。如果请求号是在服务内部生成的,那么每次重试都会生成一个新的,幂等直接失效。幂等键必须由最初的调用方生成,并沿着整条链路透传,通常放在请求头里,日志和链路追踪也要带上它,否则出了问题你连“这两条记录是不是同一次请求”都判断不了。
落地:一份可以照着做的检查清单
参数调优最容易犯的错误是“一次性全改”。比较稳妥的做法是先观测再动手:把每条链路的 P99、超时率、重试次数、线程池活跃数放到同一张面板上,看清楚重试到底贡献了多少流量,再决定砍哪一层。没有观测数据就调参,本质上是在猜。
- 确认这条链路是同步等待还是异步投递,异步链路优先解决消费端幂等和积压
- 给每个写接口标注是否幂等,非幂等接口显式关闭自动重试
- 超时预算沿链路递减,上游总预算大于下游单次超时乘以最大重试次数
- 重试只针对可恢复异常(连接超时、5xx),业务异常不重试
- 重试必须带指数退避和随机抖动,并设置总时长上限
- 每个同步依赖都配置熔断,明确半开状态的探测流量
- 幂等键由入口生成并透传,写入日志与链路追踪
YAML
resilience4j:
retry:
instances:
inventory:
maxAttempts: 3
waitDuration: 200ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
retryExceptions:
- java.io.IOException
ignoreExceptions:
- com.example.BusinessException
timelimiter:
instances:
inventory:
timeoutDuration: 800ms
cancelRunningFuture: true
这段配置里有两个细节值得注意。ignoreExceptions 把业务异常排除在重试之外,避免“库存不足”这种确定性失败被重试三次;cancelRunningFuture 保证超时后真正取消底层调用,而不是仅仅让调用方不再等待——如果只做后者,线程和连接仍然被占着,超时就成了摆设。
边界:什么时候“不重试”才是对的
不是所有链路都值得重试。写操作、资金类操作、有外部副作用的调用(发短信、调支付网关),默认应该不重试,除非已经做好了端到端的幂等。这类场景里,一次失败让用户重试,比系统自动重试三次要安全得多,因为用户能看到结果,而系统看不到。
还有一种情况是下游已经明确过载。这时候重试的边际收益是负的:它不仅不会成功,还会延长故障时间。熔断器在这里的作用是把“重试”替换成“快速失败 + 兜底返回”,让上游用降级数据先撑住,把恢复窗口留给下游。降级不是认输,是把有限的资源从注定失败的路径上撤回来。
总结与讨论
第一,超时、重试、幂等要作为一组参数一起设计,单独调任何一个都可能把故障放大。重试的放大系数是各层 的乘积,三层各两次就是 27 倍,这个数字应该出现在每一次容量评估里。
第二,调优顺序是熔断优先于超时、超时优先于重试。熔断负责把线程还给上游,超时负责给等待设上限,重试只负责处理偶发抖动,三者职责不能混。
第三,重试的合法性来自幂等,而幂等键必须由入口生成并透传。没有这条约束,任何自动重试都是在赌业务不会重复执行。
最后留一个值得讨论的问题:在你们现有的链路里,有没有哪一层的重试其实是“为了掩盖下游不稳定”而加上的?如果把这层重试去掉、改成熔断加降级,用户实际感受到的成功率会变好还是变差?这个问题没有标准答案,但它能逼着我们把“重试次数”从一个配置项,还原成一个关于容量和责任的决策。