支付系统重复收费难题:幂等键依赖的四个假设及应对之策
突发:支付系统重复收费事件
周二下午,财务团队称当天有少数客户被重复收费,其中一位客户正就重复收费问题与银行交涉。查看监控发现系统记录显示每笔订单只支付了一次,团队花一个月排查才解决仪表盘显示正常与客户被重复收费的矛盾。此后在多个支付系统也看到类似失败情况,下面内容综合多个案例编写,不针对单一系统或组织,文中数字、时间和识别细节已修改。
导致重复收费的重试操作
客户点击“支付”后,订单服务调用支付服务,支付服务再调用外部支付提供商,提供商对客户银行卡收取 200 美元并记录支付成功。但当时提供商负载过高,3 秒多才给出响应,客户端 2 秒后就放弃,这个默认超时时间从内部服务调用继承而来,未针对支付场景调整。从调用方角度看调用失败,未标记支付信息,重试逻辑重新发送请求,提供商视为新收费请求再次扣钱,数据库只记录重试那次支付信息,第一次收费记录只在提供商处,直到客户投诉才发现。在客户提出争议并导致退款前,几小时内完成重复收费退款,但弄清楚问题花了更长时间。后来延长超时时间只能减少重复收费概率,不能根本解决问题,真正错误在于系统默认超时即失败。
第三种状态
通常认为网络调用只有成功或失败两种结果,但超时是第三种状态。请求可能未到达、处理完成但响应丢失或仍在处理中,调用方无法判断。代码中很少有针对“未知”状态的处理逻辑,通常归为失败并重试,涉及资金转移时会导致重复收费。服务响应时间变长表明服务变慢,出现错误说明服务不可靠,而重复收费在系统中显示为成功,直到客户反馈才会被发现。之前写过关于超时的文章,超时可将无响应挂起状态转化为可见失败,可见失败触发重试,这是引入幂等性的原因。正如 Tyler Treat 所说,在不可靠网络中无法保证消息“恰好一次”送达,但可保证“恰好一次”的效果,即请求可能到达两次,但收费只发生一次。最初想法是停止自动重试支付,但并非所有重试都能控制,比如客户刷新页面或基础设施中的重试策略自动重新发送请求。
幂等键背后的假设
标准解决方案是使用幂等键,调用方在一次操作尝试中附上唯一值,每次重试时使用相同的值。新键会被处理并存储结果,已存在的键则返回存储结果,这样重试不会产生额外影响。Brandur Leach 在 Postgres 中实现类似 Stripe 的幂等键的详细步骤,完整展示了这种模式。使用幂等键后重复收费问题得到解决,但实现幂等键只是第一步,它依赖四个假设,整理成“四个假设测试”:1. 声明(Claim):声明一个键只需先检查它是否可用;2. 意图(Intent):相同的键始终代表相同的意图;3. 记忆(Memory):键所记录的内容可以安全地重放;4. 边界(Boundary):键背后的所有内容都在控制范围内。接下来一个月里,这四个假设都出现问题,负载测试中出现竞争问题,另外三个问题出现在生产环境中。
同一毫秒的两个请求
负载测试中,两个带有相同键的请求在同一毫秒到达,每个请求都检查键是否存在,都没发现已存在的键,于是都开始处理。“先检查键是否存在,再写入”操作存在竞争问题,破坏了声明假设。通过改变操作顺序解决问题,现在写入键就相当于检查,每个请求将键标记为“已开始”,数据库只允许一个请求声明成功。具体保障措施如下:
-- 尝试声明键;唯一索引确保只有一个调用者能成功
INSERT INTO operations (idempotency_key, state)
VALUES (:key, 'started')
ON CONFLICT (idempotency_key) DO NOTHING;
插入操作要么影响一行记录,要么不影响,根据受影响行数判断操作结果。影响一行表示声明成功,此时调用支付提供商,然后将记录标记为“已完成”并保存响应;未影响任何行表示声明失败,此时读取记录并返回保存的响应,如果记录仍为“已开始”,则告知调用方稍后重试。有一个细节易出错:在调用支付提供商之前提交声明。否则,系统崩溃会回滚声明,抹去可能正在进行的收费记录。更麻烦的情况是,声明成功的请求在收费过程中崩溃,键会一直处于“已开始”状态,每次重试都会被告知等待一个永远不会到来的响应。这种卡住的声明就像之前的“未知”状态,当键处于“已开始”状态的时间超过正常调用所需时间时,在再次收费之前,需要向支付提供商确认实际情况。
相同键,不同请求
生产环境运行一周后,出现第二个问题,破坏了意图假设:调用方将同一个键用于两个不同的请求,分别是 200 美元和 500 美元,系统返回了第一个请求的存储响应,没有注意到金额已经改变。通过在插入键的同时存储请求内容的指纹来解决问题,这样竞争声明失败的请求可以将自己的指纹与获胜者的指纹进行比较。如果指纹匹配,就是真正的重试;如果不匹配,则说明键被用于不同的操作,会拒绝该请求。但这个修复方案很快拒绝了一个有效的重试请求。之前对整个请求进行指纹计算,包括每次尝试都会变化的时间戳和顺序不同的字段,导致指纹不匹配。指纹应该捕捉请求的核心意图,而不是字节的排列方式。如果只对精心挑选的业务字段进行哈希计算,可能会出现无声冲突,即某个被遗漏的字段会让两个不同的请求指纹匹配。如果对整个请求(去除已知的噪声,如时间戳)进行哈希计算,失败会更明显:遗漏一个可变字段会拒绝一个有效的重试请求。选择了更明显的失败方式,修复代码只需要两行:
intent = drop_fields(request.json, volatile={"client_ts", "trace_id"}) # 仅去除已知噪声
fingerprint = sha256(canonical_json(intent)) # 规范形式:键排序,数字和间距标准化
即使是“规范形式”也涉及一些决策。RFC 8785 对其进行了详细定义,但它会将每个数字转换为 IEEE 754 双精度浮点数,这会导致大数值精度丢失,所以金额最好以字符串或整数美分的形式存储。改变规范形式会导致所有存储的指纹不匹配,因此对其进行版本管理,并将版本号与指纹一起存储。
缓存的错误
第三个问题通过客户支持反馈发现:一位客户因资金不足支付失败,添加资金后使用相同的键再次尝试,却得到了之前“资金不足”的响应,系统根本没有再次询问支付提供商。原来系统缓存了所有响应,包括支付失败的响应,导致错误一直与键关联。这引出了记忆假设背后的问题:键允许记录哪些内容?最终确定的规则是:只缓存成功的响应。软拒绝或验证错误会释放声明,记录状态变回可声明,指纹保留。下一次尝试会通过更新操作重新声明键,只有一个重试请求能成功,添加资金的客户就能进行实时支付,而不是重放之前的失败响应。硬拒绝是个例外,比如盗刷卡的响应是最终结果,声明会一直保持关闭状态。遇到超时情况,不知道收费是否成功,所以会向支付提供商确认,并根据结果采取相应措施。
保障失效的情况
前三个问题都出在团队可控的端点上,第四个问题出现在对账过程中:旧支付提供商的账单上有一笔收费记录,但内部系统中没有匹配的记录。该提供商不支持幂等键,幂等性保障超出了边界,无法确保两次调用的安全性。尽力减少风险:在调用前创建待处理记录,重试前检查状态,通过对账来发现并退还遗漏的重复收费。但仍然存在一个时间窗口,即收费已经完成,但记录还未更新。一直在缩小这个窗口,但始终无法完全消除。存储幂等键的数据库也带来了一个决策问题:当数据库故障时,要么停止接受支付,要么在无保护的情况下继续接受支付。这是一个业务决策。对于低风险的写入操作,清理偶尔出现的重复记录的成本可能低于拒绝客户。但支付业务风险较高,所以选择在数据库恢复之前停止接受支付,因为丢失的销售机会可以挽回,而刚花了一个月时间了解重复收费的代价。
设计评审时的提问
对于任何存储或修改数据的操作,会问三个问题:1. 如果这个操作执行两次会怎样?对每一次写入操作都要明确提出这个问题;2. 能证明答案吗?在测试中按顺序和并行方式执行两次操作,第二次执行应该不会改变任何结果;3. 当系统之间出现分歧时,真相在哪里?对于支付业务,支付提供商的记录能显示资金是否实际转移,所以真相在他们那里。在事故发生前,要确定以谁的答案为准。幂等键是个好主意,在涉及资金转移的业务中是必要的,但它并不能提供绝对保障。真正的保障在于围绕它的设计:无竞争的声明、指纹确认的意图、安全可重放的记忆以及预先规划的边界。这就是“四个假设测试”。每个假设最终都需要接受检验,要么在设计阶段主动测试,要么在生产环境中被动接受考验。
