OpenAI 的工程师团队耗费数周时间,试图解开 Rockset 产品中那些令人费解的崩溃谜题。Rockset 是一款基于 C++ 的数据基础设施服务,主要支撑 ChatGPT 的搜索与数据插件功能。在排查过程中,工程师发现函数返回了错误的内存地址,栈指针在执行时似乎发生了 8 字节的偏移。团队提出的种种假设,几乎都被有力的反证一一推翻。这个 Bug 似乎根本就不可能存在。
起初,团队以为这是一个单一 Bug,结果却意外发现是两个互不相关的 Bug 巧合地在同一时间暴露无遗。这一突破性的发现并非源于对单个崩溃案例的深入剖析,而是得益于他们转向了“流行病学调试”的方法:构建一套自动化管道,分析过去一年生产环境中的所有核心转储文件,从中寻找整体规律,而非仅凭直觉推断个别案例。
团队让 ChatGPT 编写了一个脚本,用于提取核心转储文件开头的寄存器数据,过滤误报,并将崩溃标记为“返回空指针”、“栈对齐错误”等类型。随后,他们将此脚本并行应用于过去一年内的所有 Rockset 核心转储文件。很快,他们便发现了其中的关联性。那些看似症状相同的崩溃事件,实际上对应着两组特征截然不同的故障源。
所有由“栈对齐错误”引发的崩溃均源自同一个 Azure 区域,且都有明确的爆发日期,且从未出现在长期运行的节点上。团队追踪定位发现,这些崩溃的源头是一台物理主机,其 CPU 正在悄无声息地产生错误计算结果。该主机既未过热,也未触发机器检查异常,仅仅是数学运算出了问题。一旦将该主机从服务中移除,因栈对齐错误导致的崩溃便随之彻底消失。
在剔除了硬件故障导致的崩溃后,剩余的“返回空指针”问题便变得可控了。此前,团队曾试图排除 C++ 异常展开作为原因,因为他们认为自己找到了反例——即在不使用异常的代码路径中发生了崩溃。然而,他们很快意识到这些反例全部来自有硬件损坏的故障集群。排除这些干扰因素后,所有剩余的崩溃都确认为发生在异常展开过程中。
问题的根源在于 GNU libunwind 库中 `_Ux86_64_setcontext` 函数内的一个已存在 18 年的竞争条件。在 C++ 异常展开过程中,libunwind 会在栈上构建一个 `ucontext_t` 结构体以保存寄存器状态,随后调用 `_Ux86_64_setcontext` 将控制权转移给清理处理程序。问题在于,该函数在尚未完成从旧结构体读取指令指针的操作时,便先将栈指针更新为指向新的栈帧。一旦栈指针改变,该结构体便不再处于活动栈范围,也不再受内核红区保护。如果信号恰好在这个时间窗口内到达,内核会在该结构体之上构建信号帧,导致指令指针被破坏,从而使函数跳转到空指针或垃圾内存地址。
竞争窗口的宽度恰好是一条指令,按现代处理器的时钟频率计算,大约只有 100 皮秒。在大多数程序中,这种情况几乎不会触发。然而,OpenAI 的 Rockset 使用了 `timer_create` 函数,每隔几毫秒的 CPU 时间就发送一次 SIGUSR2 信号,旨在实现轻量级的按查询记账。这种高频的信号发送机制,将原本只在理论上存在的竞争状况转化为了实际生产环境中的崩溃。
团队已将修复方案及自包含的重现示例提交至 GNU libunwind,并经验证确认,其他展开器(如 libgcc)不存在此问题。该修复方案通过重新排序指令,确保在更新栈指针之前先读取指令指针,从而彻底消除了这个时间窗口。
该团队对这一教训的总结值得反复引用:最重要的步骤并非巧妙地解读汇编代码,也不是对细节的深入了解,而是构建一个高质量的数据集。如果没有这个数据集,团队就会将两种截然不同的现象混为一谈,并试图通过推理来理清这种混乱。一旦获得了准确且完整的全量数据,问题的结构便一目了然。如果你的团队正在排查难以解释的生产环境崩溃问题,请检查是否将多个 Bug 混淆在了一起。那些看似与所有假设都不相符的症状,实际上可能并不矛盾;它们可能与两个不同的假设相符,而你却无意中将它们混淆了。洞察问题结构的最快途径,并非对单个案例进行更深入的分析,而是获取涵盖所有故障案例的完整、带标签的数据。
这篇完整的工程技术博文包含了详细的栈内存示意图、存在漏洞的汇编指令,以及揭示出两种不同类型故障的崩溃率可视化图表。
