常见重试模式:从线性到渐进,你该选哪个?
重试策略听起来简单——失败了就再来一次呗。但实际落地时,你会发现「怎么重试」比「要不要重试」复杂得多。我见过太多团队直接写个 for i in range(3): try...except 就上线了,结果线上把数据库打崩了。
这一章,我们来拆解五种最常见的重试模式。每种模式都有它的适用场景,也有它的坑。我会结合自己的踩坑经历,帮你理清楚什么时候该用哪种。
核心观点:没有最好的重试模式,只有最合适的。选错了,比不重试更危险。
固定间隔重试:简单但危险
固定间隔重试是最直观的模式。每次重试之间等待相同的时间,比如 1 秒、5 秒。实现起来几乎零成本:
import time
def retry_fixed_interval(func, max_retries=3, interval=2):
for attempt in range(max_retries):
try:
return func()
except Exception as e:
if attempt == max_retries - 1:
raise
print(f"第 {attempt+1} 次失败,{interval}秒后重试...")
time.sleep(interval)
这个模式最大的问题是什么?容易造成「踩踏效应」。我曾在一次线上事故中亲眼看到:100 个微服务实例同时检测到数据库连接失败,然后同时等待 5 秒,再同时重试——数据库直接被冲垮了。
避坑指南:固定间隔重试只适合以下场景:
- 客户端数量极少(比如 1-3 个)
- 失败原因是临时性的(如网络抖动)
- 下游服务有足够的冗余容量
否则,请考虑其他模式。
线性重试:可预测的递增
线性重试让等待时间随着重试次数线性增长。比如第一次等 1 秒,第二次等 2 秒,第三次等 3 秒。这样做的好处是:给下游服务留出「喘息」的时间。
def retry_linear(func, max_retries=3, base_interval=1):
for attempt in range(max_retries):
try:
return func()
except Exception as e:
if attempt == max_retries - 1:
raise
wait = base_interval * (attempt + 1)
print(f"等待 {wait} 秒后重试...")
time.sleep(wait)
我个人习惯在调用外部 API 时用线性重试。为什么?因为很多 API 的限流策略是「窗口式」的,线性增长能刚好避开限流窗口。不过要注意,如果重试次数太多,等待时间会变得很长——你想想看,第 10 次重试要等 10 秒,用户体验就崩了。
指数退避:业界标准方案
指数退避是分布式系统中最常用的重试模式。每次重试的等待时间按指数增长:1s、2s、4s、8s、16s……
def retry_exponential_backoff(func, max_retries=5, base=1, cap=60):
for attempt in range(max_retries):
try:
return func()
except Exception as e:
if attempt == max_retries - 1:
raise
wait = min(base * (2 ** attempt), cap)
print(f"等待 {wait} 秒后重试...")
time.sleep(wait)
这里有个关键参数:cap(上限)。不加 cap 的话,第 10 次重试要等 512 秒,这显然不合理。我一般把 cap 设为 30 秒或 60 秒,具体看业务容忍度。
我的经验:指数退避最适合「下游服务过载」的场景。比如数据库连接池满了、消息队列堆积了。指数增长给了下游足够的时间来恢复。但要注意——如果所有客户端都用相同的指数退避策略,它们会在同一时间点重试,这就是「惊群效应」。
带抖动的指数退避:解决惊群效应
惊群效应的解法很简单:加随机抖动。在指数退避的基础上,给等待时间加上一个随机偏移量,让不同客户端的重试时间错开。
import random
def retry_with_jitter(func, max_retries=5, base=1, cap=60):
for attempt in range(max_retries):
try:
return func()
except Exception as e:
if attempt == max_retries - 1:
raise
exponential = min(base * (2 ** attempt), cap)
jitter = random.uniform(0, exponential * 0.5)
wait = exponential + jitter
print(f"等待 {wait:.2f} 秒后重试...")
time.sleep(wait)
抖动有两种常见实现:
| 抖动类型 | 公式 | 特点 |
|---|---|---|
| 全抖动 | random(0, exponential) |
最大分散度,但等待时间不可预测 |
| 等比例抖动 | exponential + random(0, exponential * 0.5) |
保留指数增长趋势,同时错开时间 |
我推荐用等比例抖动。全抖动虽然分散效果好,但有时候等待时间会特别短,起不到「退避」的作用。等比例抖动在「退避」和「分散」之间取得了平衡。
核心建议:如果你的系统有超过 10 个客户端同时重试,请务必使用带抖动的指数退避。这是 AWS、Google Cloud 等云厂商的推荐做法。
渐进式重试:灵活的分段策略
渐进式重试不是简单的数学公式,而是分段定义的重试策略。你可以为不同的重试阶段设置不同的间隔和行为。
def retry_progressive(func, stages):
"""
stages: [(interval, max_attempts), ...]
示例: [(1, 2), (5, 3), (30, 5)]
"""
total_attempts = 0
for interval, max_attempts in stages:
for _ in range(max_attempts):
try:
return func()
except Exception as e:
total_attempts += 1
if total_attempts == sum(s[1] for s in stages):
raise
print(f"等待 {interval} 秒后重试...")
time.sleep(interval)
举个例子:
- 第一阶段:每 1 秒重试,最多 2 次(应对瞬时故障)
- 第二阶段:每 5 秒重试,最多 3 次(应对短暂恢复)
- 第三阶段:每 30 秒重试,最多 5 次(应对慢恢复)
这种模式特别适合与外部第三方服务交互的场景。我记得有一次对接支付网关,对方文档里明确写了「前两次重试间隔 1 秒,之后间隔 10 秒」——这不就是渐进式重试嘛。
注意:渐进式重试的配置复杂度较高。你需要根据实际监控数据来调整每个阶段的参数。我建议先用指数退避兜底,等积累了足够的线上数据后,再优化为渐进式策略。
如何选择?一张表说清楚
| 模式 | 适用场景 | 风险 | 推荐指数 |
|---|---|---|---|
| 固定间隔 | 单客户端、低负载 | 踩踏效应 | ★★☆☆☆ |
| 线性重试 | API 限流场景 | 等待时间过长 | ★★★☆☆ |
| 指数退避 | 通用场景、下游过载 | 惊群效应 | ★★★★☆ |
| 抖动指数退避 | 多客户端、高并发 | 实现稍复杂 | ★★★★★ |
| 渐进式重试 | 第三方服务、精细控制 | 配置复杂 | ★★★★☆ |
我个人最常用的是带抖动的指数退避。它足够通用,实现简单,而且能应对大部分线上场景。只有在对接特定第三方服务时,我才会切换到渐进式重试。
嗯,这里要提醒一句:重试策略不是写出来就完事了。你需要监控重试的成功率、平均等待时间、对下游的影响。我曾经见过一个团队用了指数退避,但没设 cap,结果重试等待时间飙到 5 分钟——用户早跑了。