3. 超时重试机制设计原则:指数退避、抖动、最大重试次数
超时重试,说白了就是「这次没响应,等会儿再试一次」。但怎么等、等多久、试几次,这里面门道很深。我见过不少项目,重试策略写得太粗暴,结果服务没恢复,反而把系统搞垮了。
这一节,我们聊聊三个核心原则:指数退避、抖动、最大重试次数。掌握了它们,你的 SOME/IP 客户端在面对网络抖动或服务重启时,会从容很多。
3.1 为什么不能固定间隔重试?
先看一个反面案例。我以前接手过一个项目,客户端发现服务端没响应,就每隔 500ms 重试一次。看起来挺合理对吧?
问题出在哪?你想想看,如果服务端因为过载挂了,500ms 后它大概率还没恢复。客户端们同时发起重试,等于给服务端又来了一波冲击。这叫惊群效应——重试不但没解决问题,反而加剧了故障。
固定间隔还有另一个坑:如果网络只是短暂抖动,500ms 可能不够;如果服务真的挂了,重试再多次也是白费。所以我们需要一种更聪明的策略——让重试间隔逐渐变大。
3.2 指数退避:让等待时间「涨」起来
指数退避的核心思想很简单:每次重试失败后,把等待时间翻倍。
比如第一次等 100ms,第二次等 200ms,第三次等 400ms,第四次等 800ms……这样既给了服务恢复的时间,又不会在短时间内发起大量请求。
公式长这样:
wait_time = base_delay × (2 ^ retry_count)
其中 base_delay 是基础延迟,retry_count 是当前重试次数(从 0 开始)。
我在项目中常用的配置:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| base_delay | 100ms | 基础等待时间,太短容易浪费资源,太长影响用户体验 |
| max_delay | 5s | 最大等待时间,防止退避到离谱的数值 |
| max_retries | 5 次 | 超过这个次数,直接走降级逻辑 |
用 Python 实现一下:
def exponential_backoff(base_delay=0.1, retry_count=0, max_delay=5.0):
delay = base_delay * (2 ** retry_count)
return min(delay, max_delay)
嗯,这里要注意:max_delay 是个安全阀。没有它,重试 10 次后等待时间会超过 100 秒,这在实际场景中不可接受。
3.3 抖动:别让客户端们「齐步走」
指数退避解决了「等多久」的问题,但没解决「大家一起等」的问题。
假设有 100 个客户端同时发现服务不可用,它们都按指数退避计算等待时间。结果呢?所有客户端都在第 100ms、第 200ms、第 400ms 同时发起重试。服务端刚喘口气,又被一波流量拍倒了。
抖动的目的就是让每个客户端的等待时间随机化,避免同步重试。
我常用的抖动策略有两种:
- 全抖动:在 [0, wait_time] 之间随机取一个值。简单粗暴,但可能导致等待时间过短。
- 等比例抖动:在 [wait_time × 0.5, wait_time × 1.5] 之间随机取一个值。既保留了指数退避的趋势,又加入了随机性。
我个人更推荐等比例抖动,它更可控。实现如下:
import random
def jittered_backoff(base_delay=0.1, retry_count=0, max_delay=5.0):
delay = base_delay * (2 ** retry_count)
delay = min(delay, max_delay)
# 等比例抖动:±50%
jitter = delay * 0.5
return delay - jitter + random.uniform(0, jitter * 2)
3.4 最大重试次数:别让重试变成死循环
重试不是万能的。如果服务真的挂了,或者网络彻底断了,重试 100 次也没用。所以必须设置一个最大重试次数。
超过这个次数后,应该做什么?我建议走服务降级流程:
- 记录错误日志,告警
- 切换到本地缓存或备用服务
- 通知上层调用方,返回降级结果
我曾经遇到过一个线上事故:某个客户端没有设置最大重试次数,结果服务端重启时,客户端在 10 分钟内重试了 3000 多次,把日志系统都打满了。从那以后,我定了一条铁律:任何重试逻辑必须有上限。
最大重试次数怎么定?我一般参考这个表格:
| 场景 | 推荐最大重试次数 | 理由 |
|---|---|---|
| 网络短暂抖动 | 3 次 | 短时间恢复概率高,重试太多浪费资源 |
| 服务重启 | 5 次 | 服务重启通常需要几秒到十几秒 |
| 服务过载 | 2 次 | 过载时重试只会雪上加霜,尽快降级 |
3.5 完整策略:把三者结合起来
指数退避、抖动、最大重试次数,这三个原则不是孤立的。在实际项目中,我把它们组合成一个完整的重试策略:
class RetryStrategy:
def __init__(self, base_delay=0.1, max_delay=5.0, max_retries=5):
self.base_delay = base_delay
self.max_delay = max_delay
self.max_retries = max_retries
def get_wait_time(self, retry_count):
if retry_count >= self.max_retries:
return None # 超过最大次数,不再重试
delay = self.base_delay * (2 ** retry_count)
delay = min(delay, self.max_delay)
jitter = delay * 0.5
return delay - jitter + random.uniform(0, jitter * 2)
def should_retry(self, retry_count, error):
if retry_count >= self.max_retries:
return False
# 某些错误不值得重试,比如认证失败
if error in [ErrorType.AUTH_FAILED, ErrorType.INVALID_REQUEST]:
return False
return True
3.6 核心逻辑流程图
下面这张图总结了超时重试的完整决策流程:
3.7 实战中的几个坑
最后分享几个我踩过的坑,希望能帮你少走弯路:
- 重试次数别设太大。 5 次以内足够了。超过 5 次,等待时间已经很长,用户体验很差,不如早点降级。
- 抖动不是可选项,是必选项。 没有抖动的指数退避,在集群环境下就是定时炸弹。
- 记录重试日志。 我习惯在每次重试时打印一条 WARN 级别的日志,包含重试次数和当前等待时间。这样排查问题时一目了然。
- 考虑业务场景。 有些请求对实时性要求高(比如转向控制),重试反而有害。这种情况下,直接降级比重试更合理。
核心要点回顾:
- 指数退避让等待时间逐渐增大,避免冲击服务
- 抖动让等待时间随机化,防止客户端同步
- 最大重试次数是安全阀,防止无限重试
- 三者结合,才能构建健壮的超时重试机制