← 返回
IT技术

重试风暴实测:退避抖动砍掉近六成无效负载

✍️ zhirenhun 📅 2026/10/14 👁 131 阅读 ⏱ 16 分钟
重试风暴实测:退避抖动砍掉近六成无效负载

重试风暴实测:退避抖动砍掉近六成无效负载

今天刷 dev.to 热榜,看到一篇文章标题相当扎眼——《Your failover is a retry loop pretending to be resilience》,直译过来就是“你的故障转移不过是一个伪装成韧性的重试循环”。评论区一片叫好,但没人给出数字。我第一反应是不太服气:重试循环真的有那么大的破坏力吗?所谓指数退避加抖动,收益到底能不能量化?嘴上说“重试风暴会压垮服务器”的人很多,亲手算过一笔账的没几个。

所以我决定自己动手模拟一次。不搞分布式集群,不上真实压测,就用一个最简单的离散事件模型:200 个客户端抢一个每秒只放行 20 个请求的服务器,一组用裸重试循环,一组用指数退避加抖动,跑上五个回合取平均。下面是我做出来的完整过程和真实结果,有几个数字连我自己都没料到。

热榜上的说法

先说热榜那篇文章的核心论点。作者的立场很尖锐:很多团队把“加重试”当成处理故障的银弹,以为 try-except 外面套个 while 循环就叫高可用,实际上这种做法在系统整体过载时会把局部故障放大成全局雪崩。失败越多,重试越猛;重试越猛,服务器越忙不过来;越忙不过来,失败越多——一个标准的正反馈死循环。

这个说法在工程圈流传很久了,Google SRE 那本书里也反复强调过指数退避和抖动的重要性。但我一直觉得这些论述偏定性,给的都是示意图和故事,缺少一个能直观对比“裸重试”和“退避重试”在同一场景下开销差异的最小模型。这篇热榜文章同样如此——观点鲜明,证据靠经验。作为一个写过不少重试代码的人,我想知道这件事的代价到底长什么样。

实验设计与代码

我的模型刻意做到最简:200 个客户端在同一个时刻发起请求,服务器每个时间片只接受 20 个,也就是 10 倍超载。被拒绝的客户端下一拍立刻重试(裸重试组),或者按指数退避加随机抖动等待(对照组),直到全部成功为止。我统计四个指标:总请求次数、被拒绝的无效请求数、全体完成所需的时间片数、单个时间片内的峰值请求数。为了排除随机性干扰,同一个随机种子跑 5 轮取平均。

完整代码如下,纯 Python 标准库,直接就能跑:

import random

random.seed(42)
N, CAP, MAXT, TRIALS = 200, 20, 3000, 5


def simulate(use_backoff):
    att_sum = rej_sum = tick_sum = burst_sum = 0
    for _ in range(TRIALS):
        # each client: [next_retry_tick, consecutive_failures]
        clients = [[0, 0] for _ in range(N)]
        remaining, t = N, 0
        attempts = rejected = burst = 0
        while remaining and t < MAXT:
            arriving = [i for i, c in enumerate(clients) if c[0] == t]
            attempts += len(arriving)
            burst = max(burst, len(arriving))
            if len(arriving) <= CAP:
                winners = arriving
            else:
                rejected += len(arriving) - CAP
                winners = random.sample(arriving, CAP)
            for i in winners:
                clients[i][0] = MAXT + 1
                remaining -= 1
            for i in arriving:
                if i not in winners:
                    c = clients[i]
                    if use_backoff:
                        c[1] += 1
                        wait = int(min(2  c[1], 64) * random.uniform(0.5, 1.5))
                        c[0] = t + max(1, wait)
                    else:
                        c[0] = t + 1
            t += 1
        att_sum += attempts
        rej_sum += rejected
        tick_sum += t
        burst_sum += burst
    n = TRIALS
    return att_sum / n, rej_sum / n, tick_sum / n, burst_sum / n


naive = simulate(False)
smart = simulate(True)
print('Retry strategies: 200 clients, server accepts 20 req/tick')
print('-' * 50)
print(f"{'strategy':<20}{'attempts':>9}{'wasted':>8}{'ticks':>7}{'peak/tick':>11}")
for name, r in (('naive retry loop', naive), ('backoff + jitter', smart)):
    waste = 100 * r[1] / r[0]
    print(f"{name:<20}{r[0]:>9.0f}{r[1]:>8.0f}{r[2]:>7.0f}{r[3]:>11.0f}  {waste:.0f}% wasted")
cut_att = 100 * (naive[0] - smart[0]) / naive[0]
cut_rej = 100 * (naive[1] - smart[1]) / naive[1]
print(f'Backoff cut total attempts by {cut_att:.0f}% and wasted load by {cut_rej:.0f}%')
print(f'Latency cost: {smart[2] - naive[2]:.0f} extra ticks on average')
print(f'Peak retry burst: {naive[3]:.0f} -> {smart[3]:.0f} requests/tick')

设计上有个关键点:抖动系数取 0.5 到 1.5 之间的均匀随机数,退避基数从 2 指数增长并封顶 64。这意味着同一个客户端不会整齐划一地在同一拍重试——这正是抖动的全部意义所在。如果不加抖动,只做纯指数退避,所有被拒绝的客户端还是会在相同的拍子上同步苏醒,风暴只是被推迟,并没有被打散。

真实运行结果

跑出来的终端输出长这样:

实验真实运行输出

先看裸重试那行:1100 次总请求,其中 900 次是被拒绝的无效负载,浪费比例 82%。也就是说,200 个客户端完成全部任务,真实需要的只有 200 次请求,剩下的 900 次全是服务器帮客户端白做的丢弃工作。每拍峰值 200 个请求同时砸向一个容量只有 20 的服务器,全程 10 拍结束。这就是重试风暴的形态——不是慢慢压垮,是每拍都满额撞击。

再看退避加抖动那一行:总请求从 1100 降到 567,无效负载从 900 降到 367。脚本自己算的结论是:退避把总请求量砍了 48%,把无效负载砍了 59%。代价是完成时间从 10 拍变成 18 拍,平均多花 8 拍。换句话说,客户端多等了不到一倍的时间,服务器少做了一大半无用功。这笔账对任何容量紧张的服务来说都是稳赚的——你花 8 拍的延迟,换掉 533 次注定失败的请求。

反直觉发现/局限

最让我意外的一个数字:加了退避之后,峰值并发一点都没降,两组都是每拍 200。我原本预期退避能把那个 200:20 的撞击比拉平成一条缓坡,结果没有。原因想通之后很简单——所有客户端在 t=0 同时首发,这个初始冲击是重试策略管不到的,退避只能管住“被拒绝之后”的重试节奏。所以热榜那篇文章的说法要打个折:退避防的是重试风暴,防不了首发洪峰。首发洪峰得靠限流、排队或者客户端错峰启动去解。

第二个反直觉点是退避组的浪费率还有 65%,比裸重试的 82% 只低了 17 个百分点,远没到“问题解决”的程度。因为我把重试上限封在 64 拍,撞得次数多的客户端照样会带着 64 拍的等待周期继续撞。另外这个模型也有明显的简化:服务器没有崩溃机制,被拒的请求零成本丢弃,没有连接超时、没有内存回收、没有级联故障。真实世界里裸重试的危害只会比模拟里更夸张,因为 900 次无效请求在真实服务器上是要消耗线程、内存和下游调用的。反过来说,我的模型也高估不了退避的收益——它给出的 48% 和 59% 是一个非常干净的上界场景。

还有个翻车点值得记一笔:我第一版代码忘了加 max(1, wait),退避量算出来是 0 的时候客户端会在同一拍无限重试,模拟直接卡死。这本身就是个隐喻——重试逻辑里的边界条件,写的人不看输出永远发现不了。

我的结论

这次实验把热榜那篇文章的定性判断变成了可核对的数字:在 10 倍超载的模拟里,指数退避加抖动用 8 拍的延迟代价,砍掉了 48% 的总请求和 59% 的无效负载。所谓“伪装成韧性的重试循环”,危害不是修辞,是实实在在的 900 次无效请求对 367 次的差距。你服务器的 CPU 没有理由替客户端的错误策略打工。

我的建议具体到可执行:检查你手头每一个重试循环,确认三件事——有没有指数退避、退避基数有没有封顶、重试间隔有没有乘上随机抖动。三个都齐了才算韧性,缺一个都只是伪装。而如果你面对的是首发洪峰而不是重试风暴,这个实验告诉我退避帮不了你,需要的是限流和错峰——这也是我下一步想单独测一测的题目。

——

广告 广告
🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

Python 并发重试 指数退避