幂等键:支付按钮点两次,为什么不能扣两次钱
TL;DR
网络超时并不等于支付没有发生。Idempotency-Key 让客户端可以安全重试,服务端则通过唯一约束、请求指纹、状态机和结果复用把重复请求变成同一次业务操作。
网络超时并不等于支付没有发生。Idempotency-Key 让客户端可以安全重试,服务端则通过唯一约束、请求指纹、状态机和结果复用把重复请求变成同一次业务操作。

支付按钮被用户点了两次,网络却只返回了一次结果;客户端以为请求失败,自动重试了一遍;服务端其实已经扣款成功,只是响应在路上丢了。如果接口把每次 POST 都当作一笔新操作,重试就可能变成重复扣款。
幂等性解决的不是“请求永远只到达一次”,而是“同一业务意图被重复执行时,最终效果仍然可控”。HTTP 规范把 GET、PUT、DELETE 等方法的幂等语义和 POST 的非幂等特性区分开;针对 POST/PATCH 的 Idempotency-Key 目前仍是 IETF Internet-Draft,而不是已经发布的 RFC。HTTP Idempotency-Key 草案
先区分三个容易混淆的概念
至多一次
客户端只发送一次,失败就不重试。重复执行风险低,但用户体验差,网络抖动时容易出现“其实成功了,但页面显示失败”。
至少一次
客户端或消息系统会重试,直到得到确认。成功率更高,但服务端必须能够识别重复意图,否则同一笔业务可能执行多次。
幂等
同一个业务操作执行一次和执行多次,最终状态一致。它不代表每次响应都完全相同,也不代表所有副作用天然安全;它要求系统为“重复请求”定义明确行为。
Idempotency-Key 是怎么工作的
客户端为一次业务意图生成唯一键,并在重试时复用它:
POST /payments
Idempotency-Key: "pay-8e03978e-40d5-43e8-bc93-6894a57f9324"
Content-Type: application/json
{"orderId":"order-123","amount":1999,"currency":"CNY"}
服务端通常需要保存三类信息:
- 幂等键属于哪个租户或用户。
- 第一次请求的请求指纹,例如规范化后的请求体哈希。
- 业务处理状态、结果和可重复返回的响应。
第一次收到键时,服务端创建一条“处理中”记录,并在同一个事务边界内推进业务。后续收到相同键时,如果业务已经完成,就返回已保存结果;如果仍在处理,可以返回处理中或让客户端稍后重试;如果请求体与第一次不同,则必须拒绝,避免一个键被复用于两个业务意图。
幂等键不是简单加一列字符串
原子占位很重要
两个并发请求可能同时查不到键,如果只是“先查询,再插入”,两者都可能开始扣款。应使用数据库唯一约束、原子插入或带条件的状态更新,让只有一个请求获得处理权。
业务状态和幂等状态要配合
如果幂等记录写成功,但支付事务失败,重试应该允许恢复;如果支付成功但幂等记录没保存,重试仍可能再次调用支付渠道。真正可靠的设计需要把业务状态、外部副作用和幂等记录放进一致的流程里,必要时使用状态机、Outbox 或支付渠道提供的幂等能力。
响应要不要保存
保存完整响应最方便重试,但可能占用空间,也要考虑敏感信息。只保存业务结果和重新构造响应,可以降低存储成本,但要求响应构造本身稳定。错误响应是否缓存,也应按错误类型区分:参数错误可以固定返回,临时超时则可能需要再次执行。
外部服务让问题更难
你的数据库事务不能自动回滚银行、短信服务或物流平台的动作。于是“扣款成功但本地超时”成为必须建模的状态,而不是异常分支。
常见做法包括:
- 传递同一个幂等键到支持幂等的下游服务。
- 为外部调用记录发送状态、响应和重试次数。
- 用可查询的业务状态机区分待处理、成功、失败和未知。
- 对未知状态先查询下游,不要直接再次扣款。
- 把副作用事件写入可靠队列,使用 Outbox 避免数据库提交成功但消息丢失。
幂等键的生命周期怎么定
保存太短,客户端晚一点重试可能被当成新请求;保存太久,会增加存储成本,还可能让历史键与新的业务语义冲突。生命周期应该由业务最大重试窗口、对账周期和风险决定,而不是随便设置一个小时。
幂等键还必须带租户或用户边界。全局只按字符串查找,可能导致两个不同租户恰好使用同一个键时互相影响。建议使用“租户 ID + 幂等键”作为唯一范围,并限制键长度、格式和请求体大小。
它和去重、分布式锁不完全一样
- 去重关注“这条事件是否处理过”,幂等性关注“重复执行这次业务意图时如何得到一致结果”。
- 分布式锁关注同一时刻谁能进入临界区,幂等记录关注之后的重试如何复用结果。
- 数据库唯一索引可以阻止重复资源,但不一定能返回第一次请求的完整响应。
它们经常一起使用,但不能互相替代。一个锁释放后,第二次请求仍然可能执行;一个唯一键报错后,客户端仍然需要知道第一次操作到底成功还是失败。
一句话带走
网络请求永远可能超时、重复或丢响应。幂等键把“重复执行”从不可预测的副作用,变成可查询、可恢复、可审计的业务状态。支付、订单、发票和资源创建接口,应该在写第一行代码时就决定重试语义,而不是等第一次重复扣款后再补救。



