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)
我的经验:抖动范围不要超过 ±50%。范围太大,等待时间可能变得不可预测;范围太小,又起不到分散效果。±50% 是个不错的平衡点。

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
避坑指南:我曾经犯过一个错误——对所有错误都重试。结果认证失败也重试,白白浪费了 5 次请求。后来我加了一个错误类型白名单,只有网络超时、服务不可用这类错误才触发重试。这个改动让系统的无效请求减少了 80%。

3.6 核心逻辑流程图

下面这张图总结了超时重试的完整决策流程:

超时重试决策流程图 发起 SOME/IP 请求 是否超时? 是 返回成功结果 否 是否可重试? 否 触发服务降级 是 计算指数退避 + 抖动 等待 delay 时间 重试次数 +1 关键说明: 1. 指数退避:每次重试等待时间翻倍,避免短时间内大量请求 2. 抖动:在等待时间上加入随机偏移,防止客户端同步重试 3. 最大重试次数:超过上限后触发服务降级,避免无限重试 4. 错误类型过滤:只有可恢复的错误才触发重试

3.7 实战中的几个坑

最后分享几个我踩过的坑,希望能帮你少走弯路:

  • 重试次数别设太大。 5 次以内足够了。超过 5 次,等待时间已经很长,用户体验很差,不如早点降级。
  • 抖动不是可选项,是必选项。 没有抖动的指数退避,在集群环境下就是定时炸弹。
  • 记录重试日志。 我习惯在每次重试时打印一条 WARN 级别的日志,包含重试次数和当前等待时间。这样排查问题时一目了然。
  • 考虑业务场景。 有些请求对实时性要求高(比如转向控制),重试反而有害。这种情况下,直接降级比重试更合理。

核心要点回顾:

  • 指数退避让等待时间逐渐增大,避免冲击服务
  • 抖动让等待时间随机化,防止客户端同步
  • 最大重试次数是安全阀,防止无限重试
  • 三者结合,才能构建健壮的超时重试机制