服务出问题时,客户端会重试;重试策略选不好,反而会把恢复中的服务再次压垮。这篇不靠推公式,而是启动 100 个真实线程,让它们分别用固定间隔、指数退避、指数退避加抖动去重试同一个「先宕机、后限流」的服务,把每 50 ms 打出去多少请求真的数出来。
三种重试间隔 def delays(strategy, rng, n): for k in range(n): if strategy == "fixed": yield 0.05 elif strategy == "exp": yield min(0.02 * 2 ** k, 0.32) elif strategy == "exp_jitter": yield rng.uniform(0, min(0.02 * 2 ** k, 0.32)) # 全抖动
fixed 50.0 50.0 50.0 50.0 50.0 50.0 50.0 50.0 (ms) exp 20.0 40.0 80.0 160.0 320.0 320.0 320.0 320.0 (ms) exp_jitter 2.7 33.9 61.1 40.8 158.5 143.8 208.5 252.4 (ms)
固定间隔永远是 50 ms;指数退避每次翻倍直到 320 ms 封顶;加了全抖动(full jitter)之后,每次等待是 0 到指数上限之间的随机值,同一个客户端的每次间隔不同,不同客户端之间更不同。
服务端的模型:0 到 300 ms 完全不可用,之后每个 20 ms 的窗口只能处理 15 个请求,多的被拒绝。100 个线程同时开始。每种策略跑 3 次,取总请求数居中的那一次。
fixed 总请求 833 | 宕机期间 600 | 被限流拒绝 133 | 成功 100 | 全部完成用时 579 ms | 任意 10 ms 内最多 100 个请求 (3 次取总请求数居中的一次) exp 总请求 674 | 宕机期间 400 | 被限流拒绝 174 | 成功 100 | 全部完成用时 1636 ms | 任意 10 ms 内最多 100 个请求 (3 次取总请求数居中的一次) exp_jitter 总请求 645 | 宕机期间 545 | 被限流拒绝 0 | 成功 100 | 全部完成用时 606 ms | 任意 10 ms 内最多 126 个请求 (3 次取总请求数居中的一次)
固定间隔:重试最多,833 个请求里 133 个被限流拒绝。指数退避(无抖动):请求总数少了,但被拒绝的更多(174),而且完成得最晚(1636 ms)。指数退避加抖动:0 次被拒绝,606 ms 完成。「指数退避」本身并不等于「更好」——没有抖动时,所有客户端的重试时刻依然完全同步。(表里「服务恢复后任意 10 ms 内最多」一列的数字包含了 t=0 时 100 个客户端同时开始的那一次齐发,所以不是判断重试风暴的好指标;请看下一节的分布。)
fixed [100, 100, 100, 100, 100, 100, 98, 49, 23, 40, 19, 4, 0, 0, 0, 0] exp [200, 100, 0, 100, 0, 0, 100, 0, 0, 0, 0, 0, 10, 75, 0, 0] exp_jitter [290, 77, 74, 45, 31, 28, 28, 25, 19, 12, 12, 3, 1, 0, 0, 0]
固定间隔:前 6 格每格整整 100 个请求——所有客户端步调一致。指数退避无抖动:格子里不是 0 就是 100 的整数倍([200, 100, 0, 100, 0, 0, 100, …]),请求集中在几个瞬间同时到达,像一次次脉冲。抖动之后是平滑衰减的曲线:[290, 77, 74, 45, 31, 28, …](第一格的 290 包含了 t=0 时 100 个客户端的第一次请求)。
Transient 函数被调用 4 次后抛出 Transient(retry_on=Transient) ValueError 函数被调用 1 次后抛出 ValueError(retry_on=Transient)
重试函数用 retry_on=Transient 限定要重试的异常。暂时性错误重试到上限(共 4 次调用);参数错误(ValueError)只调用 1 次就抛出——重试不会让错误的参数变对。另外,对有副作用的操作(比如「扣款」)重试还要保证幂等,这一点本篇没有验证。
threading、random;时间用 time.perf_counter,线程调度存在毫秒级抖动,因此每种策略跑 3 次取居中结果,具体数字每次会略有不同