支付系统重复收费难题:幂等键依赖的四个假设及应对之策

📅 2026/7/3 4:25:18
支付系统重复收费难题:幂等键依赖的四个假设及应对之策
突发支付系统重复收费事件周二下午财务团队称当天有少数客户被重复收费其中一位客户正就重复收费问题与银行交涉。查看监控发现系统记录显示每笔订单只支付了一次团队花一个月排查才解决仪表盘显示正常与客户被重复收费的矛盾。此后在多个支付系统也看到类似失败情况下面内容综合多个案例编写不针对单一系统或组织文中数字、时间和识别细节已修改。导致重复收费的重试操作客户点击“支付”后订单服务调用支付服务支付服务再调用外部支付提供商提供商对客户银行卡收取 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. 当系统之间出现分歧时真相在哪里对于支付业务支付提供商的记录能显示资金是否实际转移所以真相在他们那里。在事故发生前要确定以谁的答案为准。幂等键是个好主意在涉及资金转移的业务中是必要的但它并不能提供绝对保障。真正的保障在于围绕它的设计无竞争的声明、指纹确认的意图、安全可重放的记忆以及预先规划的边界。这就是“四个假设测试”。每个假设最终都需要接受检验要么在设计阶段主动测试要么在生产环境中被动接受考验。