API 幂等真正解决的,是“结果未知以后怎么办”

很多人第一次听到 API 幂等,是从“防重复提交”开始的。

用户点了一次提交,页面没反应,又点了一次。前端给按钮加个 loading,提交后禁用三秒,看起来问题就解决了。再高级一点,加个 token,或者在 Redis 里 setnx 一下,同一个请求不要重复处理。

这些做法不是没用。

但如果你把 API 幂等理解成“防用户手快”,就会低估它真正要处理的场景。线上很多重复执行,并不是用户连续点了两下按钮,而是系统不知道上一件事到底发生到哪一步了。

请求发出去了,客户端超时了。

支付渠道扣款成功了,但响应丢了。

消息队列投递了一次,消费者处理到一半重启了。

Webhook 已经通知过你,但对方没收到你的 2xx,于是又发了一次。

后台补偿任务昨晚跑失败,今天人工补跑。

这些时候,问题不是“前端有没有挡住第二次点击”,而是:

当结果未知时,调用方一定会想办法再来一次。服务端要保证再来一次不会把业务改坏。

这才是 API 幂等真正解决的问题。

幂等不是按钮防抖

前端防连点解决的是用户体验层面的重复触发。

它可以减少无意义请求,也可以让页面状态更清楚。但它挡不住这些情况:

  • 用户刷新页面后重提
  • 客户端超时后自动重试
  • 网关、SDK 或任务框架重试
  • MQ 至少一次投递
  • 第三方 webhook 重复通知
  • 人工补偿和批量重放
  • 攻击者拿旧请求重新提交

前端按钮只活在浏览器当前页面里。服务端面对的是网络、队列、数据库、第三方系统、后台任务和人肉运维。

所以服务端幂等的第一步,是承认一个事实:

同一个业务意图可能会从多个入口、在不同时间、以不同姿势再次到达。

如果这个业务意图是“查一下订单列表”,重复到达通常没什么。如果它是“扣款”“发券”“创建订单”“发货”“记账”“领取奖励”,重复到达就可能产生真实损失。

按钮禁用只能减少“点了两下”。它不能回答“第一次到底成功了吗”。

幂等要回答的是这个问题。

HTTP 方法的幂等,和业务写操作的幂等,不是一回事

HTTP 规范里确实有幂等方法的概念。RFC 9110 把 PUTDELETE 和安全方法定义为幂等方法:同一个请求执行多次,对服务端的预期效果应当和执行一次相同。规范还提醒,客户端不应该自动重试非幂等请求,除非它知道这个请求语义本身就是幂等的。[1]

这很重要,但它只解决一层语义问题。

很多业务事故发生在 POST 上:

  • 创建订单
  • 创建支付单
  • 发起退款
  • 发放权益
  • 提交审批
  • 创建导入任务

从 HTTP 方法看,POST 通常不是天然幂等的。因为“创建一个新资源”执行两次,很可能就是两个资源。

但业务上我们经常希望某些 POST 可以安全重试。

比如“用这个客户端请求号创建一张订单”。如果客户端超时了,它应该可以带着同一个请求号再问一次:刚才那张订单到底创建出来没有?

这时候幂等不是 HTTP 方法送你的。

它是你在业务层设计出来的。

真正的敌人是“未知结果”

很多接口设计只考虑两种结果:

  • 成功
  • 失败

但分布式系统里最麻烦的是第三种:

  • 不知道

客户端发起支付请求。服务端调用三方支付。三方已经创建了支付对象,但你的服务端在写本地结果前重启了。

这个时候客户端看到的是超时。

它不知道该提示用户“失败了”,还是“成功了”,还是“稍后查看”。更现实的是,它会重试。SDK 会重试。业务方会重试。客服可能也会点一次“重新发起”。

如果服务端没有幂等,重试就可能变成第二次创建、第二次扣款、第二次发券。

所以幂等设计的核心不是“挡掉重复请求”,而是把未知结果变成可查询、可复用、可恢复的确定结果。

同一个业务意图再来一次时,服务端最好能回答:

  • 这件事第一次是否已经开始执行
  • 第一次使用的参数是什么
  • 第一次生成的业务对象是什么
  • 第一次最终成功、失败,还是仍在处理中
  • 这次重试应该返回旧结果、继续等待,还是拒绝

如果这些都没有记录,系统就只能靠猜。

猜,是幂等事故的开端。

幂等键绑定的是业务意图

最常见的做法,是让调用方带一个 idempotency key

Stripe 的官方 API 文档就是很典型的样板:调用方给 POST 请求带幂等键后,Stripe 会保存第一次请求的状态码和响应体,后续同一个 key 的请求会返回同一个结果;幂等键建议使用足够随机的值,比如 UUID v4;保留窗口至少 24 小时;同一个 key 后续请求的参数如果和第一次不一致,会被识别为误用。[2]

这个设计里有几个细节很值得注意。

第一,幂等键不是“这个字符串用过了就行”。

它要绑定一次业务意图。

比如用户点击“购买 A 商品 1 件”,客户端生成了一个请求号。这个请求号可以用于重试同一次购买。但它不能下一次又拿来购买 B 商品 3 件。

所以服务端只记录:

1
key = abc 已经用过

是不够的。

更稳的记录通常至少包括:

  • 幂等键
  • 规范化后的请求参数摘要
  • 首次请求时间
  • 当前处理状态
  • 关联的业务主键
  • 首次完成后的响应结果

这样第二次请求进来时,服务端才能判断:

  • 是同一业务意图的重试
  • 还是调用方复用了 key
  • 还是恶意或异常流量在碰撞

第二,幂等结果不是所有失败都应该永久固化。

Stripe 文档里还有一个边界:如果参数校验失败,或者请求在并发冲突前还没有真正开始执行,就不会保存幂等结果。[2:1]

这个边界很实用。

字段缺失、格式错误这种请求,本来就没有进入业务执行。调用方修正参数后可以重试。如果你把这种失败也永久绑定到 key 上,调用方反而很难恢复。

幂等真正要固化的,是“已经开始产生业务效果”的执行。

只靠幂等表还不够

很多人做到这里会觉得,建一张 idempotency_record 表就完事了。

还不够。

幂等表解决的是请求层的问题:同一个 key 再来时如何处理。

但业务层仍然要有自己的硬约束。

因为重复不一定都从同一个 key 来。

同一个支付回调可能有事件 ID。用户发起支付可能有客户端请求号。后台补偿可能按订单号跑。对账任务可能按渠道交易号修正。人工重放可能从管理后台触发。

如果这些入口最后都会影响同一张支付单、同一笔账、同一份权益,那么真正保护业务的,不能只有入口处的一张幂等表。

你还需要:

  • 业务唯一键
  • 数据库唯一约束
  • 条件更新
  • 状态机 guard
  • 事务边界
  • 版本号或乐观锁

比如支付单只允许从 PENDING 变成 PAID

重复的支付成功事件来了,可以忽略。

旧的支付失败事件晚到了,不能把 PAID 改回 FAILED

这不是幂等键一个字段能解决的。

这是状态机要解决的问题。

幂等解决“同一个触发重复来了怎么办”。

状态机解决“这次状态推进在业务上还允不允许”。

两者不是替代关系。

支付回调是最好的反例

支付回调很适合用来理解幂等,因为它天然会打破很多天真的假设。

天真的写法通常是:

  1. 回调进来
  2. 验签
  3. 查本地订单
  4. 如果未支付,就改成已支付
  5. 发货或发权益
  6. 返回成功

这段流程看起来很顺。

但线上现实通常不顺:

  • 回调可能比本地下单落库更早到
  • 同一事件可能重复投递
  • 事件可能乱序
  • 你的接口返回慢,对方会重试
  • 你处理成功了,但返回 2xx 之前进程挂了
  • 你本地状态已经是终态,但旧事件才刚到

Stripe 的 webhook 文档也明确提醒,事件不保证按生成顺序送达;在 live mode 下,如果 endpoint 没有成功响应,Stripe 会自动重试最长三天,并使用指数退避;文档还建议 webhook handler 尽快返回成功,把复杂逻辑延后异步处理。[3]

所以更稳的模型不是“回调来了就同步做完整个业务”,而是:

  1. 验签
  2. 原始事件落库
  3. 按事件唯一键去重
  4. 快速返回 2xx
  5. worker 异步消费事件
  6. 按业务主键和状态机更新订单
  7. 副作用通过 outbox、任务或事件继续推进
  8. 失败进入重试、死信、补偿或人工处理

这里有两层幂等。

第一层是事件级去重:同一个 webhook event 不要处理两次。

第二层是业务级幂等:即使两个不同事件都表达“这笔支付已经成功”,也不能重复记账、重复发货、重复发券。

很多支付事故就发生在只做了第二层的一点点:

1
2
if order.status == PAID:
return success

这只能挡住一部分重复支付成功回调。

它挡不住事件审计缺失,挡不住旧状态覆盖新状态,挡不住查不到订单的孤儿回调,挡不住发货逻辑被两个入口同时触发。

服务端要防的不是“这一行 if 有没有写”。

服务端要防的是所有入口都不能把核心不变量打破。

防重放、幂等、锁,不要混成一件事

幂等还经常和两个概念混在一起:防重放、锁。

它们有关,但不是同一个东西。

防重放关注的是:一份旧的合法请求,现在还能不能再次被接受。

比如 webhook payload 和签名被复制后重新提交。如果签名只证明“内容没被改过”,但没有 timestamp、nonce、event id、请求 ID 或时间窗口,那么旧请求可能仍然看起来合法。

所以高价值入口通常要回答三件事:

  1. 这个请求是谁发的,内容有没有被改
  2. 这是不是一条仍在有效窗口内的新请求
  3. 如果它重复到达,业务副作用是否只发生一次

第一件事靠鉴权、签名、验签。

第二件事靠 timestamp、nonce、event id、request id、时间窗口。

第三件事靠幂等键、唯一约束、状态机和事务。

幂等不能替代鉴权。

一个重复请求不重复生效,不代表它来源可信。

锁解决的是另一类问题:多个执行者如何互斥地修改资源。

比如两个 worker 同时抢一个任务,或者两个请求同时扣同一份库存。锁、乐观锁、唯一约束、条件更新都可能用上。

但锁也不是幂等。

锁能让同一时刻只有一个人进门,却不能告诉你这个人是不是昨天已经办过同一件事,也不能告诉你超时以后再次进门该返回什么结果。

把这些概念混在一起,最后很容易写出一种“看起来很安全”的代码:

1
2
3
4
前端禁用按钮
Redis setnx
查一下状态
没问题就执行

它能挡住一部分普通重复提交。

但它没有回答未知结果、参数复用、持久约束、状态回退、补偿重放这些更关键的问题。

先写不变量,再写接口

我现在更愿意从不变量开始设计这类接口。

不要先问:

  • 用不用 Redis
  • 幂等表怎么建
  • key 放 header 还是 body
  • 要不要分布式锁

先问:

  • 这个业务最不能坏的事实是什么
  • 哪些副作用绝不能重复
  • 哪些状态一旦到达就不能回退
  • 哪些检查必须和写入在同一个事务里完成
  • 哪些失败可以稍后补偿,哪些失败不能先脏后修

比如支付场景里,不变量可能是:

  • 同一支付意图只能生成一张有效支付单
  • 同一渠道交易只能记账一次
  • 已支付订单不能被旧失败事件回退
  • 发货或权益开通只能对同一业务事实生效一次

有了这些不变量,再决定机制:

  • 创建支付单用业务请求号唯一约束
  • 调三方支付带幂等键
  • 回调事件表按 provider event id 去重
  • 支付单状态用条件更新
  • ledger entry 用 source object + entry type 做唯一键
  • 发货通过领域事件触发,业务侧自己幂等
  • 失败事件进入 retry / dead letter / reconciliation

这时候幂等不再是一张“防重复提交表”。

它变成一组保护业务不变量的机制。

一个最小可用清单

如果一个写接口重试后可能造成钱、库存、权益、身份、权限或不可逆状态变化,我会至少检查这些问题:

  1. 调用方在什么失败下会重试?
  2. 谁生成幂等键?这个 key 表达哪个业务意图?
  3. 同一个 key 换参数时怎么办?
  4. 首次执行结果保存在哪里?处理中状态怎么返回?
  5. 幂等记录保留多久?过期后业务层是否仍有约束?
  6. 业务主键、外部单号、事件 ID 是否有唯一约束?
  7. 状态是否只能合法前进?终态收到旧事件怎么办?
  8. 状态推进和外部副作用是否拆开?
  9. 后台重试是否有次数上限、退避、死信和人工重放?
  10. 入口是否需要签名、timestamp、nonce 或时间窗口来防重放?

这里面没有哪一项单独等于“幂等”。

真正的幂等能力,来自它们组合在一起。

结尾

“防连点”是一个太小的入口。

它让人以为重复来自用户手快,以为只要少发一个请求,系统就安全了。

但服务端面对的重复,更多来自未知结果:不知道上一次有没有执行,不知道执行到哪一步,不知道响应是不是丢了,不知道对方会不会再投递,不知道补偿任务会不会重放。

API 幂等真正解决的,不是让第二次点击消失。

而是当第二次、第三次、隔天的一次、后台补偿的一次真的到来时,服务端仍然能说清:

这是同一件事。它已经发生过。结果在这里。业务不会再被改坏。


  1. RFC 9110《HTTP Semantics》在 Idempotent Methods 中定义了 HTTP 方法幂等语义,并说明客户端自动重试非幂等方法需要知道请求语义本身是幂等的。 ↩︎

  2. Stripe 官方文档 Idempotent requests 说明了幂等键、首次响应复用、参数一致性校验、建议 UUID v4、至少 24 小时清理窗口,以及未开始执行时不保存幂等结果等边界。 ↩︎ ↩︎

  3. Stripe 官方文档 Receive Stripe events in your webhook endpoint 说明了 webhook handler 应快速返回成功、失败会自动重试、事件可能重复且不保证顺序等处理边界。 ↩︎