API 幂等真正解决的,是“结果未知以后怎么办”
很多人第一次听到 API 幂等,是从“防重复提交”开始的。
用户点了一次提交,页面没反应,又点了一次。前端给按钮加个 loading,提交后禁用三秒,看起来问题就解决了。再高级一点,加个 token,或者在 Redis 里 setnx 一下,同一个请求不要重复处理。
这些做法不是没用。
但如果你把 API 幂等理解成“防用户手快”,就会低估它真正要处理的场景。线上很多重复执行,并不是用户连续点了两下按钮,而是系统不知道上一件事到底发生到哪一步了。
请求发出去了,客户端超时了。
支付渠道扣款成功了,但响应丢了。
消息队列投递了一次,消费者处理到一半重启了。
Webhook 已经通知过你,但对方没收到你的 2xx,于是又发了一次。
后台补偿任务昨晚跑失败,今天人工补跑。
这些时候,问题不是“前端有没有挡住第二次点击”,而是:
当结果未知时,调用方一定会想办法再来一次。服务端要保证再来一次不会把业务改坏。
这才是 API 幂等真正解决的问题。
幂等不是按钮防抖
前端防连点解决的是用户体验层面的重复触发。
它可以减少无意义请求,也可以让页面状态更清楚。但它挡不住这些情况:
- 用户刷新页面后重提
- 客户端超时后自动重试
- 网关、SDK 或任务框架重试
- MQ 至少一次投递
- 第三方 webhook 重复通知
- 人工补偿和批量重放
- 攻击者拿旧请求重新提交
前端按钮只活在浏览器当前页面里。服务端面对的是网络、队列、数据库、第三方系统、后台任务和人肉运维。
所以服务端幂等的第一步,是承认一个事实:
同一个业务意图可能会从多个入口、在不同时间、以不同姿势再次到达。
如果这个业务意图是“查一下订单列表”,重复到达通常没什么。如果它是“扣款”“发券”“创建订单”“发货”“记账”“领取奖励”,重复到达就可能产生真实损失。
按钮禁用只能减少“点了两下”。它不能回答“第一次到底成功了吗”。
幂等要回答的是这个问题。
HTTP 方法的幂等,和业务写操作的幂等,不是一回事
HTTP 规范里确实有幂等方法的概念。RFC 9110 把 PUT、DELETE 和安全方法定义为幂等方法:同一个请求执行多次,对服务端的预期效果应当和执行一次相同。规范还提醒,客户端不应该自动重试非幂等请求,除非它知道这个请求语义本身就是幂等的。[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。
这不是幂等键一个字段能解决的。
这是状态机要解决的问题。
幂等解决“同一个触发重复来了怎么办”。
状态机解决“这次状态推进在业务上还允不允许”。
两者不是替代关系。
支付回调是最好的反例
支付回调很适合用来理解幂等,因为它天然会打破很多天真的假设。
天真的写法通常是:
- 回调进来
- 验签
- 查本地订单
- 如果未支付,就改成已支付
- 发货或发权益
- 返回成功
这段流程看起来很顺。
但线上现实通常不顺:
- 回调可能比本地下单落库更早到
- 同一事件可能重复投递
- 事件可能乱序
- 你的接口返回慢,对方会重试
- 你处理成功了,但返回
2xx之前进程挂了 - 你本地状态已经是终态,但旧事件才刚到
Stripe 的 webhook 文档也明确提醒,事件不保证按生成顺序送达;在 live mode 下,如果 endpoint 没有成功响应,Stripe 会自动重试最长三天,并使用指数退避;文档还建议 webhook handler 尽快返回成功,把复杂逻辑延后异步处理。[3]
所以更稳的模型不是“回调来了就同步做完整个业务”,而是:
- 验签
- 原始事件落库
- 按事件唯一键去重
- 快速返回
2xx - worker 异步消费事件
- 按业务主键和状态机更新订单
- 副作用通过 outbox、任务或事件继续推进
- 失败进入重试、死信、补偿或人工处理
这里有两层幂等。
第一层是事件级去重:同一个 webhook event 不要处理两次。
第二层是业务级幂等:即使两个不同事件都表达“这笔支付已经成功”,也不能重复记账、重复发货、重复发券。
很多支付事故就发生在只做了第二层的一点点:
1 | if order.status == PAID: |
这只能挡住一部分重复支付成功回调。
它挡不住事件审计缺失,挡不住旧状态覆盖新状态,挡不住查不到订单的孤儿回调,挡不住发货逻辑被两个入口同时触发。
服务端要防的不是“这一行 if 有没有写”。
服务端要防的是所有入口都不能把核心不变量打破。
防重放、幂等、锁,不要混成一件事
幂等还经常和两个概念混在一起:防重放、锁。
它们有关,但不是同一个东西。
防重放关注的是:一份旧的合法请求,现在还能不能再次被接受。
比如 webhook payload 和签名被复制后重新提交。如果签名只证明“内容没被改过”,但没有 timestamp、nonce、event id、请求 ID 或时间窗口,那么旧请求可能仍然看起来合法。
所以高价值入口通常要回答三件事:
- 这个请求是谁发的,内容有没有被改
- 这是不是一条仍在有效窗口内的新请求
- 如果它重复到达,业务副作用是否只发生一次
第一件事靠鉴权、签名、验签。
第二件事靠 timestamp、nonce、event id、request id、时间窗口。
第三件事靠幂等键、唯一约束、状态机和事务。
幂等不能替代鉴权。
一个重复请求不重复生效,不代表它来源可信。
锁解决的是另一类问题:多个执行者如何互斥地修改资源。
比如两个 worker 同时抢一个任务,或者两个请求同时扣同一份库存。锁、乐观锁、唯一约束、条件更新都可能用上。
但锁也不是幂等。
锁能让同一时刻只有一个人进门,却不能告诉你这个人是不是昨天已经办过同一件事,也不能告诉你超时以后再次进门该返回什么结果。
把这些概念混在一起,最后很容易写出一种“看起来很安全”的代码:
1 | 前端禁用按钮 |
它能挡住一部分普通重复提交。
但它没有回答未知结果、参数复用、持久约束、状态回退、补偿重放这些更关键的问题。
先写不变量,再写接口
我现在更愿意从不变量开始设计这类接口。
不要先问:
- 用不用 Redis
- 幂等表怎么建
- key 放 header 还是 body
- 要不要分布式锁
先问:
- 这个业务最不能坏的事实是什么
- 哪些副作用绝不能重复
- 哪些状态一旦到达就不能回退
- 哪些检查必须和写入在同一个事务里完成
- 哪些失败可以稍后补偿,哪些失败不能先脏后修
比如支付场景里,不变量可能是:
- 同一支付意图只能生成一张有效支付单
- 同一渠道交易只能记账一次
- 已支付订单不能被旧失败事件回退
- 发货或权益开通只能对同一业务事实生效一次
有了这些不变量,再决定机制:
- 创建支付单用业务请求号唯一约束
- 调三方支付带幂等键
- 回调事件表按 provider event id 去重
- 支付单状态用条件更新
- ledger entry 用 source object + entry type 做唯一键
- 发货通过领域事件触发,业务侧自己幂等
- 失败事件进入 retry / dead letter / reconciliation
这时候幂等不再是一张“防重复提交表”。
它变成一组保护业务不变量的机制。
一个最小可用清单
如果一个写接口重试后可能造成钱、库存、权益、身份、权限或不可逆状态变化,我会至少检查这些问题:
- 调用方在什么失败下会重试?
- 谁生成幂等键?这个 key 表达哪个业务意图?
- 同一个 key 换参数时怎么办?
- 首次执行结果保存在哪里?处理中状态怎么返回?
- 幂等记录保留多久?过期后业务层是否仍有约束?
- 业务主键、外部单号、事件 ID 是否有唯一约束?
- 状态是否只能合法前进?终态收到旧事件怎么办?
- 状态推进和外部副作用是否拆开?
- 后台重试是否有次数上限、退避、死信和人工重放?
- 入口是否需要签名、timestamp、nonce 或时间窗口来防重放?
这里面没有哪一项单独等于“幂等”。
真正的幂等能力,来自它们组合在一起。
结尾
“防连点”是一个太小的入口。
它让人以为重复来自用户手快,以为只要少发一个请求,系统就安全了。
但服务端面对的重复,更多来自未知结果:不知道上一次有没有执行,不知道执行到哪一步,不知道响应是不是丢了,不知道对方会不会再投递,不知道补偿任务会不会重放。
API 幂等真正解决的,不是让第二次点击消失。
而是当第二次、第三次、隔天的一次、后台补偿的一次真的到来时,服务端仍然能说清:
这是同一件事。它已经发生过。结果在这里。业务不会再被改坏。
RFC 9110《HTTP Semantics》在 Idempotent Methods 中定义了 HTTP 方法幂等语义,并说明客户端自动重试非幂等方法需要知道请求语义本身是幂等的。 ↩︎
Stripe 官方文档 Idempotent requests 说明了幂等键、首次响应复用、参数一致性校验、建议 UUID v4、至少 24 小时清理窗口,以及未开始执行时不保存幂等结果等边界。 ↩︎ ↩︎
Stripe 官方文档 Receive Stripe events in your webhook endpoint 说明了 webhook handler 应快速返回成功、失败会自动重试、事件可能重复且不保证顺序等处理边界。 ↩︎