ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

修复一个坏案例带崩十个正常场景:结算分摊事故复盘

修复一个坏案例带崩十个正常场景:结算分摊事故复盘 我到现在还记得那个周五晚上。工单标题写着订单 A12345 实付金额为负我花了不到三个小时定位、补分支、写单测、发版然后就看到测试同事在群里连发三条告警截图线上对账差异、分摊金额合计对不上、结算报表跑不平。再往后翻平时跑得稳稳的十个正常场景一夜之间全炸了。这不是偶然。把某一个坏案例修好和让所有正常场景继续正常是两件事而我当时只做到了前者。这篇复盘我拖了很久一直觉得把它讲清楚比发一篇技术总结更有价值——尤其是对做结算、规则引擎、数据处理这类一个公共函数被成千上万业务依赖的系统的人。先说明白这篇文章不是通用教程而是一个真实事故的完整复盘——坏案例是怎么修的、十个正常场景为什么会被同一个改动带崩、以及我事后重建了哪些防御手段。场景和代码做了简化但排查链路和结论是我实打实踩过的。1. 先复盘那个坏案例我到底改了什么1.1 工单的原始现象我们的系统承担订单结算和优惠分摊。简单说一张订单有多个行项目商品行下单时用了优惠券结算时要把券金额按权重分摊到每个行项目上得出每个商品实际付了多少钱。那个坏案例的现象很直观订单里有一个行项目已经全额退款了但分摊逻辑仍然把一部分券金额分到这个退款行上导致它的实付金额变成负数。下游对账模块看到负金额直接拉警报业务同学截了图工单就转到我这里。从业务上看这个现象确实不正常一个已经退完款的商品凭什么还要承担优惠券金额数字变成负数等于顾客不仅没付钱还倒赚了。任何一个正常人看到这个结果都会判定逻辑有 bug。1.2 第一次排查从现象直接滑到了结论我当时的定位路径很常规打开分摊函数allocDiscount()读代码发现它根本没有针对退款行做任何特殊处理。它只按照行项目金额占比来分配券金额完全不关心这个行项目是否经历过退款。举个例子订单有两行A 行 100 元退款后变 0B 行 100 元一张 20 元全场券。按权重分摊A 行和 B 行各分 10 元于是 A 行实付变成 -10 元看起来就是退完款还被扣了钱。于是我很自然地得出一个结论退款行应该跳过分摊。一个退款行金额已经是 0 甚至负数让它继续参与分摊本身就是它产生负数的原因。这个结论在当时听起来无懈可击。1.3 那个后来惹祸的快修修复就三行核心逻辑在分摊函数最前面加了一个提前返回// 修复前退款行也会参与分摊导致实付金额为负 if (line.isRefunded() line.getAmount() 0) { line.setAllocatedDiscount(0); return; }意思很简单只要行项目被标记为退款并且金额小于等于 0就直接把分摊金额置为 0返回不再走后面的分摊逻辑。当时我特意构造了复现用例坏案例订单、退款行、负金额单测全绿。又放了一小波灰度流量线上没有即时报错。我心里想的是行了这个 bug 算是修完了。1.4 这个补丁在当时看起来完美的三个理由现在回看当时觉得完美是有原因的而且每一个原因都很有代表性单测只覆盖了坏案例本身你给一个 bug 加了测试测试当然会过。但它只证明这个输入不再产生这个输出并没有证明其他输入依然产生正确输出。灰度流量没测到关键场景小流量放出去大部分是普通订单。退款行占比本来就不高十个出问题的场景里可能一个都没进灰度样本。改动看起来局部只加了一个提前返回没动后面的主逻辑从 diff 上看非常克制给人影响面很小的错觉。这三条加起来就是一个教科书式的看似安全实则危险的补丁。2. 补丁上线之后十个正常场景是怎么集体崩掉的2.1 第一波告警来得比想象中快灰度还没放完测试同事先在内网环境复现出了差异一笔部分退款的订单分摊后的合计金额和订单实付金额对不上。随后告警群开始刷对账差异量不算大但每一条都指向同一类订单——只要包含退款行。我当时的第一反应是不可能我明明只改了退款行为的处理。结果一看告警明细整个人清醒了一半出问题的订单里退款行只是其中一个特征这些订单在各自业务场景下全都属于正常状态。2.2 十个场景的完整清单我把反馈来的问题订单做了聚类最终得到十个场景。这里列出来你会发现共同点非常明显而共同点恰恰是陷阱所在场景订单特征改动前行为改动后行为1部分退款剩余行金额为正券按剩余金额比例分摊账目正确退款行被跳过券少分摊合计差异2全额退款但订单尚未关闭退款行仍参与权重计算账面对退款行被跳过券金额悬空无归属3多行订单一行全额退款其他行多张券叠加多张券在剩余行间正确叠加退款行退出叠加比例错乱4跨店结算大订单部分行退款按店铺维度分摊后汇总正确退款行退出店铺维度金额差异5退款后重新入账的行项目退款撤销行金额变正后正常参与分摊残留退款标记导致该行被永久跳过6赠品行与正常行混合赠品行退款赠品行不承担券但参与权重赠品行被跳过顺序依赖的其他行错乱7组合支付订单的行级退款行级金额精确计算对账一致退款行退出后支付维度无法勾稽8多币种订单的退款换算行汇率换算出正金额后参与分摊只要金额小于等于 0 就跳过漏分摊9售后换货生成的差价行差价行金额为负但业务上正常被当成退款行跳过换货订单金额错10预付款订单的保证金行保证金行金额可为负本身合理被跳过导致预付款单结算异常看到这张表问题严重性已经不用多说了我用来识别坏案例的条件只是退款 金额 0这个外部特征而这个特征在另外十个正常场景里同样成立。2.3 为什么是十个一起崩而不是一个一个崩因为它们全部命中同一条代码路径——分摊函数里的那个提前返回。任何一张订单只要有一个行项目满足isRefunded() amount 0分摊逻辑就直接短路。这个短路对坏案例是正确的但对十个正常场景来说等于把后面整套分摊算法整个绕了过去。本质上我犯的错是用特征去匹配问题而不是去理解问题为什么发生。十个场景崩在同一行代码上不是巧合是必然。同一个外部特征背后可能有完全不同的内部语义特判一旦覆盖过宽必然误伤。3. 完整排查链路从十个告警收敛到一行特判3.1 第一步把告警订单反转成查询条件找共同字段收到十个场景的反馈后我没有直接去看代码而是先让数据同学把所有出问题的订单 id 拉出来做字段分布统计。这一步很关键先找数据的共同点再回代码里找代码的共同点顺序不能反。统计结果出乎意料地干净100% 出问题的订单都包含至少一个满足isRefundedtrue且amount0的行项目而坏案例本身也满足这个条件。到这一步怀疑对象从某个业务场景收敛到了某一个判断条件。排查这里有个很实用的技巧告警往往是按业务场景聚合的但排查时要把告警订单反解成原始数据特征再求特征集合的交集。交集越窄越接近真相。3.2 第二步Git diff 代码走查确认改动点接着我把这个模块最近一次改动单独拉出来看 diff。总共就一个文件、一次提交核心就是那个提前返回。代码走查时我问了自己三个问题条件表达式里每一个字段的语义是什么除了坏案例还有哪些数据会命中这个条件命中条件后直接返回后面本来要执行的分摊逻辑对这个输入有依赖吗第三个问题我当时没答上来——或者更准确地说我根本没有意识到需要回答。而后面的逻辑对这个输入是否有依赖恰恰是判断跳过是否安全的唯一标准。如果后面的分摊逻辑本来就能正确处理退款行只是权重计算有缺陷那正确的改法应该改权重而不是短路整个函数。3.3 第三步做回滚一半实验确认唯一变量为了验证就是这一行特判导致十个场景崩我做了个很笨但有效的实验把特判去掉恢复旧提交只保留坏案例复现用例跑全量回归。结果十个场景全部恢复正常坏案例依然错实付金额为负。再把特判加回来坏案例好了十个场景又崩。反复两次结论没有任何歧义——这行特判就是唯一的变量。这一步的价值在于它排除了多个改动叠加导致问题的可能也把环境差异数据漂移这些干扰项排除掉了。做排查时能用二分法锁定唯一变量就不要靠猜。实验比讨论高效得多。3.4 第四步把根因挖到底——问题不在该不该跳过当天晚上我开始冷静下来看根因为什么一个退款行会被分到负数最终答案是分摊算法的权重计算对退款行存在边界缺陷。退款行金额为 0 或负数时权重计算没有把它从分母里剔除导致一个 0 权重的行分到了券金额这才出现负数。正确做法是在权重计算阶段就把金额小于等于 0 的行排除出分母而不是在分摊函数入口直接短路。两个改法的差别在于一个是让不该分到钱的行不分到钱一个是整个跳出分摊流程。前者改变的是算法内部的权重逻辑后者改变的是函数的调用契约。而十个正常场景依赖的恰恰是这个调用契约。4. 这类修一崩十事故的三个通用模式4.1 模式一用跳过代替修正这是最危险的补丁模式。跳过意味着我这套主逻辑处理不了这个输入所以把输入挡在外面。但主逻辑处理不了往往不是因为输入不该进来而是因为主逻辑本身有缺陷。你把输入挡掉缺陷还在总有一天会以另一种形式爆出来。这次的缺陷是权重分母没有剔除零值行如果我当时直接修权重退款行不会分到券其他十个场景也不会有任何变化。但我选择了跳过等于把一个局部算法缺陷放大成了整个函数的契约变更。4.2 模式二用外部特征圈定问题实体我用的条件是isRefunded() amount 0这是坏案例的外在特征不是坏案例的本质。本质是什么本质是一个权重为 0 的行项目不应该参与分摊权重计算。正确的条件应该围绕权重是否为 0来写而不是围绕是否退款、金额是否非正来写。特征会骗人本质不会。场景 9 的差价行金额是负的但它的负值本身就是业务常态场景 5 的退款撤销行有退款标记但金额已经变正理应重新参与分摊。用静态特征去描述动态语义误伤是迟早的事。4.3 模式三没有回归基线十个正常场景处于裸奔状态说句实在话如果当时系统里有一组覆盖这十个正常场景的回归用例这个补丁根本不可能发出去。它们为什么没有因为上一个负责这个模块的人没建我也默认线上跑得好好的不需要额外保护。等到线上真的崩了才意识到线上没问题不等于有测试保护。这三个模式放在一起会发现它们有一个共同根源把修复单个 bug当成了目标而不是把保证系统行为符合契约当成目标。单个 bug 好修契约难保难就难在你看不见那些依赖契约的调用方。5. 事后重建的防御体系我现在的改代码习惯这次事故之后我在团队里推动做了几件事每件事都对应上面说的一个坑。5.1 把崩掉的十个场景固化成正常场景基线集事故发生第三天我做的第一件事就是把十个场景写成回归用例单独建了一个测试集名字就叫正常场景基线。这个测试集的断言不是不报错而是输出精确到分的业务规则断言分摊合计必须等于订单实付、退款行权重必须为 0、跨店汇总必须与订单维度一致等等。从那以后任何改动这个分摊模块的代码本地跑不完基线集不许提 MR。这个习惯后来救了我们至少三次——每次有人想顺手优化分摊逻辑都是基线集在第一时间拦住了行为变更。5.2 给修复加效果断言而不是只加分支现在我自己写修复代码时会在修复逻辑之后显式加上效果断言。拿这次的场景举例分摊函数必须保证分摊金额合计与订单实付金额的差值恒为 0。断言的目的是把业务规则固化在代码里而不是依赖人记得。对于生产系统我通常不会把断言放在热路径上影响性能而是放在测试代码和上线前的差分比对里。效果断言的价值在于它逼迫你思考这段代码改完业务上的不变量是什么而不是只盯着这个 bug 消失没有。5.3 改共享函数前先画一遍分支覆盖矩阵我现在改任何被多处调用的公共函数之前都会先列一张表每个输入特征对应走哪个分支、返回什么、影响哪些调用方。比如这次的分摊函数矩阵大概长这样输入特征走哪个分支返回结果依赖它的场景正常多行订单权重分摊主逻辑各行分摊金额普通订单、多券叠加行金额有 0 或负值权重计算需剔除 0 值行剩余行分摊金额部分退款、差价行、保证金行行有退款标记且金额为正正常参与分摊各行分摊金额退款撤销、换货重新入账画这张表的过程通常就会暴露这个输入我没想过或者这个分支会影响那儿的盲区。宁可多花半小时画表也不要在线上花两小时救火。5.4 发布策略先跑影子流量对比再放开灰度那次事故后我把看似局部的改动强制走影子流量对比同一份线上数据同时跑老代码和新代码对比输出差异。只要差异不为空就说明有场景行为被改变了必须逐条确认是有意变更还是无意误伤。这次事故里如果当时有这个环节十个场景的差异会在发布前直接列在眼前我绝不会把它放上线上。影子流量不需要额外造数据直接复用线上流量成本很低收益却极高。 之后我又给预发环境加了场景标签采样从基线集里按业务标签各抽一条典型流量灰度期专门盯这组样本。样本量小但代表性强比随机流量更能暴露跨场景影响。现在想来那次事故真正教会我的不是别在函数开头加提前返回而是三个更朴素的问题改的是现象还是原因这行改动影响的是单个输入还是整条契约线上正常不等于有保护。如果你也在维护一个被很多场景依赖的公共逻辑希望这篇复盘能帮你少踩一次同一个坑。
返回列表