接口重试与幂等:一次超时到底引发了多少件小事
写服务间调用的时候,我们习惯把结果分成成功和失败两类。但真实的网络调用有三种结局:成功、明确失败、以及在约定的时限内没有回答。第三种最麻烦,因为你不知道对面到底做没做。
一、超时不是一种错误,是一种状态
举个例子。A 调 B 下单,客户端超时设了 500 毫秒,B 的数据库写入加风控校验实际用了 800 毫秒。对 A 来说这是一个 TimeoutError;对 B 来说订单已经落库了。此时 A 按照可重试异常的定义又发了一次请求,就有了两笔订单。如果把场景换成扣款,就是重复扣款。
所以第一步不是把超时时间调大,而是承认:超时之后,对面处于未知状态,任何自动重试都必须建立在重复执行无害的前提上。
二、幂等的三个层次
第一层是天然幂等。查询、设置类操作属于这层。注意语义区别:把库存设为 3 是幂等的,把库存加 1 不是。很多接口名字里写着 set,实现里却是累加,这是重复扣减的源头。
第二层是幂等键。由调用方生成一个请求标识,随请求一起发出去;服务端记住这个键和它的处理结果,同一个键再来就直接返回第一次的结果,不再执行。
def with_idempotency(store, key, payload, handler):
if key in store:
rec = store[key]
if rec[payload_hash] != payload_hash_of(payload):
raise Conflict(key)
return rec[result]
result = handler(payload)
store[key] = {payload_hash: payload_hash_of(payload), result: result}
return result
两个细节最容易漏:一是键必须和参数指纹一起校验,同一个键配不同参数要拒绝,不能默默返回旧结果;二是存储要有过期时间,否则这张表会无限长大,而过期太短又会让慢重试失去保护。
第三层是状态机加版本号。用一条条件更新语句把重复执行挡在数据库里:
update orders set status = PAID, version = version + 1
where id = ? and status = UNPAID
受影响行数为 0,说明这单已经被处理过或者状态不对,调用方把它当成成功而不是异常。这层的代价是并发可见性最好,好处是不需要额外的存储。
三、退避不是重试次数那么简单
固定间隔重试会在下游刚恢复的时候再来一波洪峰。指数退避缓解了这一点,但如果所有客户端用同样的底数和同样的初始时刻,退避之后的时间点仍然会撞在一起。加一个随机抖动把它打散:
import random, time
def backoff(attempt, base=0.2, cap=8.0):
raw = min(cap, base * (2 ** attempt))
return random.uniform(0, raw)
还有一条经常被忽略的:重试要有预算。比较实用的做法是只允许一小部分请求携带重试,比如同一个接口在一分钟窗口内最多 10% 的流量是重试流量,超了就快速失败。这样下游故障时不会被上游的自救行为打死。
四、三个真实的坑
- 超时值小于下游的 p99。看上去是偶发,实际是每百次请求稳定超出一两次,重试逻辑长期开着,故障时放大成雪崩。
- 把非幂等接口包成幂等。在客户端记录我已经发过了并不能替代服务端幂等键,进程重启、多实例部署、并发副本都会绕过这层保护。
- 重试边界和事务边界不一致。一次调用写了主表成功、写流水失败,重试时主表那步又被执行了一遍。跨多张表的写操作要么收敛到一个事务里,要么每一步都自带幂等键。
五、上线前自检清单
- 这个接口重复执行一次,结果和执行一次是否完全一致?
- 幂等键由谁生成,参数变了键变不变?
- 幂等记录的生命周期多长,和重试的最大时间窗是否匹配?
- 超时值是照抄默认配置,还是从下游的实际耗时分布算出来的?
- 重试有没有次数上限、总时长上限和抖动?
- 重试风暴时,下游有没有被保护住的手段(限流、熔断、重试预算)?
这几条里最值得先做的是第二和第三条。幂等键是调用方和被调方之间的一份约定,早定比晚定便宜;等真的出了一次重复扣款再去补,就要同时处理历史脏数据和线上兼容了。
- 点赞
- 收藏
- 关注作者
评论(0)