分布式调用失败了怎么办:重试、退避与幂等键的设计
分布式调用失败了怎么办:重试、退避与幂等键的设计
调用失败分三类,再决定要不要重试。能重试的前提是幂等,没有幂等,重试就是在制造重复副作用。退避方式决定了重试会不会把一次抖动变成一波雪崩。
一、失败分三类,处理方式不一样
我把一次远程调用的结果分成三类,代码里也按这三类分支:
- 明确失败:对方返回了 4xx、5xx,或者连接被拒绝。调用方知道这次没生效,可以按规则重试,或者直接报错。
- 成功:对方返回 200,业务结果明确,不需要再做任何事。
- 未知结果:超时、连接中断、对方没回包。这是最麻烦的一类。对方可能已经把订单写进去了,也可能根本没收到。调用方只知道自己没拿到结果,不知道对方做了什么。
第三类是很多重试 bug 的根源。把超时一律当成失败去重试,等于默认对方没生效,这个假设在超时场景下不成立。
二、没有幂等,就不要重试
幂等的意思很具体:同一个请求发一次和发多次,对业务状态的影响是一样的。实现方式通常是一个幂等键:
- 客户端在第一次调用时生成一个唯一键,随请求一起发过去,键和业务请求绑定,不能每次重试换一个新键。
- 服务端收到请求查键:如果这个键已经处理过,直接返回第一次的结果,不再重复执行业务逻辑。
- 键要设置过期时间。永久保存所有键,存储会一直涨;过期太短,重试窗口外的重复请求又会穿透。
没有这一步,重试次数越多,重复扣款、重复下单、重复发券的概率越高。幂等键不是可选项,是重试的前提。
三、退避:固定、指数、加抖动
重试间隔有三种常见写法,效果不一样:
- 固定间隔:每次都等 0.5 秒。实现简单,缺点是所有失败的客户端会在同一时刻一起重试,容易把服务端的恢复期打满。
- 指数退避:第一次等 0.5 秒,第二次 1 秒,第三次 2 秒,依次翻倍,并设一个上限,比如 4 秒。给服务端留出恢复时间。
- 指数退避加抖动:在指数退避的基础上乘一个 0.5 到 1 之间的随机数。抖动的作用是把重试时刻打散,避免大量客户端在同一秒同时回来。
下面是一段可以直接跑的 Python 装饰器,演示指数退避加抖动和幂等键的配合。注意 idempotency_key 是调用方传入的,不是每次重试重新生成的。
import time
import random
import functools
def retry_with_backoff(max_attempts=4, base_delay=0.5, max_delay=4.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, idempotency_key=None, **kwargs):
delay = base_delay
last_error = None
for attempt in range(max_attempts):
try:
return func(*args, idempotency_key=idempotency_key, **kwargs)
except TimeoutError as e:
# 未知结果:只能用同一个幂等键重试,不能换键
last_error = e
except ConnectionError as e:
last_error = e
if attempt == max_attempts - 1:
break
# 指数退避加抖动,并设上限
wait = min(delay, max_delay) * random.uniform(0.5, 1.0)
time.sleep(wait)
delay *= 2
raise last_error
return wrapper
return decorator
这段代码有意只捕获超时和连接错误。业务上的明确失败,比如参数错误、余额不足,不应该进重试分支, retry 只会把一次明确错误重复四次。
四、本地模拟:三组对比
为了看清楚区别,我在本地跑了一组模拟,不是生产数据。参数写在前面:200 次调用,服务每次有 30% 明确失败概率,10% 超时概率,超时那次服务端已经生效了。最多重试 4 次,固定退避每次 0.5 秒,指数退避从 0.5 秒起步、上限 4 秒、乘 0.5 到 1 的抖动。随机种子固定为 42,方便复现。
结果如下,成功是指调用方最终拿到了明确的成功结果:
| 策略 | 调用方确认成功 | 总调用次数 | 重复副作用次数 | 平均等待时间 |
|---|---|---|---|---|
| 不重试 | 114 / 200 | 200 | 0 | 0 秒 |
| 固定 0.5 秒重试 | 196 / 200 | 321 | 25 | 0.302 秒 |
| 指数退避加抖动重试 | 198 / 200 | 300 | 18 | 0.256 秒 |
三点值得注意:
- 不重试的成功率最低,114 次。很多调用其实已经生效了,只是调用方不知道,等于把成功当成失败丢掉了。
- 重试把确认成功拉到了 196 次和 198 次,但没有幂等的情况下,重复副作用分别是 25 次和 18 次。重试次数越多,没有幂等的代价越明显。
- 指数退避加抖动在总调用次数和平均等待上都比固定退避低一点,198 次成功、300 次总调用、平均等待 0.256 秒。抖动没有让成功率爆炸式提升,它的作用是把重试时刻打散,减轻同一时刻的冲击。
如果服务端按幂等键去重,上面 25 次和 18 次重复副作用在设计上应该归零:同一个键第二次来,服务端直接返回第一次的结果,不再执行业务逻辑。重试的收益保留,重复的代价被幂等键挡住。
五、容易踩的坑
- 超时叠加:单次调用超时 10 秒,重试 4 次,总耗时可能超过调用方的整体超时。重试次数和单次超时要一起算,不能只看单次。
- 重试放大:一个请求失败后重试 4 次,等于把一次失败放大成 4 次请求。服务刚恢复时,大量重试同时回来,可能把服务再次压垮。这时应该降重试次数,或者配合熔断,停一会儿再放流量。
- 换键重试:每次重试生成新幂等键,等于告诉服务端这是三个不同的请求,幂等设计直接失效。
- 对明确失败重试:参数错了、权限错了,重试多少次结果都一样,只会浪费调用次数和日志。
六、上线前检查清单
- 这次失败属于明确失败、成功、未知结果中的哪一类?
- 这个接口有没有幂等键?重试时键是不是同一个?
- 单次超时、重试次数、退避上限加起来,总耗时在不在调用方的超时预算内?
- 退避有没有抖动?大量客户端同时失败时,会不会在同一秒一起回来?
- 幂等键的过期时间是多少?存储涨到多少需要清理?
- 重试失败后,调用方给用户的报错有没有说清楚:是明确失败,还是结果未知、需要人工核对?
把这六条过一遍,再决定重试策略。重试本身不难,难的是在超时和未知结果面前,不把一次失败变成两次副作用。
- 点赞
- 收藏
- 关注作者
评论(0)