重试是把放大器:什么时候能重试、该退避多久、以及为什么总在最后变成雪崩
重试是把放大器:什么时候能重试、该退避多久、以及为什么总在最后变成雪崩
「这个接口偶尔超时,加重试吧」——这句话在线上事故里的出场频率,大概仅次于「加个缓存吧」。
重试本身没错,错在很多人把它当成一个局部的小修补。实际上重试是系统里最强的放大器:一次用户请求,可能在下游被放大成几次、几十次调用。放大倍数不受控,重试就从"提高可用性"变成"制造雪崩"。
一、先判断能不能重试
重试有三个前提,缺一个就不该重试:
第一,操作必须幂等。 查询天然幂等;扣款、下单、发消息不是。给一个非幂等接口加重试,失败场景下就是重复扣款。要么先把接口做成幂等(幂等键、去重表、状态机),要么干脆不重试。
第二,错误必须是瞬时的。 连接被重置、DNS 临时失败、下游短暂过载,这些重试一次大概率能好。而参数校验失败、权限不足、404 这类确定性错误,重试一百次结果都一样,只是把错误放大了一百次。
第三,次数和时间必须有界。 很多事故的根源是"无界重试"——一个请求在链路里层层重试,最终把本来只是慢的下游彻底打死。
判断口诀很简单:确定性错误别重试,非幂等操作别重试,没有退避的重试不如不重试。
二、指数退避和抖动,缺一不可
固定间隔重试的问题不在于慢,而在于同步。假设下游在某一瞬间抖动,一万个客户端同时在 100ms 后重试,下游面对的还是同样的一万并发——只是晚了一点。这就是"重试风暴"。
标准解法是指数退避配合随机抖动:
第 n 次重试的等待 = min(基准 × 2^n, 上限) × random(0.5, 1.5)
那个随机因子就是抖动。它把本来整齐划一的重试打散,让下游承受的是平滑的尾部流量,而不是一堵墙。没有抖动的指数退避,仍然是同步的,这一点很多人漏掉。
另外一定要有上限。2^n 涨得很快,不封顶的话第 10 次重试要等十几分钟,那时候用户早就走了;而连接还占着,反而浪费资源。
三、超时和重试必须一起算
这是最容易出事的地方:上层的总超时,必须大于"单次超时 × 最大重试次数",否则上层早就超时返回了,下层的重试还在跑——用户看到的是失败,系统里却还在持续加压。
反过来的错误也常见:只给下游设了很短的单次超时,却没给总时长设限,于是一个请求在重试链里耗掉几十秒,把线程池全占了。
实用的做法是给每个请求带一个剩余时间预算:进链路时算出一个 deadline,每次重试前检查还剩多少,不够就直接放弃并快速失败。这样无论重试几层,总耗时都是可控的。
四、重试预算:把重试本身当成一种资源
更好的做法不是限制"每个请求重试几次",而是限制"整个集群拿多少流量去重试"。
思路是:正常情况下重试占比很低(比如 1%),一旦超过阈值(比如 10%),说明下游已经在系统性故障,此时不是重试能救的,应该直接快速失败、让熔断器接管。这就是重试预算(retry budget)——它比单请求次数限制更能反映系统真实状态。
配合熔断,效果更好:连续失败到阈值就跳过重试直接失败,过一段时间放少量请求试探。重试解决的是偶发,熔断解决的是持续,两者分工不同,不能互相替代。
五、重试要有观测
没有观测的重试是隐形炸弹。至少要有三个指标:
- 重试率:被重试的请求占总请求的比例。它突然上升,通常比错误率更早暴露问题。
- 重试成功率:重试里有多少最终成功。这个数字很低,说明重试在做无用功,只是在放大流量。
- 重试放大倍数:一次用户请求平均产生多少次下游调用。这个值大于 2 就值得警惕了。
还有个容易忽略的:日志里要能看出"这是第几次重试"。否则排查时看到的是一堆重复请求,根本分不清是并发还是重试。
六、几个务实的小建议
- 给重试加开关,并且能在不改代码的情况下关掉。事故时第一件事往往就是"先把重试停了"。
- 重试只在入口层和最外层做,中间层不要层层重试。每一层都"好心"重试一次,放大倍数就是指数级。
- 异步任务的重试要配合死信队列,失败到一定次数就落盘,别无限循环。
- 对于写操作,宁可返回"处理中 + 稍后查询",也不要盲目重试。
重试写起来只有几行,但它改变的是系统的流量特性。把它当成一个需要预算、需要退避、需要观测的机制,而不是一个 try-catch 里的循环,才是它真正该有的地位。
- 点赞
- 收藏
- 关注作者
评论(0)