3. 重试策略:指数退避、抖动、最大重试次数、幂等性设计
流水线里出故障,最直接的反应就是「再试一次」。但怎么试,这里面门道很深。我见过不少团队,一遇到失败就立刻重试,结果系统雪崩,比不重试还惨。
重试不是简单的循环调用。它是一套组合拳:什么时候重试、等多久再试、试几次停手、以及重试会不会造成数据混乱。这四个问题,对应着四个核心概念:指数退避、抖动、最大重试次数、幂等性。
核心原则:重试是容错的最后一道防线,但不是万能药。用得好,系统韧性翻倍;用不好,就是二次灾难的导火索。
3.1 指数退避:别在伤口上撒盐
假设你的服务调一个下游接口,超时了。你立刻重试,又超时。再重试,还是超时。你想想看,如果下游本来就负载高,你这种「闪电连击」只会把它彻底打垮。
指数退避的思路很简单:每次重试的等待时间,按指数增长。第一次等 1 秒,第二次等 2 秒,第三次等 4 秒,第四次等 8 秒……这样给下游留出恢复时间。
我个人习惯用这个公式:
wait_time = base_delay * (2 ^ attempt_number)
其中 base_delay 是基础等待时间,attempt_number 是当前重试次数(从 0 开始)。
举个例子:
| 重试次数 | 等待时间(base_delay=1s) | 累计等待 |
|---|---|---|
| 0(首次) | 0s | 0s |
| 1 | 1s | 1s |
| 2 | 2s | 3s |
| 3 | 4s | 7s |
| 4 | 8s | 15s |
| 5 | 16s | 31s |
你看,到第 5 次重试时,已经等了半分钟。这段时间足够下游从短暂的故障中恢复过来。
经验之谈:我在项目中遇到过,base_delay 设得太小(比如 100ms),指数增长后还是很快。建议根据你的业务容忍度来定,一般 500ms 到 2s 比较合理。
3.2 抖动:别让所有请求同时爆炸
指数退避解决了「重试太频繁」的问题,但引入了一个新问题:惊群效应。
想象一下,你的服务有 1000 个请求同时失败。它们都按指数退避,第一次重试都在 1 秒后。1 秒一到,1000 个请求同时打向下游。下游刚缓过来,又被一波流冲垮了。
为什么会这样?因为所有请求的退避时间一模一样。
解决方案就是加抖动。在退避时间上引入随机偏移,让每个请求的等待时间都不一样。
我常用的抖动公式:
wait_time = base_delay * (2 ^ attempt_number) + random(0, base_delay * (2 ^ attempt_number))
或者更简单的版本:
wait_time = base_delay * (2 ^ attempt_number) * (0.5 + random(0, 0.5))
这样每个请求的等待时间会在一个范围内随机分布,不会出现「齐射」的情况。
注意:抖动不是越大越好。抖动范围太大,会导致某些请求等待时间过长,影响整体延迟。我个人习惯把抖动控制在退避时间的 50% 以内。
3.3 最大重试次数:及时止损
重试不是无限的。你想想看,如果下游已经挂了,你重试 100 次也没用,只会浪费资源。
所以必须设置最大重试次数。超过这个次数,就放弃重试,走降级或报错流程。
最大重试次数怎么定?我一般看两个指标:
- 业务容忍度:用户能等多久?比如支付接口,用户等 10 秒就急了,那重试总时间不能超过 10 秒。
- 故障类型:临时性故障(如网络抖动)可以多试几次;永久性故障(如 404、400)试一次就够了。
我建议把重试次数控制在 3-5 次。太少不够用,太多容易雪崩。
这里有个常见的坑:重试次数和超时时间要联动。比如你设了 5 次重试,每次超时 2 秒,那最坏情况下一个请求要等 10 秒以上。如果上游也在等你,整个调用链就堵死了。
避坑指南:我曾经在一个项目中,重试次数设了 10 次,超时时间没改。结果一次下游故障,所有请求都在重试,线程池被打满,整个服务挂了。从那以后,我每次设重试次数,都会算一下最坏情况下的总耗时。
3.4 幂等性设计:重试的命根子
前面说的都是「怎么重试」,但还有一个更根本的问题:重试会不会造成数据错误?
比如一个下单接口,第一次调用成功了,但响应超时。客户端重试,又下了一单。用户被扣了两次钱。这就是典型的非幂等问题。
幂等性,说白了就是:同一个操作,执行一次和执行多次,结果一样。
实现幂等性,我常用的方法有几种:
| 方法 | 原理 | 适用场景 | 注意点 |
|---|---|---|---|
| 唯一请求 ID | 客户端生成唯一 ID,服务端去重 | 写操作(下单、转账) | ID 必须全局唯一,服务端要持久化去重记录 |
| 乐观锁 | 用版本号或时间戳判断是否已处理 | 更新操作 | 版本号要递增,不能回退 |
| 状态机 | 操作只能在特定状态下执行 | 流程类操作(订单状态流转) | 状态转换要严格定义 |
| 数据库唯一约束 | 利用数据库的唯一索引防重复 | 插入操作 | 要处理唯一冲突的异常 |
我个人最推荐唯一请求 ID 的方式。客户端每次请求带一个 UUID,服务端处理前先查一下这个 ID 是否处理过。处理过就直接返回上次的结果,不再重复执行。
// 伪代码示例
function handleRequest(requestId, data) {
// 1. 检查 requestId 是否已处理
if (cache.exists(requestId)) {
return cache.get(requestId) // 直接返回上次结果
}
// 2. 执行业务逻辑
result = processData(data)
// 3. 缓存结果
cache.set(requestId, result)
return result
}
重要提醒:幂等性不是「加个唯一 ID」就完事了。你还要考虑:去重记录的过期时间、并发情况下的锁竞争、以及去重记录本身的持久化。我曾经见过一个系统,去重用 Redis,结果 Redis 挂了,所有请求都变成非幂等了。
3.5 完整重试策略框架
把上面四个概念串起来,就是一个完整的重试策略。我画了一张图,帮你理清思路:
这个框架的核心逻辑是:
- 请求失败后,先判断是否达到最大重试次数。到了就放弃,没到就继续。
- 计算退避时间,加上抖动,然后等待。
- 执行重试,但必须确保操作是幂等的。
- 如果重试成功,结束;如果失败,回到第一步。
3.6 实战中的几个坑
最后,分享几个我在实战中踩过的坑:
- 坑一:重试穿透缓存。 有一次我重试时没注意,每次重试都重新查数据库,结果数据库被打爆了。后来在重试逻辑里加了本地缓存,第一次查到的结果后面直接复用。
- 坑二:重试和超时混用。 重试的超时时间和正常请求的超时时间要分开。重试时网络可能更差,超时时间应该更长一些。
- 坑三:忽略业务语义。 不是所有失败都适合重试。比如「余额不足」这种错误,重试 100 次也没用。要根据错误码区分哪些值得重试。
我的习惯:每次上线重试策略前,我都会做一次「最坏情况模拟」:假设所有请求都失败,重试策略会不会把系统搞垮。如果会,就调参数,直到安全为止。
重试策略说难不难,说简单也不简单。指数退避、抖动、最大重试次数、幂等性,这四个要素缺一不可。把它们组合好,你的流水线就能在故障面前多扛一会儿。