幂等请求怎么设计:让重复操作得到同一个结果

网络不保证一次提交只到达一次。客户端超时后会重试,网关会重放,用户会连点按钮,消息系统通常以至少送达一次为契约。如果业务操作不接受重复,这些正常的重试就会变成重复下单、重复扣款或重复发货。幂等的目标很具体:同一业务意图无论被执行多少次,系统对外的可见结果都与只执行一次相同。它不保证中间过程只发生一次,也不代替事务。两次请求都看到成功,资源只产生一份;一次看到超时、一次看到成功,系统里也不该留下两份。

幂等请求怎么设计,让重复操作得到同一个结果
幂等请求怎么设计,让重复操作得到同一个结果放大图解 ↗

有些接口天然接近幂等。按主键删除一条已经不存在的记录,再删一次仍然是“它不在了”。把配置设成某个确定值,重复设置不会累加。给账户增加固定金额就不是这样,因为第二次会把余额再改一截。区分这两类,是设计的起点。读请求一般可以重放,但读也会有副作用,例如把“未读”改成“已读”,或者在审计日志里记一条。若这些副作用不能重复,读也要纳入幂等讨论,不能因为动词是查询就放行。

用业务身份,而不是用偶然的请求

幂等需要一个稳定的意图标识。订单号、支付单号、客户端生成的幂等键,都是这个身份。它应当由发起方在操作开始前生成,并在每一次重试中原样带上。若等服务端分配身份再重试,超时的那一次可能已经创建了资源,客户端却不知道编号,下次会再要一个新编号,去重就失败了。标识的生成要避免碰撞。时间戳加随机数在低并发下常常够用,规模上来后仍可能撞车。更稳的是使用足够长的随机标识,或在用户、设备、业务类型之内保证不重复。

仅有标识还不够,还要定义标识的作用域。同一个键在不同用户、不同接口、不同资源上含义不同。全局唯一能减少误用,但键空间会很大,排查时也不易读。实践中常见的范围是“调用方加接口加业务对象”。用户甲创建订单使用的键,不能被用户乙复用后看到甲的订单。内部服务代调用时,也不能只信任调用方自报的用户身份,作用域里的主体必须来自已经鉴权的身份。键的长度、字符集和有效期要在协议里写清。无限保留所有键,存储会一直涨;保留过短,晚到的重试又会被当成新请求。有效期应覆盖客户端可能重试的窗口、人工补单的窗口,以及下游对账能发现差异的时间。

先占位,再执行

正确的顺序通常是:先以幂等键占位,占位成功才执行业务,执行完成后把响应和资源身份写回这条记录。占位本身要用数据库的唯一约束或等价的原子操作,不能先查询再插入。两个并发重试会同时看到“键不存在”,然后各做一次业务。唯一约束让其中一个插入成功,另一个得到冲突。冲突的一方再去读已有记录,而不是再执行一遍。

占位记录至少要有这些状态:处理中、完成、失败可重试、失败不可重试。只有“完成”才能直接把上一次的响应交还给后来的请求。若状态仍是处理中,后来的请求不应再启动一次业务。它可以等待一小段时间后重读,或返回一个表示处理中的响应,让客户端按原键查询。直接再执行,会在前一次还没提交时造成双写。等待也不能无限。前一次进程可能已经崩溃,占位永远停在处理中。这时需要租约:占位带上过期时间,持有者周期续期,过期后其他工作者才能接管。接管前仍要确认业务是否其实已经完成,例如外部系统里是否已经存在同一单号。不确定时,查询优于盲重做。

完成态要保存对外响应所必需的内容:资源编号、最终状态、对客户端有意义的摘要。保存整个内部对象会让旧版本代码在重放时读到它不认识的字段,或者把后来才产生的敏感字段一并返回。重放的响应应与当时的成功响应一致。若后来订单被履约系统改成已发货,重放创建接口仍应表现创建那一次的结果,并附上可以继续查询的资源身份。用最新状态冒充当时的创建响应,客户端会无法区分“这次创建成功了”和“这是一张早已存在的订单”。

相同的键必须对应相同的意图

幂等键冲突还有另一种情况:键相同,请求体不同。这不是可以折叠的重试,而是调用方错误。系统应拒绝,并返回能够区分的错误,而不是用先前那一次的参数悄悄执行、把第二次的不同金额当成同一次。比较请求体时要忽略不会改变意图的字段,例如请求时间、追踪编号、客户端时钟。金额、币种、收款账户、商品和数量则必须参与比较。比较前要把字段规范化,避免同一语义因空格、键顺序或数字格式不同而误判。

有的业务不能用客户端任意传的键,而要用领域里的自然身份。例如“一个用户对一篇文章只有一次点赞”。此时唯一约束可以直接落在用户和文章上,重复请求返回当前的已赞状态。取消点赞同样按这组身份删除。这种约束比随机幂等键更强,因为它表达的是业务规则,不依赖调用方记得重试时带上同一个键。反过来,创建订单通常没有这样的自然身份,两次真的想买同款商品是合法的,所以必须靠调用方提供的意图键区分“重试”和“再买一单”。不要图省事用用户编号加商品编号做订单去重,那会把合法的第二单吃掉。

外部副作用要单独看

数据库里的唯一约束只能保护自己的写入。调用支付渠道、发短信、推送消息,发生在约束之外。若业务先请求渠道再写本地,本地事务失败后重试会再次请求渠道。更稳的结构是把幂等记录和业务写入放在同一本地事务里,需要外部动作时只写入一条待发送记录,事务提交后再发送。发送方每次都带上同一个下游幂等键。下游若不支持幂等,就只能在本地把“已发送”记下来,并在重试前先查下游单据。查不到且前一次可能还在处理时,不要立刻再发。这会慢一些,但比双扣款更容易补救。

消息消费也是重复执行的高发区。消费者要把消息标识或业务键写入处理记录,并与业务更新放在同一事务。处理完成后再确认消息。确认前崩溃会导致重投,重投因为处理记录已在而直接跳过。若先确认再处理,崩溃会丢消息;若处理与去重记录不在同一事务,就会出现处理成功但去重没记下,于是再处理一次。消费端的幂等粒度要跟业务一致。按消息编号去重,重发的新编号仍会执行;按订单编号去重,才能挡住同一订单的多条投递。

哪些失败可以带着原键再来

不是所有错误都该重试。参数不合法、权限不足、库存明确不足,重复发送不会改变结果,客户端应停止自动重试并展示原因。超时、连接重置、返回处理中,则可以按原键重试。服务端对不可重试的失败也要落记录,否则客户端一重试又会把业务做一遍,再次得到同一个失败,副作用却已重复。当然,若失败发生在任何写入之前,不落记录也可以,因为重试是安全的。界限在于有没有产生外部可见的变化。

幂等记录本身需要权限和保留策略。它含有业务结果,甚至可能含有支付摘要。按键查询必须校验主体,不能让知道或猜到键的人取走别人的响应。日志可以记键的摘要,避免把完整键和完整响应打进普通日志。过期清理要先确认没有仍在重试的老客户端。清理之后,迟到请求会被当成新意图。若此时业务上仍可能重复,就需要第二道自然约束,例如支付单号在渠道侧仍然唯一。

接口文档应当写明:键由谁生成,放在何处,作用域是什么,相同键相同意图返回什么,相同键不同意图返回什么,处理中如何查询,键保留多久。客户端库则负责在重试时复用键,而不是每个 HTTP 客户端自己发明。后台补单、对账任务和人工重放必须走同一套占位逻辑。旁路脚本直接插业务表,是幂等体系最常见的漏洞。

收束

幂等不是给接口加一个请求头就结束。它要求意图有稳定身份,身份有明确作用域和有效期,并发下用原子占位而不是先查后写,完成后能重放一致的结果,键相同而参数不同时能够拒绝。数据库约束、消息去重和外部调用的幂等键要接成一条链。做不到链上的某一环,重复仍会从那一环漏出去。设计时先问清一次操作什么叫“同一次”,再决定键放在哪一层。这个问题比选择哪一个随机算法更决定系统能否承受重试。

见字如晤

图解