谁动了我的数据?一个 Bug 背后的“一行代码”真凶
引子:风平浪静下的暗流涌动
一个再普通不过的周五下午,午后的阳光透过玻璃洒落进来。键盘敲击声此起彼伏,一切都显得那么平静而有序。我正沉浸在代码的世界里,憧憬着周末的到来。然而,就在这时,Slack 上突然弹出一条同事消息:
“Hi,最近有动过 xxx 表的数据吗?自动化 case 挂了两个。”。
自动化测试是我们的“第一道防线”,一旦报警,往往意味着生产环境可能也存在隐患。更要命的是,下周的发布负责人,正是我自己。
但事已至此,先过周末吧。说不定欧洲同事会有回复呢?毕竟喜欢“自由”的他们可有不少直接跑脚本的“前科”。
第一章:神秘的“幽灵数据”与无果的追查
周一,Slack 上一片寂静;Daily run 的测试用例也依旧失败。
初步排查的结果让人有些困惑:失败的用例都指向了数据不一致。相关逻辑 6 个月以上的 Git 记录也意味着没有代码变更的痕迹。根据以往的“惨痛”经验,这种数据异常,十有八九是有人“手动”操作了数据库,而且通常是“神不知鬼不觉”。
我接着同事的消息后继续询问:“请问各位最近有没有人动过数据库?或者对数据做过什么操作?”
消息发出后,依旧毫无回应,仿佛掉进了一个无底洞。即便我“威胁”没有回复就将数据回滚,也无济于事。
这下可把我给难住了。数据明明被改了,却没人“认领”,难道是它自己长腿跑了不成?一定是哪个“深藏不露”的同事在暗中操作。
第二章:按下葫芦浮起瓢
周二,一天之后的 Slack 依旧寂静。这个“悬而未决”的幽灵随着发布临近,已经从一个普通的 bug 升级成了必须解决的 blocker 了。开发环境与生产环境的数据不一致,就像一颗定时炸弹,让即将上线的发布充满了未知的风险。
在“真凶”不明的情况下,为了保证发布能够顺利进行,我们只能采取最直接、也最无奈的办法——回滚数据。我硬着头皮接下了这个“脏活”:发布通知,编写脚本,回滚数据,然后通知 QA 重新测试。一套操作行云流水,心里却在打鼓,总觉得事情不会这么简单。
果然,怕什么来什么。
回滚操作刚完成不久,QA 那边就传来了“好消息”:之前失败的两条 case 终于通过了!我刚松了一口气,还没来得及喝口水,QA 又传来了新的消息。
“奇怪,之前一直正常的一条 case,现在挂了...”
我的心瞬间沉了下去。这真是“按下葫芦浮起瓢”,旧的问题解决了,新的问题又冒了出来。我们陷入了一个两难的境地:不回滚数据,影响发布;回滚了数据,又破坏了其他正常的业务逻辑,调查再一次陷入了僵局。
第三章:柳暗花明,真凶浮现
“按下葫芦浮起瓢”的窘境,让调查彻底陷入了僵局。既然回滚数据这条路走不通,问题本身又如此诡异,唯一的可能性,只剩下代码本身了。可相关的业务逻辑已经几个月没有动过了,这又是怎么回事呢?
我决定换个思路,不再局限于直接相关的业务代码,而是去翻查那段时间窗口内所有的代码提交记录。我打开了 git log
,像一个考古学家一样,从最近的提交开始,一行行地回溯。这是一个枯燥且耗费精力的过程,无数无关的修改从眼前划过,我几乎要放弃。
就在这时,一个看似毫不起眼的提交引起了我的注意。在一个辅助函数里,有一行代码的改动:
- let deviceBrand;
+ let deviceBrand = brand;
一行代码,仅仅是给一个变量赋了初始值。这看起来人畜无害,甚至可以说是更规范的写法。我的第一反应是:这不可能有问题。但多年的经验告诉我,越是这种不起眼的地方,越可能隐藏着“惊天大秘密”。
我立刻钻进了这个函数的上下文里,顺着 deviceBrand
这个变量往下追查。在函数的末尾,我发现了这样一段逻辑:
function mockFn(brand){
let deviceBrand = brand; // 问题就出在这里
if(cond1 && sn){
deviceBrand = someLogic(args) || brand;
data = override(`override.${deviceBrand}`, []);
return data;
}
// 在之前的代码里,如果上面的 if 不满足,deviceBrand 是 undefined
// 而 override 方法内部会忽略 undefined 的 key
// 但现在,deviceBrand 有了来自入参 brand 的初始值
// 导致这个 override 总会被执行,覆盖了原有的数据
data = override(`override.${deviceBrand}`, []);
return data;
}
真相瞬间大白!
在之前的代码中,如果 if
条件不满足,deviceBrand
的值是 undefined
。我们下游的 override
方法在设计时,会自动忽略掉 key
为 undefined
的操作,因此相安无事。
但是,在这次修改后,deviceBrand
从入参 brand
那里获得了一个初始值。这就导致了即使 if
条件不满足,deviceBrand
依然是个有效值,override
方法因此被执行,“错误”地覆盖了数据库中的原有数据!
自动化测试中那两条失败的 case,正是在这种场景下,数据被悄无声息地修改了。而我们回滚数据后,又导致了依赖这些被覆盖后数据的另一条 case 失败。
然而,当我找到提交这行代码的同事,并拿着“证据”去“对质”时,却得到了一个让我意外的答复:“这个改动是和 PO 确认过的,是一个预期的行为变更。”
第四章:尘埃落定后的反思
原来,这根本不是一个 Bug,而是一个有意的功能调整!只不过,这个调整的“副作用”超出了所有人的预料,并引发了这场“血案”。
既然是确认过的需求,那解决方案就不是回滚代码了。我们立即和 QA 团队沟通,解释了问题的来龙去脉。最终,QA 同事修改了他们的自动化测试用例,以适应新的业务逻辑。我们也再次执行脚本,将数据“恢复”到了被“覆盖”后的状态。一场风波,总算尘埃落定。
虽然问题解决了,但这次过山车般的经历,却让我不得不进行更深层次的复盘。表面上看,问题出在那“一行代码”,但背后暴露出的问题远不止于此。
首先,单元测试的缺失。 我惊讶地发现,这个如此关键的逻辑变更,竟然没有导致任何一个单元测试失败。这说明我们的测试用例覆盖率严重不足,特别是对于各种分支条件和边界情况的考虑远远不够。如果当时有一个健壮的单元测试,这个“副作用”在代码合入前就应该被发现。
其次,对变量初始值的警惕性不足。 在 JavaScript 这种动态语言中,一个变量是 undefined
还是有具体的值,可能会导致程序走向完全不同的分支。这次的教训足够深刻:任何时候都不要小看一个变量的初始状态。
最后,也是最重要的,是对复杂和“奇怪”场景的设计和评审不足。 在需求评审和技术设计阶段,我们往往会把注意力集中在主要流程上,而对于那些看似不会发生的边缘场景,常常一笔带过。但魔鬼恰恰就藏在这些细节里。如果当时能多问一句“如果这个条件不满足会怎么样?”,也许就能提前预见到风险。
一个小小的 Bug,就像多米诺骨牌一样,牵一发而动全身。它不仅考验了我们的技术能力,更考验了我们对工程化、对规范、对流程的敬畏之心。