幂等设计:点两次付款为什么只扣一次
一个再普通不过的场景:小满在付款页点了「确认支付」,页面转了两秒没反应,她心里没底,又点了一下。
她会被扣两次钱吗?
如果你觉得这只是「用户手抖」的小概率事件,先看三种重复请求的成因——它们覆盖的是所有正常系统:
- 回话没回来 ≠ 没送达。 第一笔请求可能已经执行成功,只是回执在网络上丢了。用户的连点,其实是在重发一笔已经完成的付款。
- 用户连点、手抖。 移动端弱网下,这是最常见的成因。
- 回调可能重复投递,也可能被伪造。 支付渠道的通知走公网、带重试机制;重复是设计内的,伪造是设计外的。
再叠加一层工程现实:丢包、超时、队列重投——「至少一次投递」(at-least-once delivery)是分布式系统的默认口径。重复不是异常,重复是常态。
所以幂等设计的目标不是「阻止重复请求进来」——那个做不到。它只回答一个问题:同一笔付款重发过来时,系统认不认得出它跟第一次是同一笔?
三道关:档位、号牌、暗记
工程上防止「同一件事被处理两遍」,靠的是三道互补的关:
| ① 档位(状态机) | ② 号牌(幂等键) | ③ 暗记(通知验签) | |
|---|---|---|---|
| 防什么 | 已经成功的单再被点 | 同一笔被处理第二遍 | 假通知 / 重复通知 |
| 怎么工作 | 只准从「新单」走到「成功」,不倒回去 | 同一张号牌第二次来,直接还上次结果 | 通知带暗记,验对才收;同条只认一次 |
| 什么时候失效 | 「纯累加」操作挡不住 | 重发时换了新号牌 | 共用的密钥泄露了 |
三道关常一起上、互为补充,不是任选其一;而且都不承诺「绝对不重复」——这是工程口径,不是谦虚。
关一:状态机——只往前走,不倒回去
支付单的状态设计成一条单向道:
created → processing → succeeded
↘ failed
↘ expired
一笔单子一旦走到 succeeded,再收到「支付」请求时,状态机直接拒绝——因为它不在「可支付」的状态里了。定时任务还会把超时未支付的单子置为 expired。
状态机的代价:对「纯累加」操作无效。给用户加积分、加余额——「再加 10 分」发两遍,状态机拦不住第二遍,因为它没有「已经加过」这个状态。
关二:幂等键——同一张号牌,只算一次
幂等键(Idempotency Key)是目前最通用的做法:客户端为每个操作生成一个唯一键,重发时原样带上。
POST /api/v1/payments
X-Idempotency-Key: 7f9c24e8-1b3a-4d5e-9c00-3e2a1f8b6d71
Content-Type: application/json
{ "order_id": "SO-20261006-001", "amount": "199.00", "currency": "CNY" }
服务端的行为逻辑:
flowchart TD
C["客户端:生成幂等键<br/>重试时原样带上"] --> Empty{"键为空?"}
Empty -->|"是"| Pass["放行(不启用幂等)"]
Empty -->|"否"| Q{"查「键 → 首次结果」"}
Q -->|"未命中"| Run["执行操作<br/>落库「键 → 结果」"]
Q -->|"命中 · 内容一致"| Re["返回首次结果<br/>不重复执行"]
Q -->|"命中 · 内容不一致"| Err["返回 422<br/>同一号牌不能挂两件事"]
图 1:幂等键判定——没见过的键才执行;见过的键比对内容:一致还旧结果,不一致报 422。
两个工程要点:
- 数据库唯一约束兜底并发:两个相同键的请求同时到达、都没命中缓存时,靠
(租户, 幂等键)的唯一索引保证只有一个能写入。另一个拿到冲突后,读取首次结果返回。缓存图快,唯一约束兜底。 - 有效期:Stripe 的官方口径是幂等键 24 小时内返回原结果;IETF 有一份
Idempotency-Key头的草案(draft-ietf-httpapi-idempotency-key-header)。07 版已于 2026-04 过期,之后仍有新修订版在推进,至今未成为 RFC。但 Stripe、Adyen、Square 等厂商在生产中沿用了其事实口径:键必须唯一、不得与不同内容复用、可按时间设有效期。
sequenceDiagram
participant A as 请求 A(键 K)
participant S as 服务端:唯一索引
participant B as 请求 B(键 K)
A->>S: 同一幂等键,同时到达
B->>S: 同一幂等键,同时到达
S-->>A: 唯一约束放行 → 执行
S-->>B: 唯一冲突 → 回读首次结果
图 2:两笔并发同键请求——缓存都没命中时,唯一索引让其中一笔执行,另一笔拿冲突、回读首次结果。
客户端最容易犯的错误只有一个:重发时忘了带键,或者换了新键。 同一张号牌第二次来,系统认得出「还是同一笔」;换了新号牌,在系统眼里就是全新的一笔,又扣一次。键要在操作开始时生成、在内存里保存、重试时原样带上。
顺带把 HTTP 的语义摆正(RFC 9110 §9.2.2):GET / PUT / DELETE 按定义是幂等的,重发无害;POST / PATCH 不是——付款、下单、发券这类操作几乎都走 POST,所以必须在应用层自己补上幂等。
关三:回调验签——通知带暗记
支付渠道的「支付成功」通知,是另一个重复与伪造的高发区。处理原则两条:
- 先验签,再落库。 通知体上带 HMAC 签名(RFC 2104),用共享密钥验,验得过才收。比较签名时用常数时间比较,失败按最坏情况处理(fail-closed)。
- 同一条只认一次。 落库时按「事件唯一标识」建唯一约束;处理过的通知再来,直接跳过。
幂等的三种实现
| 实现 | 优点 | 代价 |
|---|---|---|
| 响应缓存(HTTP 中间件) | 通用、可重放首次响应 | 依赖缓存、到期失效 |
| 数据库唯一键 | 强一致、无外部依赖、并发也稳 | 业务表要带键、冲突要处理 |
| 状态机(领域层) | 语义最稳 | 需建模、纯累加操作挡不住 |
资金接口通常两者并用:中间件缓存图快,数据库唯一约束兜底。
现实证据:重复扣款不是小概率
2026 年 6 月发布的一项研究(SSRN 6895958,发布方为对账服务厂商 Rexi)分析了 2021 年 1 月至 2025 年 12 月间提交给美国消费者金融保护局(CFPB)的 97,028 件金融投诉,把与「对账失败」一致的模式(包含未被冲回的重复扣款)单独归类。这类投诉以货币救济(退款、赔付、减免费用)结案的比例为 9.96%,是其它金融支付类投诉(3.51%)的近 3 倍。
重复扣款对用户的伤害是直接的,而且往往不是「点了一下」那么简单——它是长尾、累积、需要人工追溯的运营负担。该研究样本覆盖 11 家金融科技公司;对厂商研究,我们只转述数字、不做超出数据的推断。
另一个角度:先冻结,再扣款
对付重复的另一种思路更彻底:让扣款之前先「占位」。 这就是信用卡预授权(authorization hold)的机制:
- 下单/入住时授权——这笔钱被冻住(hold),但还没有真正划走;
- 消费完成时 capture——冻结解除,正式扣款;
- 未 capture 到期的授权自动释放;对应的支付单转为取消/过期。
卡组织的授权有效期各不相同:以 Visa 为例,卡不在场交易(如线上支付)约 10 天、卡在场交易约 5 天;Mastercard 一般在 7 天上下;酒店、租车押金这类场景的授权占用最长可到 30 天(各卡种、各场景不同,公开资料通常只给区间)。
钱包系统里对应的设计是余额三栏:
| 栏位 | 含义 |
|---|---|
| 可用余额 | 现在就能花、能提的 |
| 冻结金额 | 还是你的钱,被一桩没有了结的事先冻住 |
| (总余额) | 二者之和 |
冻结必须配两样东西才成立:凭条(为哪桩事、多少、何时起,active → released)和期限(到期未了结自动释放)。没有期限的冻结会变成「死钱」——用户的钱在账上,却谁也动不了。
提现/出金这类操作再叠一道审批:冻结提现额 → 审批 → 过则扣款、没过放回。三道拦截各管一段:冻结拦钱、审批拦决定、限额(日/月上限、自动审批限额)拦尺度。
但要记住它们的边界:冻结与审批管的是「流程走没走完」,不是「账记没记对」——后者要靠账本与对账兜底。
Autional 的做法
支付链路的幂等由 pay-service 和 wallet-service 配合完成:
pay-service(收与回调):
- 状态机:
PaymentIntent显式建模状态流转,定时任务把过期未付的单子置为expired; - 幂等键 + 唯一约束:
PaymentIntent.IdempotencyKey租户内唯一(唯一索引uni_payment_intents_tenant_idempotency,跨租户同键互不冲突);RefundRecord的幂等键同样唯一;HTTP 中间件提供X-Idempotency-Key响应缓存(带 TTL 释放;键为空则放行、键长上限 256); - 回调验签:
PaymentWebhook按(TenantID + Channel + PaymentID + EventType)四元组唯一;处理前先VerifyWebhook(HMAC 双密钥、常数时间比较、fail-closed),并以IsVerified/IsProcessed两个标志位防止重复处理。
wallet-service(冻结与出金):
- 余额三栏(
Balance/FrozenBalance/AvailableBalance)+ 冻结凭条(FreezeRecord,active → released); - 提现状态机(
pending → approved/rejected → completed)+ 策略可配(日/月上限、单笔下限、是否需人工复核、自动审批限额); - 冻结到期清理任务(
SchedulerWalletWithdrawalExpiry); - 并发保护用乐观锁(事务内
UpdateBalance(wallet, oldVersion))+ 幂等键双保险; - 兜底:每日钱包快照(
WalletSnapshot)+ 账本事件哈希链(详见对账一文)。
其中「冻结+审批」的管理面操作(PIM、审批工作流)走的是同一套治理:大权限不常驻,要用先申请、批了才给。
边界:幂等不承诺「绝对不重复」
把失效条件说清楚,比承诺有效更负责:
- 换新键 = 前功尽弃——客户端重试时换键,等于告诉系统「这是另一笔」;
- 纯累加操作状态机挡不住,要靠唯一键或流水记录;
- 共享密钥泄露则验签失效——密钥要能轮换、新旧并存过渡;
- 幂等键过期后,旧的重复请求可能被当作新请求——靠唯一约束兜最后一层。
常见误区
- 「回话超时 = 请求失败」——不一定,可能已执行,只是回执丢了;
- 「重发一定安全」——不带同一个号牌,重复照样发生;
- 「上了幂等键就万无一失」——过期清除、没带键、纯累加,都可能漏;
- 「回调收到了就能发货」——回调地址是公开的,必须先验签,且同一条只认一次;
- 「总余额就是能花的钱」——能花的看可用余额;
- 「冻结了就是被扣了」——钱还在,事没成会放回来;
- 「冻住了、批了就万无一失」——流程控制不替代记账正确,后者靠对账;
- 「冻结可以不管它」——必须有期限 + 清理,否则成死钱。
结语
防重复,不靠记住用户点了几次——靠让同一笔付款,怎么重发都只算一次。
判断某个操作要不要上幂等,只需问一句:这件事做两遍会出事吗? 付款、转账、下单、发券、退款——凡是「做第二遍就会出事」的操作,先回答:同一笔重发时,系统认不认得出它跟第一次是同一笔?
相关阅读
准备好给应用加上身份层了吗?
Autional 提供认证、组织、访问控制、审计与开发者 API —— 面向 AI 生成应用的身份层。