支付链路 P0 复盘:幂等键碰撞导致重复扣款

2026-05-15 事故复盘 — 范围、根因、修复、预防

5 月 15 日上午 09:14,支付网关在 11 分钟内对 1,287 笔订单产生重复扣款。直接原因是幂等键由"订单号 + 时间戳"拼成,重试窗口内的两次请求生成了相同的键,被下游误判为同一笔交易。本文档把根因、修复与未来的护栏整理成一份可执行的清单。

这是一份内部复盘,所有金额与订单号均为脱敏后的模拟值。文档同时被用作 Talon Doc Runtime 的组件总览 — 每个组件都对应文档中一个真实位置,不为了演示而堆叠。


核心决策

事故复盘会上提出过三个方向:回滚到旧路由、引入分布式锁、重设计幂等键。我们最终选择第三种,因为前两个都只是把窗口收窄,没有解决"两个不同请求可能合成同一个键"这个根本问题。

当前的"订单号 + 秒级时间戳"组合无法在重试场景中保证唯一性 — 同一秒内的两次请求会得到相同的键,被网关误合并为一次扣款。 由客户端在创建请求前生成 UUIDv7(含毫秒时间戳 + 随机后缀),通过 HTTP header 透传。即便重试也复用同一个键,重复请求被网关自然去重。 提案是给网关加一把短时锁,相同订单号在锁内不允许进入扣款。可以快速止血。 否决原因:锁只是把碰撞窗口从秒级压到毫秒级,没有解决"两次请求来自同一次重试"这个语义问题;同时给关键路径引入新的故障点(Redis 不可用 = 支付不可用)。

背景与误解

事故发生后社区出现了几种与事实不符的解读,先在这里厘清。

"幂等键碰撞 = 客户端没传幂等键"。事故期间客户端确实传了键,问题在于不同请求生成了相同的键 幂等键存在 ≠ 幂等键唯一。键的唯一性必须由生成算法保证;服务端只是按键去重,不会替你验证键的"分布合理性"。 用户点了第二次"支付"按钮 — 是一次独立的下单意图,应该被独立处理。 收到了第二个相同幂等键的请求 — 视为第一次请求的重试,结果直接复用第一次的扣款记录。

事件时间线

支付网关日志显示同一订单号收到两笔成功的扣款回执。 基于"5 分钟内同账户多笔小额交易"的规则触发,但未升级到 P0。 第一位用户拨打客服热线,描述"扣了两次但订单只有一笔"。 SRE 通过日志关联,确认是网关层去重失效。 回滚到上一版本的路由配置,新请求改走旧支付路径;下游开始补偿。 1,287 笔订单全部完成退款,客服侧确认无遗漏。

根因还原

下面这张流程图刻画了事故期间一笔订单的完整路径。两条相同的幂等键最终被网关视为同一笔交易,但下游各自完成了扣款。

两个请求经过网关时生成了 order-1023-1747291043 这同一个键。下游扣款服务 A 与 B(不同实例)各自把这个键作为"新交易"写入了流水。等到 09:14:04 才在汇总层发现键冲突 — 此时两笔扣款都已成功。

幂等键的三种命运

网关在处理一个幂等键时,会走下面三个分支之一。事故期间,"键已存在但状态未达终态"这一支被错误地归并到了"新键"。

正常路径:客户端首次发起、且键唯一。绝大多数请求走这条。 幂等设计的"happy path":已经完成的交易,直接回放之前的响应。 这是 bug。原意应该是"返回 409 让客户端重试",但实现里把"处理中"的键当作了"未见过的键",于是又走了一次完整扣款。

修复方案论证

选择 UUIDv7 作为幂等键的依据如下。每条结论都对应一段可验证的代码或测试。

v7 的时间戳部分精度到毫秒,单一节点一毫秒内的两次请求差异已经能被随机后缀覆盖。 客户端本地生成 — 即使网络重试,同一次"用户意图"复用同一个键。 键由 16 字节构成,无需任何中心化协调,适合移动端弱网场景。 已通过 :10 亿键,0 次碰撞。
// 10 亿次 UUIDv7 生成 — 单机基准,验证碰撞率。
import { generateUUIDv7 } from './uuid7'

const seen = new Set<string>()
const N = 1_000_000_000
let collisions = 0

for (let i = 0; i < N; i++) {
  const id = generateUUIDv7()
  if (seen.has(id)) collisions++
  seen.add(id)
}

console.log(`collisions=${collisions} of ${N}`)
// → collisions=0 of 1000000000
测试在 M2 Max 上跑了约 14 分钟。集合的内存峰值 ~38 GB;生产侧不需要保存全集,这里只是为了观察是否出现碰撞。

客户端只需要在请求头加一行:

headers.set('Idempotency-Key', generateUUIDv7())

方案多维评估

在三个候选方案之间做平面对比时,单一指标会掩盖权衡。下表把各个轨道单独展开。

UUIDv7 在数学上达到 1e-18 量级;Redis 锁只能在锁未过期时保证;回滚方案直接保留旧 bug。 UUIDv7 改动集中在客户端 SDK + 网关解析;Redis 方案需要新建一组缓存集群与告警。 需要同时支持旧键格式 6 个月。期间网关解析逻辑变复杂,是新的潜在 bug 面。 已在 idempotency_key_collision_rate 指标上加了告警;建议每月手动 review 一次基线。

客户端 vs 服务端生成键的取舍

  • 同一次"用户意图"的多次重试复用同一个键,天然幂等。
  • 网关无状态、无需新依赖。
  • 移动端弱网场景表现良好。
  • 无法分辨"重试"与"两次独立下单"。
  • 需要给网关引入会话存储以追溯键。
  • 客户端崩溃重启后键就丢了。

修复进度

影响面与分布

受影响订单按金额分桶;可以看出绝大部分是小额订单,但单笔最大金额仍超过 ¥3,000。

补偿进度按渠道拆分。微信渠道占大头但退款最快;银联与信用卡因清算窗口需 T+1。

未消化的风险

修复方案合入后仍有几条遗留风险,监控中:

附录:原始日志与代码

下面这些是排查过程中提取的原始材料,平时不影响阅读,留给后续审计与新人 onboard。

错误判断的代码位置如下。status 字段在 "processing" 状态时没有进入 fast-path,反而落到了 default 分支被当作"未见过的键"。

async function dispatchByKey(key: string, req: Request) {
  const record = await store.get(key)
  switch (record?.status) {
    case 'committed':
    case 'failed':
      return replay(record)            // 终态 → 复用历史响应
    // BUG: missing 'processing' case
    default:
      return processAsNew(key, req)    // 误把"处理中"也当作新键
  }
}
修复后 processing 状态会返回 409 Conflict,客户端拿到 409 后停止重试并轮询终态。
2026-05-15T09:14:03.412Z gateway INFO  key=order-1023-1747291043 status=new dispatch=A
2026-05-15T09:14:03.918Z gateway INFO  key=order-1023-1747291043 status=new dispatch=B
2026-05-15T09:14:04.105Z gateway WARN  key=order-1023-1747291043 collision_detected committed=2
09:14:03.412 dispatch=A
09:14:03.918 dispatch=B
09:14:04.105 collision detected

当日累计 6 小时客服处理,平均通话时长 4 分钟。所有用户在 T+0 收到自动退款短信,无投诉升级。


Action items

网关 fix(commit a4f12c)已合入 main。 客户端 SDK 升级 — Web 端已发版,iOS/Android 灰度中(预计本周三全量)。 新增"网络抖动 + 自动重试"的混沌演练用例。 把幂等键唯一性纳入新接口设计的 review checklist。 6 个月后清理网关兼容代码,前提是旧格式流量降到 0。

责任人

网关解析、客户端 SDK、混沌演练用例。

@chenyu

review checklist、复盘机制、新接口规范。

@ling

兼容窗口指标看板、6 个月后下线决策。

@huangxin

本次修复涉及的文件

术语表

是这次事故的中心概念, 是修复采用的具体方案。两者结合的关键不在于"用了新算法",而是 的转移。

幂等性是一个端到端的契约,不是某一层的算法。键的唯一性必须由"最理解请求意图"的那一端来生成 — 也就是客户端本身。

整个修复看上去只是换了一个键的生成方式,但真正改变的是关于"重试"的语义边界 — 之前网关在猜"这是不是一次重试",之后由客户端明确地标注"这是同一次意图的第 N 次发送"。这种把"猜"换成"声明"的设计选择,可以推广到鉴权 nonce、订单号、文件上传 chunk-id 等多个场景。


操作

这一节是 Talon Doc Runtime 的交互演示,按钮触发的是 runtime 的内置 action:

定位 bug 源码 展开基准测试 切换深色 切换浅色 切换长文风格 切换办公风格

内联状态徽标也是组件的一种:当前事故 P0,目前状态 已关闭,仍有 3 项后续 待跟踪。


事故复盘的目标不是分配责任,而是把"今天为什么会发生"翻译成"明天为什么不会再发生"。每一条 action item 都对应文档中一段具体的根因 — 这样的 Map 比追责更能让团队真正变好。