跳到主内容
Autional 徽标
Architecture9 分钟#幂等#支付#分布式#架构

幂等设计:点两次付款为什么只扣一次

一个再普通不过的场景:小满在付款页点了「确认支付」,页面转了两秒没反应,她心里没底,又点了一下。

她会被扣两次钱吗?

如果你觉得这只是「用户手抖」的小概率事件,先看三种重复请求的成因——它们覆盖的是所有正常系统:

  1. 回话没回来 ≠ 没送达。 第一笔请求可能已经执行成功,只是回执在网络上丢了。用户的连点,其实是在重发一笔已经完成的付款。
  2. 用户连点、手抖。 移动端弱网下,这是最常见的成因。
  3. 回调可能重复投递,也可能被伪造。 支付渠道的通知走公网、带重试机制;重复是设计内的,伪造是设计外的。

再叠加一层工程现实:丢包、超时、队列重投——「至少一次投递」(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,所以必须在应用层自己补上幂等。

关三:回调验签——通知带暗记

支付渠道的「支付成功」通知,是另一个重复与伪造的高发区。处理原则两条:

  1. 先验签,再落库。 通知体上带 HMAC 签名(RFC 2104),用共享密钥验,验得过才收。比较签名时用常数时间比较,失败按最坏情况处理(fail-closed)。
  2. 同一条只认一次。 落库时按「事件唯一标识」建唯一约束;处理过的通知再来,直接跳过。

幂等的三种实现

实现 优点 代价
响应缓存(HTTP 中间件) 通用、可重放首次响应 依赖缓存、到期失效
数据库唯一键 强一致、无外部依赖、并发也稳 业务表要带键、冲突要处理
状态机(领域层) 语义最稳 需建模、纯累加操作挡不住

资金接口通常两者并用:中间件缓存图快,数据库唯一约束兜底。

现实证据:重复扣款不是小概率

2026 年 6 月发布的一项研究(SSRN 6895958,发布方为对账服务厂商 Rexi)分析了 2021 年 1 月至 2025 年 12 月间提交给美国消费者金融保护局(CFPB)的 97,028 件金融投诉,把与「对账失败」一致的模式(包含未被冲回的重复扣款)单独归类。这类投诉以货币救济(退款、赔付、减免费用)结案的比例为 9.96%,是其它金融支付类投诉(3.51%)的近 3 倍。

重复扣款对用户的伤害是直接的,而且往往不是「点了一下」那么简单——它是长尾、累积、需要人工追溯的运营负担。该研究样本覆盖 11 家金融科技公司;对厂商研究,我们只转述数字、不做超出数据的推断。

另一个角度:先冻结,再扣款

对付重复的另一种思路更彻底:让扣款之前先「占位」。 这就是信用卡预授权(authorization hold)的机制:

  1. 下单/入住时授权——这笔钱被冻住(hold),但还没有真正划走;
  2. 消费完成时 capture——冻结解除,正式扣款;
  3. 未 capture 到期的授权自动释放;对应的支付单转为取消/过期。

卡组织的授权有效期各不相同:以 Visa 为例,卡不在场交易(如线上支付)约 10 天、卡在场交易约 5 天;Mastercard 一般在 7 天上下;酒店、租车押金这类场景的授权占用最长可到 30 天(各卡种、各场景不同,公开资料通常只给区间)。

钱包系统里对应的设计是余额三栏:

栏位 含义
可用余额 现在就能花、能提的
冻结金额 还是你的钱,被一桩没有了结的事先冻住
(总余额) 二者之和

冻结必须配两样东西才成立:凭条(为哪桩事、多少、何时起,active → released)和期限(到期未了结自动释放)。没有期限的冻结会变成「死钱」——用户的钱在账上,却谁也动不了。

提现/出金这类操作再叠一道审批:冻结提现额 → 审批 → 过则扣款、没过放回。三道拦截各管一段:冻结拦钱、审批拦决定、限额(日/月上限、自动审批限额)拦尺度。

但要记住它们的边界:冻结与审批管的是「流程走没走完」,不是「账记没记对」——后者要靠账本与对账兜底。

Autional 的做法

支付链路的幂等由 pay-service 和 wallet-service 配合完成:

pay-service(收与回调):

  1. 状态机:PaymentIntent 显式建模状态流转,定时任务把过期未付的单子置为 expired;
  2. 幂等键 + 唯一约束:PaymentIntent.IdempotencyKey 租户内唯一(唯一索引 uni_payment_intents_tenant_idempotency,跨租户同键互不冲突);RefundRecord 的幂等键同样唯一;HTTP 中间件提供 X-Idempotency-Key 响应缓存(带 TTL 释放;键为空则放行、键长上限 256);
  3. 回调验签: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 生成应用的身份层。