侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 172 篇文章
  • 累计收到 7 条评论

重试整轮,已经扣过的款又扣了一次

2026-10-11 / 0 评论 / 3 阅读

重试整轮,已经扣过的款又扣了一次

一次下单流程拆成三步,先查余额,再扣款,最后写回执。第三步的上游偶发 502,调度层重试,用户那边看到两次扣费。出问题的边界很窄:重试的粒度是整轮,而中间的扣款带副作用。

拿一份桩代码把三种处理方式跑一遍,比较的是同一笔订单最终在流水里留下几条扣款记录。桩里的工具就是往一个数组里追加一行,方便数数;调度层写成一个函数,重试由外层的循环驱动。三组用同一份步骤定义,只有重试策略不同。

表一:整轮重试

第二轮从第一步重新开始,扣款跟着又跑了一次。

== 1. 整轮重试:第 3 步失败,从第 1 步重跑 ==
尝试 1:
  步骤 get_balance 完成
  步骤 charge 完成
  步骤 write_receipt 失败: write_receipt: 上游 502
尝试 2:
  步骤 get_balance 完成
  步骤 charge 完成
  步骤 write_receipt 失败: write_receipt: 上游 502
ledger 里 A-1001 的记录数: 2
实际扣款合计: 198 元(本应 99)

两条记录,合计 198 元。真实系统里这就是用户账上的两笔,而订单只有一笔。重试本身没错,错在重放的范围把有副作用的步骤一起包了进去。这一类错在测试里很难出现,因为触发它需要一次真实的失败。

表二:扣款工具自己收幂等键

把去重做在工具内部:同一个键第二次进来,直接返回第一次的结果,不再执行。

== 2. 带幂等键:同一 key 第二次直接返回上次结果 ==
第一次 charge(B-1002, idem=k-1): deduped=false
重试后 charge(B-1002, idem=k-1): deduped=true
ledger 里 B-1002 的记录数: 1
实际扣款合计: 99 元

第二次返回里多了个 deduped 标记,扣款合计回到 99 元。调用方不需要改重试逻辑,重试几次都只落一笔。这份桩把已处理的键存在一个 Map 里,真实实现换成带唯一索引的表效果一样,靠数据库拒掉重复写。

表三:只重试失败的那一步

换一种做法,把已经完成的步骤记下来,重试时跳过它们。

== 4. 只重试失败的那一步(对照写法) ==
尝试 1: get_balance 完成
尝试 1: charge 完成
尝试 1: write_receipt 失败 — write_receipt: 上游 502
尝试 2: 跳过已完成的 get_balance
尝试 2: 跳过已完成的 charge
尝试 2: write_receipt 完成
ledger 里 C-1003 的记录数: 1
实际扣款合计: 99 元

第二次尝试直接跳到 write_receipt,成功之后就结束了。这一组同样只落一笔,区别在于挡在什么位置:幂等键挡在工具里,跳过已完成步骤挡在调度里。调度里那个集合需要持久化,进程重启之后前面的步骤才不会重跑。

幂等键得是重试之间不变的那一个

== 3. 幂等键放哪里:三种 key 的生成方式,跨重试是不是同一个 ==
时间戳 Date.now()           两次取值是否相同: false   (t-1791722813704 / t-1791722813707)
随机 UUID                  两次取值是否相同: false   (u-bb06cd43 / u-8cdddf31)
业务键 orderId + 动作         两次取值是否相同: true   (order-B-1002-charge / order-B-1002-charge)

三种生成方式里,时间戳和随机数在两次调用之间都会变,只有由业务字段拼出来的那个稳定。用前两种等于每次都是新请求,去重表形同虚设。这一条不需要跑代码也看得出来,但实际项目里在工具函数内部随手写 Date.now() 的做法并不少见。

三组的差别落在哪一步

整轮重试那组的问题不在重试次数,在于重放的集合包含了「已经生效过」的操作。剩下两组解决的是同一件事的两个面,一般不冲突,可以同时用:调度层尽量只重试没成功的那一步,工具层对写操作一律收幂等键。两层都做了,某一次调度层没判断好,工具层还兜得住。

代价也要说清楚。幂等键要求工具能存下「这个键处理过」,多一张表或者多一份缓存;只重试失败那一步要求每个步骤都能判断自己是否完成,读操作好办,写操作最后还是绕回幂等这件事。两边都要额外的存储,只是位置不同。

桩代码之外的部分

那个 502 是桩里写死的,真实的上游什么时候抖、抖几次,这一段我没实测。能定下来的是这类问题的判断位置:先标出哪些工具是写操作,再决定重试的粒度,顺序反了就只能靠补救。前面三组对照里的数字都来自本机这一份桩代码,换一套调度实现,次数会变,结论的方向不变。

    🤞 分享