同一笔钱被扣了两次,该怪哪一步

2026-10-06 Aryee 0

先把结论摆在这儿:「重试」这个动作本身没有错,错的是重复执行会产生重复的后果。 一个设计到位的系统,同一笔请求来一次和来十次,结果应该完全一样——扣一次钱、发一趟货、开一张票。这件事在架构上有个名字,但它在业务上只有一句话:你要能回答"如果这一步被执行了两遍,会发生什么"。

多数团队的处理方式是出一次事故修一次:把那个 bug 修掉,给那个接口加个判断。于是三年后系统里有十七个各修各的防重点,而下一个新写的接口照样没有。这一篇想说的是另一件事:这是一层设计,不是一批补丁。

先承认:这是一道没有第三条路的选择题

系统调用的时候,总有那么一刻会超时——网络抖一下、对方机房慢一下、连接被掐了。超时的麻烦不在于慢,在于它不告诉你结果:请求可能根本没送到,也可能已经送到并且已经扣款成功,只是回信在路上丢了。

这时候只有两条路可走:

不重试。 那这一单就丢了。客户那边显示"支付中",钱已经扣了,你这边没有订单。丢单要人工找回,一天几百单的系统丢三十单,客服成本比技术成本高。

重试。 那就有概率把同一件事做两遍。

所有活着的系统最后都选了重试,因为丢单的损失更直接。这不是谁的失误,这是这条路的固有代价——所以真正要设计的东西,是"重试之后怎么保证只做一遍"。 把它当成一个待修的 bug,就永远修不完。

重复是从哪五层来的

按离客户的距离排,每一层都在独立地制造重复,而且它们互相不知道对方存在。

第一层:客户那一侧。 页面转圈,客户以为没成功,又点了一次;手机信号不好,同一个请求被发了两遍;客户在两个设备上打开同一个订单。这一层最容易被"按钮点一下就变灰"挡掉——也最容易让人误以为挡掉了。变灰只挡得住同一个浏览器里的同一个人,挡不住后面四层。

第二层:入口和网关。 请求进到系统之前要经过负载均衡、网关、代理。这些组件的默认行为里就写着"后端没回应就换个机器再发一次"。市场上有篇讨论很实在:为什么有些网关默认不重试写操作——因为一旦重试,同一个下单请求就会打到两台服务器上。你要的"高可用"和你要的"只做一遍",在这一层是互相冲突的。

第三层:服务自己调下游。 订单服务调支付、支付调银行通道。只要中间任何一跳配了重试,同一个扣款指令就会送出去两次。这一层的重复最隐蔽,因为它发生在两个你自己写的模块之间,出了问题谁都不觉得是自己的责任。

第四层:消息和异步任务。 这一层要单独说,因为它的重复不是故障,是设计选择。绝大多数消息队列默认走"至少送达一次"——宁可给你两条一模一样的消息,也不给你漏一条。为什么这么选?漏一条意味着丢一单,重复一条只是多做一遍,两害相权。所以只要你的系统用了消息、用了异步任务,重复就是必然会来的,剩下的问题只是你的接收端怎么认出来。

第五层:故障恢复和补跑。 半夜任务挂了,早上有人把失败的那批重新跑一遍;数据同步断了半小时,恢复后把缺的那段补上;某个流程卡住,运维手工重放了一次。这一层的重复是人在制造的,而且往往发生在最紧张、最没有时间核对的时刻。最近技术圈讨论"故障恢复之后怎么避免重复执行",说的就是这一格——它最贵,因为每一次都是临时决定。

五层的共同点是:每一层都觉得自己只是"再试一次",只有叠在一起才变成"做了两次"。

按后果分,而不是按系统分

技术团队习惯按系统列清单:支付接口要防重、订单接口要防重、库存接口要防重。列到第二十行就漏了。更实用的是按后果分档,因为能不能补救决定了你要花多少钱防它。

重复了还能退回来的: 通知类(多发一条短信、多推一条微信)、内部记录类(多一条日志、多一次统计)。这一档漏了防重,代价是客户烦、报表歪,能忍。

重复了要花钱花人才能退回来的: 扣款、退款、结算、发优惠券。钱能退,但要手续费、要客服工时、要客户打电话进来,而且每一笔都要有人签字。这一档是多数事故复盘里损失最大的那一格。

重复了退不回来的: 库存已经出库、货已经发走、发票已经开出去(红冲比开票更麻烦)、给第三方提交的开户或物流单。这一档的共同点是动作已经离开了你的系统,你在自己这边怎么判断都拦不住对方那一份。

最麻烦的是外部动作那一类: 你重试不了对方,只能靠一个双方都认的单号。所以和外部对接时,"我们这边生成的那串号有没有传过去、对方有没有拿它做唯一判断",是比任何内部设计都值钱的两个问题。

分完档你就知道钱该花在哪:不是所有接口都要同一档防护,但上面那两档必须有人点名认领。

三种防线,各挡得住哪一层

第一种:给每一次操作一个贯穿全程的业务唯一号。

通俗说就是:这笔扣款从客户点下按钮开始,就带着一张"身份证",它经过网关、经过订单、经过支付、经过消息、经过补跑,每一站都拿这个号先问一句"这个号我处理过没有"。处理过就直接返回上一次的结论,不再做第二遍。

这是三种里最便宜、覆盖面最广的一种——它同时挡得住前面五层,因为不管重复从哪一层来,号是同一个。

它的代价不在技术,在纪律:这个号必须从最外层一路传到最后,任何一站没往下传,那一站之后防重就断了;任何一站自己造了一个新号,重复就来了。所以这一种防线的真实成本是"有人负责检查这条链有没有断",而不是写代码。

顺带一句:这个号必须是业务意义上的唯一(同一笔支付只能有一个),不能是时间戳或者随机数——时间戳在重复请求里恰好就是两个不一样的。

第二种:状态机,把"重复"变成"非法状态"。

订单从"待支付"到"已支付"只允许走一次;已经"已支付"的订单再收到一次支付成功,系统不重新处理,只回一句"这个我已经处理过了,结论是上次那个"。

这一种挡得住第一到第三层,尤其擅长挡"同一个订单被两条链路各推进一次"。它的代价是状态要提前设计清楚:有多少个状态、谁能把状态从哪推到哪、推错了怎么退。一旦定了,改状态流转就是改业务规则,要评审。很多团队不是不会写状态机,是不愿意承认"我们的状态流转是业务规则,不能随手改"。

第三种:每日对账,把漏网的捞回来。

不指望前面一层都不漏,而是每天把两边的流水摆在一起比:我们的订单和支付渠道的账、我们的出库和仓库的账、我们发出的消息和对方消费的记录。对不上的挑出来,重复的退掉。

这一种是唯一一种承认自己会漏的防线,也因此是所有防线里最值钱的——它把"没人知道的重复"变成"第二天有人处理的重复"。它的代价最直白:钱已经动过、客户已经打过电话、货已经在路上。对账救的是损失,不救体验。

这三种不是三选一。 唯一号管住"同一件事别做两遍",状态机管住"不该发生的跃迁别发生",对账管住"前面两层都漏了怎么办"。只做第一种的系统,一旦某个环节没带号就裸奔;只做对账的系统,每天都在退款。

为什么这一格在 2026 年变贵了

两个变化都跟"谁在发起重试"有关。

第一,代码越来越多是生成的,而重复执行这个缺陷在测试环境里看不见。 一个功能写完,正常路径跑通、异常路径跑通,测试全绿——因为测试环境里没人会在超时那一刻再发一次。重复执行只在真实故障、真实网络抖动、真实补跑的那一次才暴露,而那一次通常不在上线前。所以"AI 写出来的代码有没有这一格"这个问题,问代码本身问不出来,只能问设计评审里有没有这一条。

第二,重试不再由人发起。 这是最近半年技术圈反复在写的事故类型:一个自动跑的任务超时了,它自己重试了一次,客户被扣了两遍钱;一个智能体在长流程里判断"上一步可能没成",重放了一次,券发出去两张。人在电脑前的时候,重复是能被看出来的——屏幕上弹了两次、客户打电话进来。自动跑的任务在夜里跑、在队列里跑、在别人的服务器上跑,没有那双眼睛。

同一时间,异步长任务、智能体、自动补跑这些形态正被大量接进业务系统。最近一篇讲「智能体接支付」的分析里有句话我认为是这一整年最实在的一句判断:任务可以昨天没做完今天接着做,但支付不能把昨天的交易原样重放。 它点出的变化也不是效率,而是资金安全、授权校验和结果对账这三格——因为一个能跨小时、跨天继续跑的流程,等它真的动手那一刻,商品条件、账户状态、授权范围都可能已经变了,而它自己不知道。

所以这一格从"支付团队的专业问题"变成了"所有自动化流程的准入问题"。

三个自查问题

这三个问题都不需要读代码,现在就可以问技术负责人,答案要能在十分钟内给出来。

一,"下单、扣款、发货"这三个动作,如果同一个请求连着进来两次,会发生什么? 只有两种合格答案:一种是"会生成两笔"(那这一格没做),另一种是"第二笔会被认出来,直接返回第一笔的结果",并且他能说出靠什么认出来的。第三种答案最危险——"应该不会吧,我们有限流/有唯一索引/前端有防抖",这三样都挡不住第四层和第五层。

二,半夜补跑的那批任务,跑第二遍会不会重复? 这一格是五层里唯一由人制造的,也是最容易没有规则的。合格的答案要么是一份写清楚的"哪些任务可以安全重跑、哪些必须先看一眼",要么是补跑前有一道人工确认。不合格的答案是"看情况"。

三,我们有没有一张每天在跑的重复清单? 同一订单两笔扣款、同一发票两个号、同一客户两次发货。如果这张清单不存在,那么"我们系统没有重复扣款"这句话的真实含义是"我们从来没查过"。这一条最便宜,也最容易被跳过——因为它不解决任何问题,只是让问题变得可见。

读完能做什么决定

今天就能做的:把上面那三个最贵的动作点名(多数公司是扣款、发货、开票),逐个问第一个自查问题。这一步一分钱不花,它只是让你知道这一格是空的还是满的。

这个季度要做的:给写操作配一个贯穿全链的业务唯一号,同时把每日对账那张表建起来。这两件事的顺序建议反过来理解——先建对账,再谈防重。因为对账能让你知道到底漏在哪一层,而防重改造一旦拍脑袋选一层做,很可能做的正是重复最少的那一层。

可以不做的那一档:纯查询、纯报表、内部工具。给这些加防重是纯粹的成本,没有收益。

最后留一句给正在往业务里接自动化流程和智能体的团队:一个不会重复执行的系统,和一个"没人重试所以看起来不会重复"的系统,长得一模一样。区别只在出事那一次——前者的那一次会留下一条"已处理过"的记录,后者的那一次会留下一张客户来电工单。你现在就可以问一句:我们上一次真的遇到超时,是什么时候,留下了什么。

你手上那套系统,卡在哪一步?

留个手机号和方便的时间,我打过来先听你说现状与约束,再给可执行的判断:这套系统是该继续修、该动哪里,还是干脆重做一版设计更省。

约一次沟通不接纯 UI 外包,只做系统层面的问题。