- 引言 — 被重复扣款的噩梦
- 什么是幂等性
- 重试与超时的问题
- 幂等键 — 给请求贴上名牌
- 去重窗口与存储
- 唯一约束 — 交给数据库来把关的安全网
- 把支付建模为状态机
- 给 at-least-once 投递加上 dedup
- Stripe 风格的幂等性
- 实务清单
- 结语
- 参考资料
引言 — 被重复扣款的噩梦
支付系统里最常见、也最令人恐惧的 bug 是「用户被扣了两次款」。如果一杯咖啡的钱被扣两次,用户会生气;如果一大笔钱被扣两次,信任就崩塌了。而且这个 bug 即便代码里没有明显的失误,也会发生。原因通常是网络的不确定性,以及应对它的重试。
分布式系统的一个根本事实是「网络迟早会失败」。而失败发生时,客户端能采取的合理应对就是重试。可在支付场景里,这个合理的重试却可能产生不合理的结果 — 重复扣款。
本文正面处理这个问题。我们会看看为什么重试会造成重复扣款、防止它的幂等性(idempotency)是什么、如何设计幂等键与去重窗口与唯一约束、如何把 dedup 与 at-least-once 投递结合起来、如何把支付建模为状态机,以及像 Stripe 这样的真实系统是如何实现幂等性的。如果你想亲眼实验重试、流量控制、至少一次投递这些概念,不妨把本站的消息队列演练场一并打开。
什么是幂等性
幂等性(idempotency)是一个来自数学与计算机科学的概念。如果对某个操作应用多次得到的结果,和只应用一次的结果相同,那么这个操作就是幂等的。
用一个日常的例子就能直观理解。电梯的楼层按钮无论按一次还是按五次,结果都一样 — 只会去那一层。这就是幂等。相反,往自动售货机里投硬币不是幂等的,因为每投一次余额就会增加一次。
从 HTTP 方法来看会更清楚。
GET是幂等的。同一个资源读多少次,服务器状态都不会变。PUT和DELETE也被设计成幂等的。同一个值写多少次,同一个资源删多少次,最终状态都一样。POST默认不是幂等的。它通常会创建新资源或触发某个动作,所以调用两次就会发生两次。
问题在于,支付操作通常是 POST。「创建一笔支付」本质上是一个带有副作用(钱会转出)的非幂等操作。我们要做的,就是把这个非幂等的操作变成幂等的 — 也就是说,无论同一笔支付请求发送多少次,支付都只会发生恰好一次。
重试与超时的问题
要理解为什么需要幂等性,就必须精确地看清重试是如何造成重复扣款的。核心在于超时的模糊性。
客户端向服务器发送支付请求,然后等待响应。可是超时发生了。此刻客户端唯一能确定的事实,只有「在规定时间内没有收到响应」这一件事。而实际发生了什么,可能有好几种情况。
超时发生时,实际可能发生过的情况:
情况 A:请求根本没有到达服务器 -> 未扣款。 应当重试。
情况 B:服务器已处理,但响应丢失了 -> 已扣款。 不应重试。
情况 C:服务器仍在处理中,尚未结束 -> 进行中。 重试有竞态风险。
问题的本质就在这里。客户端无法区分 A、B、C。仅凭「没有响应」这一事实,无从得知支付究竟发生了没有。
此时客户端能做的选择只有两种。如果不重试,那么实际上是情况 A 的话,就会放弃而错失这笔支付(用户想付款却没能付成)。反过来如果重试,实际上是情况 B 的话,就会重新发送而造成重复扣款。
所以没有幂等性,就无法摆脱这个两难。幂等性正是解开这个结的办法。只要保证「重试也是安全的」,客户端就能放心重试 — 情况 A 会被正常处理,情况 B 的重复会被过滤掉。换句话说,幂等性是让重试变得安全的机制。
幂等键 — 给请求贴上名牌
实现幂等性的标准做法是幂等键(idempotency key)。思路很简单:客户端给每笔支付请求附上一个唯一标识符,服务器则依据这个键来判断「这个请求我之前见过吗」。
POST /v1/charges
Idempotency-Key: 9f2c1a7e-...-b3 <- 客户端生成的唯一键
{ "amount": 5000, "currency": "cny", "source": "tok_..." }
服务器的处理规则是这样的。
收到请求时:
1) 之前见过这个幂等键吗?
- 没见过 -> 实际处理这笔支付,保存 (键 -> 结果),然后返回结果
- 见过 -> 原样返回保存好的那个结果(不再次执行支付)
有了这条简单的规则,无论客户端用同一个键重试多少次,支付都只会发生恰好一次。只有第一个请求会真正执行支付,之后的重试都会收到那个保存下来的第一次结果的「副本」。
这里,键由谁、如何生成很关键。
- 键必须由客户端生成。这样重试时才能再次发送同一个键。如果由服务器生成键,客户端在重试时就无从得知该用哪个键。
- 键应该一一对应一个逻辑操作。比如「结算购物车 X」这一个操作对应一个键,该操作的所有重试都使用同一个键。使用 UUID 这类足够随机的值,就不必担心偶然碰撞。
- 如果用户再次按下支付按钮是想发起一笔新的操作,就必须生成新的键。重用同一个键,系统会把它当作「重试」而拦下这笔新支付。把这一点和 UX 妥善对齐很重要。
去重窗口与存储
要保存幂等键,就得决定「保存到什么时候」。这就是去重窗口(dedup window)。键-结果的记录不可能永久保存,所以通常只保留一段固定时间(例如 24 小时)。
决定窗口长度的标准是「重试实际会发生在多大的时间范围内」。因网络错误引起的重试,通常会在几秒到几分钟之内发生。但考虑到客户端可能短暂离线、之后才重试的情况,实务中常常留出大约一天的余量。Stripe 会把幂等键保留 24 小时。
存储本身最好支持快速查询和过期(TTL)。常见的选择有以下几种。
- 关系型数据库中的专用表:可以和支付的账本数据放在同一个事务里处理,一致性好,也和后面要讲的唯一约束很搭。
- Redis 这类内存存储 + TTL:非常快,过期是自动的。不过它是和账本 DB 分开的存储,所以要谨慎处理二者之间的一致性(例如键已记录、但支付事务却失败了这种情况)。
无论用哪种方式,重要的是保存的值不应只是一个简单的「见过这个键」标志位,而应该是第一次请求的完整结果。因为要给重试的客户端返回和原始响应完全相同的东西(同一个支付 ID、同一个状态、同一个金额)。
唯一约束 — 交给数据库来把关的安全网
如果只用「先查询,不存在就插入」的方式来实现幂等键,会留下一个微妙的竞态条件。当两个携带同一个键的请求几乎同时到达时,两者都可能判断「这个键不存在」,然后都继续执行支付。问题出在查询和插入之间的那个缝隙。
从根本上堵住这个缝隙,最稳固的工具是数据库的唯一约束(unique constraint)。只要在幂等键这一列上加上唯一约束,即便两个请求同时尝试用同一个键插入,数据库也只会让其中一个成功,其余全部拒绝。「同一时刻只有一个」这件事,由数据库原子性地保证。
CREATE TABLE payment_requests (
idempotency_key TEXT PRIMARY KEY, -- 唯一约束:不允许重复插入同一个键
status TEXT NOT NULL, -- 'in_progress' | 'succeeded' | 'failed'
response_body JSONB, -- 保存第一次请求的完整结果
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
利用这个约束的流程大致是这样。
1) 尝试用这个键 INSERT 一行 'in_progress'
- 成功 -> 我就是这次操作的「所有者」。实际处理支付,并用结果 UPDATE 这一行
- 因唯一约束冲突而失败 -> 说明另一个请求已经抢占了这个键
-> 查询已有的那一行:
- 若已完成,返回保存好的结果
- 若仍是 in_progress,稍等片刻再查一次,或返回「处理中」响应
关键在于,把「先占位」这个动作本身,做成一次带唯一约束的 INSERT。查询后再插入的那个缝隙就消失了,所以即便是同时到达的重试,也恰好只有一个会执行支付。如果把这一行放在和账本数据相同的事务里处理,支付记录和幂等记录就会始终一起提交,从而保证一致性。如果想亲自实验 SQL 的约束与事务,这类 schema 与约束也可以在本站的 SQL、Postgres 演练场里试一试。
把支付建模为状态机
要把幂等性处理妥当,最好不要把支付简单建模为「成功/失败」这两个值,而是建模为状态机(state machine)。因为支付会经过好几个中间状态,而每个状态下重试或回调应该如何作用是不同的。
典型的支付状态流转是这样的。
created ──▶ authorizing ──▶ authorized ──▶ capturing ──▶ captured
│ │ │
│ ▼ ▼
│ failed failed
▼
canceled
(另一条流转) captured ──▶ refunding ──▶ refunded
各状态的含义简单归纳如下。
created:支付意图已创建,但尚未授权。authorizing:授权请求已发往 PSP/发卡机构(等待响应)。authorized:授权完成,额度已冻结。尚未扣款(capture)。capturing/captured:扣款进行中 / 已完成。failed/canceled/refunded:终止状态。
状态机之所以对幂等性有决定性的帮助,是因为它能让你明确定义每个状态下允许的转换。比如,一笔已经处于 captured 的支付又收到一次扣款请求,那就是重试,因此不再次扣款,而是原样返回当前状态。若在 authorizing 状态下又收到同一个请求,就能判断出「仍在处理中」。换句话说,状态机成为判断「这个请求在当前状态下是否有效,还是一次已经处理过的重试」的依据。
显式保存状态也让崩溃恢复变得容易。如果有一笔支付卡在 authorizing 状态,系统可以向 PSP 查询该笔交易的真实状态,确认它究竟是 authorized 还是 failed,并据此纠正状态。这类查询常被称为对账(reconciliation)或状态同步。
给 at-least-once 投递加上 dedup
幂等性不只是支付 API 的事,在支付系统各处的异步消息里同样是核心问题。支付事件通常会流经消息队列(例如「支付成功」事件被传播到结算、通知、会计等服务),而大多数队列只保证at-least-once(至少一次)投递。
我们来精确地看看 at-least-once 意味着什么。队列保证消息「至少」被投递一次,但不保证「恰好」一次。也就是说,同一条消息可能被投递两次以上。原因是,当消费者处理完一条消息、正在向队列告知「处理完成(ack)」的过程中如果发生故障,队列就无从得知这条消息是否已被处理,只能出于安全考虑重新投递。这和前面看到的支付 API 超时问题,结构完全一样。
消费者收到「支付成功」事件
-> 处理(例如:入账余额、发送收据)
-> 在发送 ack 的过程中发生故障
-> 队列不知道是否已处理 -> 重新投递
-> 同一事件被处理两次的风险
要在投递层真正保证「恰好一次(exactly-once)」是非常困难的。实务中的标准解法是at-least-once 投递 + 消费者端 dedup。也就是说,队列只保证至少投递一次,由消费者自行确认「这条消息是否已经处理过」来过滤重复。这通常被称为幂等消费者(idempotent consumer)。
实现原理和支付 API 的幂等性相同。给每条消息一个唯一 ID,消费者保存已处理过的消息 ID。新消息到达时,检查该 ID 是否已在处理列表中,若已存在,就静默忽略。这里唯一约束同样是一道可靠的安全网 — 若把「已处理的消息 ID」插入唯一列时失败,那本身就是「已经处理过」的信号。如果想亲眼确认队列的 at-least-once、ack、重新投递这些行为,可以在消息队列演练场里直接摆弄一下。
Stripe 风格的幂等性
来看看前面这些零件在真实产品中是如何拼在一起的,以 Stripe 的幂等性设计为例做个总结。Stripe 把幂等性作为 API 的一级功能对外暴露,其规则几乎完全遵循本文所讲的原理。
Stripe 幂等性的核心规则是这样的。
- 客户端在
Idempotency-Key请求头中放入一个唯一的键来发起请求。通常用 UUID v4 之类的随机值。 - 若同一个键再次发起请求,Stripe 会原样重放(replay)并返回第一次请求的结果。支付不会再次发生。
- 幂等键会被保留 24 小时。此后同一个键会被当作新请求处理。
- 若第一个请求仍在处理中时,同一个键又收到并发请求,Stripe 会返回错误(409 系列的响应)以防止竞态。客户端稍后重试即可。
- 若用同一个键发送不同的请求体,Stripe 会将其视为误用并返回错误。因为一个键只能对应一个请求。
第一次请求: Idempotency-Key: K, body: {amount: 5000}
-> 执行支付,把结果记录在 K 下,返回结果
重试: Idempotency-Key: K, body: {amount: 5000} (相同请求体)
-> 原样重放记录的结果,支付不会发生
误用: Idempotency-Key: K, body: {amount: 9999} (不同请求体)
-> 错误(不允许同一个键对应不同的请求)
从这个设计中可以归纳出的实务准则是这样的:客户端重试时必须发送同一个键、一个逻辑操作只使用一个键、只有在打算发起新支付时才生成新的键。服务器则要把键和结果一起保存,用唯一约束防止并发,并设置合适的去重窗口。
实务清单
把幂等性引入支付系统时要确认的事项压缩如下。
- 所有会改变状态的请求都必须是幂等的。 创建支付、扣款、退款、取消都必须可以重试。读取(GET)本身就是幂等的,但要对所有会转移资金的 POST 请求要求幂等键。
- 键由客户端生成,重试时重用同一个键。 若由服务器生成键,重试的意义就消失了。
- 用唯一约束防止并发。 仅凭「先查询后插入」仍会留下竞态条件。数据库的唯一约束是最后一道防线。
- 把结果和键一起保存。 如果只保存一个标志位,就无法把原始响应返回给重试的客户端。
- 把支付建模为状态机。 定义每个状态下有效的转换,能让重试与崩溃恢复变得清晰。
- 给异步事件配备幂等消费者。 假设队列是 at-least-once 的,由消费者用消息 ID 过滤重复。
- 设定合适的去重窗口。 太短会错过延迟到来的重试,太长则会增加存储成本,以及和正常重用发生冲突的风险。24 小时是一个常见的起点。
结语
重复扣款不是代码里明显的 bug,而是从「网络会失败,失败了就会重试」这一分布式系统的根本性质中自然流出的问题。超时让支付到底发生了没有变得模糊,而在这种模糊之中,重试就产生了重复。
幂等性正是解开这个结的钥匙。当客户端用一个唯一的键给请求贴上名牌,服务器再依据这个键过滤重复并重放结果时,无论重试多少次,支付都只会发生恰好一次。再加上用唯一约束锁住并发、用状态机理清流程、在异步路径上配备幂等消费者,一个「重试也安全」的支付系统就完成了。
用一句话概括核心:不要试图消除重试,而要让重试变得安全。 在分布式系统里,重试是无法避免的。所以正确的答案是,把每一笔支付都设计成幂等的,让重试不再造成伤害。
参考资料
- Stripe Docs: Idempotent requests — https://stripe.com/docs/api/idempotent_requests
- Stripe Engineering: Designing robust and predictable APIs with idempotency — https://stripe.com/blog/idempotency
- AWS Builders' Library: Making retries safe with idempotent APIs — https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/
- MDN: Idempotent (HTTP) — https://developer.mozilla.org/en-US/docs/Glossary/Idempotent
- Microservices.io: Idempotent Consumer — https://microservices.io/patterns/communication-style/idempotent-consumer.html
현재 단락 (1/111)
支付系统里最常见、也最令人恐惧的 bug 是「用户被扣了两次款」。如果一杯咖啡的钱被扣两次,用户会生气;如果一大笔钱被扣两次,信任就崩塌了。而且这个 bug 即便代码里没有明显的失误,也会发生。原因通...