三个核心测试,2到4天之内,可靠性从100%直接跳水到接近零。这在持续集成里,基本等于告诉所有人:别提交代码了。

大卫是 Buildkite 伸缩团队的工程经理。一个平平无奇的周二下午,他盯着构建失败通知,顺手截了一张测试仪表盘的图丢进 Slack 群组。图上最刺眼的,不是某次偶然的失败,而是一串刷屏级的不稳定测试——业内俗称“flaky tests”。

打开网易新闻 查看精彩图片

没人比持续集成平台更熟悉 flaky tests 了。代码库一膨胀,测试跑多了,总会有那么几条偶尔翻车,通常修一修或者重新触发一次就过去了。但这回不太一样。十五分钟内,团队就公开拉起了安灯绳:这些测试已经翻到了妨碍生产部署的程度。用大卫的原话说,“当务之急是让主干恢复可用”。于是他们做了唯一理性的选择——把闹事的测试从套件里暂时踢出去。

打开网易新闻 查看精彩图片

危机等级骤降之后,多数被阻塞的开发者都各回各家继续搬砖。唯独大卫多做了一个动作:他用 Buildkite 自带的 Test Engine 可靠性评分,追踪这几个测试到底是什么时候突然崩坏的。结果干净到刺眼:最不稳定的三个 flaky tests,大约在2至4天前,从近乎100%可靠,直接掉成“完全不可靠”。他在团队频道里写下:“我翻了这段时间合入的 PR,没找到明显的变化……唯一扎眼的就是一次 Redis 升级?”

那次 Redis gem 升级,是上一个周五下午做的,执行人叫帕特里克·罗宾逊——一位资深工程师,同时也是本文半个叙述者。帕特里克最喜欢干两件事:用第三人称谈论自己,和一头扎进足够鬼畜的底层 bug 里。他事后回忆,那次 gem 升级过程丝滑得像涂了黄油,线上没出现任何异常。但现实给他留了一颗地雷。

一看到 Redis 升级这条线索,调查团队立刻冒出了第二个假说:这些 flaky tests 可能并非代码逻辑出错,而是因为它们在特定的事件顺序下才暴露问题。要知道,这批不稳定的测试属于特性测试套件的一部分,环境里不光有 RSpec,还带了一个 Selenium 的无头浏览器,以及一套完整的测试环境——包含 Web/API 服务器、数据库和 Redis 服务器。这种混合架构里,不同组件之间的竞态条件是 flaky tests 的经典元凶。不幸的是,这个假说直接把调查者带进了沟里。

帕特里克沿着“竞态条件”的方向挖了一轮,发现表面上的时序冲突根本无法解释崩溃的底层特征。直到他把焦点转回到那个 Redis gem 升级上,一个更隐蔽的事实才开始浮现:问题不在测试本身的执行顺序,而在于数据在更早阶段就已经悄悄被写坏了,只是崩溃那一刻才露出牙齿。

这正是经典的内存损坏特征——数据被损坏了,但并不立刻引爆,等到某个后续操作再访问那块内存时,程序才轰然倒下。对调试者来说,这类 bug 令人发疯:常规工具抓的是崩溃时刻的系统状态,它能告诉你“这里有个野指针”,却不告诉你这个指针是谁在什么时候弄脏的。

打开网易新闻 查看精彩图片

帕特里克切入的方式,不是从崩溃栈帧往回追,而是从那个“数据应该还完好”的时间点开始比对。很快,证据链指向了 redis-client 库的一个 use-after-free 漏洞——对象被释放后,某个路径依然持有了它的引用并执行了操作,导致内存被静默改写。而这种改写正好只会在特定负载模式下出现,平时藏得滴水不漏。

也就是说,周五那个 Redis 升级,虽然表面上空转良好,实际却带着一个高危的客户端库变更。它没有立刻炸掉线上环境,却在几天后的特性测试里,通过连续的读写交互,把内存损坏一点一点推到了可见的边界。那些 flaky tests 并不是真 flaky——它们只是在不同时间点触到了那块已经被污染的内存,结果看起来时好时坏。

复盘整个排查链条,真正让线索从噪声里浮出来的,是 Buildkite 的 Test Engine 可靠性评分。它没有直接指向内存损坏,却给出了精确到天的稳定性陡降曲线,把注意力引向了唯一的变量:那次 Redis gem 升级。而如果没有这个时间锚点,团队很可能还会在“竞态条件”的假设里打转,白白浪费数天甚至数周。

这个案例也再次印证了一条古老的调试铁律:当心那些数据被破坏却不立刻坍塌的 bug。它们像代码库里的定时哑弹,不会在你犯错的那一刻引爆,只会在未来的某个下午,用一个毫无关联的测试失败,幽幽地告诉你:“嘿,你刚刚其实早就搞砸了。”