支付链路 P0 复盘:幂等键碰撞导致重复扣款
2026-05-15 事故复盘 — 范围、根因、修复、预防
这是一份内部复盘,所有金额与订单号均为脱敏后的模拟值。文档同时被用作 Talon Doc Runtime 的组件总览 — 每个组件都对应文档中一个真实位置,不为了演示而堆叠。
核心决策
事故复盘会上提出过三个方向:回滚到旧路由、引入分布式锁、重设计幂等键。我们最终选择第三种,因为前两个都只是把窗口收窄,没有解决"两个不同请求可能合成同一个键"这个根本问题。
背景与误解
事故发生后社区出现了几种与事实不符的解读,先在这里厘清。
事件时间线
根因还原
下面这张流程图刻画了事故期间一笔订单的完整路径。两条相同的幂等键最终被网关视为同一笔交易,但下游各自完成了扣款。
两个请求经过网关时生成了 order-1023-1747291043 这同一个键。下游扣款服务 A 与 B(不同实例)各自把这个键作为"新交易"写入了流水。等到 09:14:04 才在汇总层发现键冲突 — 此时两笔扣款都已成功。
幂等键的三种命运
网关在处理一个幂等键时,会走下面三个分支之一。事故期间,"键已存在但状态未达终态"这一支被错误地归并到了"新键"。
修复方案论证
选择 UUIDv7 作为幂等键的依据如下。每条结论都对应一段可验证的代码或测试。
// 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
客户端只需要在请求头加一行:
headers.set('Idempotency-Key', generateUUIDv7())方案多维评估
在三个候选方案之间做平面对比时,单一指标会掩盖权衡。下表把各个轨道单独展开。
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
a4f12c)已合入 main。责任人
网关解析、客户端 SDK、混沌演练用例。
review checklist、复盘机制、新接口规范。
兼容窗口指标看板、6 个月后下线决策。
本次修复涉及的文件
术语表
整个修复看上去只是换了一个键的生成方式,但真正改变的是关于"重试"的语义边界 — 之前网关在猜"这是不是一次重试",之后由客户端明确地标注"这是同一次意图的第 N 次发送"。这种把"猜"换成"声明"的设计选择,可以推广到鉴权 nonce、订单号、文件上传 chunk-id 等多个场景。
操作
这一节是 Talon Doc Runtime 的交互演示,按钮触发的是 runtime 的内置 action:
内联状态徽标也是组件的一种:当前事故
事故复盘的目标不是分配责任,而是把"今天为什么会发生"翻译成"明天为什么不会再发生"。每一条 action item 都对应文档中一段具体的根因 — 这样的 Map 比追责更能让团队真正变好。