研究论文

OpenAI 用流行病学方法解剖 Rockset 的 18 年老 bug:不要做单 case 的医生,先建一个高质量的全人群数据集

把 core dump 当病人,先建全人群数据集再分群——OpenAI 调试 Rockset 崩溃的工程方法论。

2026年7月1日 · 周三 深度报告 高置信 重要度 4/5

本文要点

  • OpenAI 对 Rockset 的崩溃治理从'单个 core dump 单 case 推理'升级为'多维全人群分群聚类'
  • Glibc/libunwind 中 _Ux86_64_setcontext 的 18 年潜伏竞态从理论可能变为可复现的工程问题,带公开 reproducer
  • OpenAI Rockset 端从 GNU libunwind unwinder 切换为 libgcc unwinder,缓解方案已落地
  • OpenAI 已向上游 libunwind 仓库提交 reproducer 与 fix(Issue #927 状态:Closed)

OpenAI 把基础设施调试方法学命名了一次:Core Dump Epidemiology

OpenAI 在 6 月 30 日发布了一篇题为《Core dump epidemiology: fixing an 18-year-old bug》的工程博客,把一个听起来像运维狗血剧的 C++ 崩溃调试,写成了一份基础设施方法论文。症状是:C++ 函数正常返回时,却跳到非法地址;有时 RIP 直接为 NULL,有时 %rsp 偏移 8 字节。在 SRE 视角里这是”经典但无解”的疑难杂症——单看一份 core dump,你能列出内存破坏、栈溢出、JIT 错位、编译器优化、CPU 微码、内核态干扰整整一打可能。

OpenAI 这篇博客把基础设施调试方法学命名了一次——这比找到 18 年老 bug 本身更有行业意义。他们给这个调试范式起了一个干净的名字:Core dump epidemiology——把 core dump 当病人,但先把病人排进一张大表。

最终,他们一次性识别出两个完全独立、毫无关联的问题:

  • 一台 Azure 物理机 CPU 硬件故障导致 %rsp 错位,所有相关崩溃集中在同一区域、同一机型、有明确起点,新节点下架后消失;
  • GNU libunwind 一个潜伏 18 年的竞态条件:_Ux86_64_setcontext 在更新 %rsp 后、读取 RIP 前存在约 100 皮秒的窗口,若此时 SIGUSR2 到达,会破坏 ucontext_t 结构,使 RIP 变成 NULL。

关键数字已经摆在台面:100 皮秒(竞态窗口)、1/10^8(单次异常崩溃概率)、10^4 秒(单台高负载主机平均崩溃间隔)、10^4 次/秒(SIGUSR2 调用频率)、31440e9(引入 bug 的 commit hash,2008 年)、1 个 SKU/区域(受影响 Azure 物理机)。一个潜伏 18 年、被无数项目默默跑过的库级 bug,因为 OpenAI 自己一个看似无害的栈加深改动,被顶到了小时级可观察频率——而识别它的方法,不是盯那份崩了的 core dump,而是先建一张横跨时间×机型×区域×代码路径的全人群大表。

下面把这三件事讲清楚:第一,这篇博客把一种调试范式升格到了可命名的工程方法;第二,OpenAI 自己的一个设计决策把这个潜伏的尾部风险顶了出来;第三,这个 bug 之所以能潜伏 18 年,本身就是 C 生态基础设施层的一种常态——讲完后,我们再单独讨论一个容易被忽略的边界条件。

一种新的调试范式:把 core dump 当病人,但先把病人排进一张表里

传统 SRE 调崩溃的路径是这样的:收到一份 core dump,打开 gdb 看栈、寄存器、变量,提出 1-2 个假设,写 reproducer,跑,修或放弃。这本质上就是”医生看一个病人”的范式。在病人罕见、症状非典型、病因不止一个的时候,这个范式会非常吃亏——你可能花三天时间盯一份 core dump,结果那份 core dump 反映的是 CPU 故障;而真正的代码 bug 因为复现窗口太窄,你连一份可用的样本都没拿到。

OpenAI 的做法是反过来的:不先看单个病人,先把所有病人排进一张大表

具体到这次,Rockset 端在 OpenAI 可观测体系里沉淀下来的 core dump 集是一份带多维字段的样本:每条记录至少有时间(到秒)节点 ID所在区域机型(SKU)上线时间内核版本libunwind 版本CPU 微码版本信号类型RIP/%rsp 偏移栈深度。OpenAI 没有先去 gdb 任何一份 core dump,而是先把这张表做成透视表,按”时间×机型×区域""时间×信号类型×代码路径""机型×节点年龄”等多维度做交叉聚类。

聚类结果一上来就给了一个非常清晰的指纹:有一类 %rsp 错位的崩溃,100% 集中在单一 Azure 区域、单一机型、有明确上线起点——这是硬件故障的金标准聚类指纹。剩下那些没”三轴同时收紧”特征的崩溃样本,再去单独看个案——一看,撞上了_Ux86_64_setcontext 的竞态路径。

这就是为什么 OpenAI 敢于在博客里写”最初假设单一 bug,实际是两个独立问题”——这不是工程师拍脑袋反思,而是透视表自带的多群聚类直接给出了’不止一个子群’的统计证据。每个子群用自己的统计指纹立住之后,个案调试才有意义。

传统 SRE 调试方法的盲点正在这里:它默认”症状 = 单病因”,所以会用最快的方式去解释每一份新的 core dump。当一个症状同时承载多个互相独立的根因时,这种范式的解释力会被严重稀释——每份 core dump 都会被强行塞进那个当下的单一假设里,真正的多病因结构反而被遮住了

流行病学范式的核心贡献不是技巧,而是拒绝预设。它要求工程师在动手看任何一份 core dump 之前,先把整个崩溃数据集按多维度切片,从切片的统计分布出发,倒推出”有几群、各自的指纹是什么”。这不是新理论——约翰·霍普金斯公共卫生经典教科书从 19 世纪就在用类似的”先建人群队列,再做暴露分层”的方法,而 OpenAI 现在把这一套搬到了基础设施的样本上。

这条范式为什么在 2008 年对 C++ 异常 unwinder 的 bug 不起作用,在 2026 年对 OpenAI 起了作用?核心差别不是 bug 变了,而是数据集的规模和维度变了。OpenAI 能同时拿到”机型分布""节点年龄""代码路径""信号频率""库版本""节点下架时间”——这在传统单数据中心、单 SKU 的运维里凑不出来。LLM 时代基础设施在云上的规模化和异构化,反过来把”流行病学”逼成了标配。

可以把这件事讲得更形象一点:过去 SRE 像在自家后院挖井,水浑了只能尝一口猜问题;现在 OpenAI 像在做一个跨城市的水网监测,任何一次水质异常都能在几百个站点里横向比对,识别出是局部管网污染、还是某个水厂工艺缺陷。前者是看一例猜一因,后者是先看分布、再回个案——后者并不更聪明,但更不容易被样本骗。

一个设计决策如何把一个 18 年的尾部风险顶了出来

_Ux86_64_setcontext 是 GNU libunwind 在 x86_64 上的一个内部函数,负责在 C++ 异常展开时把寄存器(包括 %rsp 和 RIP)恢复回调用方。这个函数从 2008 年起就有一个微妙的实现细节:它在更新 %rsp 之后,会从栈上的 ucontext_t 结构里把 RIP 读回寄存器——这两个动作之间,大约有 1 条 x86 指令的窗口,博客给的口径是约 100 皮秒

大多数程序一辈子都不会碰到这个窗口。但 OpenAI 的 Rockset 后端偏偏把几条路径同时叠到了这条窄缝上:

  • SIGUSR2 在 Rockset 里被用作CPU 计时机制(类似 profiler 的心跳);
  • Rockset 把异常作为 ingest 背压机制,每秒可抛 10^4 次;
  • 每一次异常都会走到 _Ux86_64_setcontext,都会在那个 100 皮秒的窗口上”裸奔”一次。

折算下来,单台高负载主机平均 10^4 秒(几小时)就崩一次。这不是”理论上的可能”,而是工程上必崩——只是崩在哪台机器、什么时间不确定。

但把这两条机制叠起来还不够。OpenAI 在博客里给了一句非常诚实的自陈:2026 年早些时候,他们主动在 SIGUSR2 处理器里加了一次 timer_getoverrun 调用——这会让栈使用稍微变深,刚好把本来可能要十年才撞一次的窗口放大到了小时级。

这是一段相当罕见的工程坦白。大多数公司遇到这种”我们自己改动把这个 bug 顶出来”的事,要么归罪老代码、要么掩盖掉。OpenAI 把它公开了——而这一点比”识别出 bug”本身更值得我们讨论。

它提示的是一个被低估的设计原则:当一个系统对”罕见事件”的实际频率被设计性地放大时,所有底层库(包括运行时、unwinder、内存分配器)的尾部风险都会被同步放大。回到数字本身:原来的 tail risk 是 10^-8(issue #927 在 100 Hz 定时器下测得),SIGUSR2 调用频率是 10^4 次/秒——这两条相乘,小时级就崩一次。这个乘法对 OpenAI 是坏事,但对所有依赖 libunwind 的 C++ 后端都是一个公开的提醒:任何”用罕见 + 高频”的乘法式放大,都是对底层库的隐性压力测试

这个原则在架构评审里几乎从不出现。我们评审 Redis 配置、调 GC 参数、设计 Rate Limit,但很少评审”哪些库的尾部风险被我们当前的信号频率/调用方式放大了多少倍”。OpenAI 这篇博客最实用的副产品,就是把这件事变成可讨论的工程条目——下次你在评审里听到”我们用 SIGUSR2 做心跳”,值得追问一句:它和你依赖的 unwinder/分配器的尾部风险是否做过乘法。

回头看 ,缓解这件事本身是简单的——OpenAI 把 Rockset 的异常路径从 GNU libunwind 切到了 libgcc unwinder(后者走的是 SJLJ 风格,不会进入 _Ux86_64_setcontext 这条路径)。一个 unwinder 切换就把小时级可见的 bug 治了。但让 OpenAI 能找到这条解题路径的,是上一节的全人群表——没有那张表,即使解法存在也可能不会意识到它必要。

一个 bug 为什么能潜伏 18 年——C 生态基础设施层的一种常态

回头看时间线:这个 bug 的引入点是 2008 年的 commit 31440e9,距今 18 年。期间 libunwind 被无数高负载 C++ 服务使用过——大型搜索引擎、广告系统、游戏引擎、量化交易、HPC、数据库内核——理论上,任何在生产里大量使用自定义同步信号、又恰好调用了 _Ux86_64_setcontext 路径的服务,都有撞到这条竞态的可能。

但 18 年里没人系统性地报告过。

这件事有两种读法。一种认为,因为组合条件稀少——既要 SIGUSR2 类的同步信号到达竞态窗口,又要高频率触发——所以大多数 C++ 服务躲过去了;libunwind 维护者守住这块地的概率很低,所以撞不到很正常。另一种认为,很多团队其实撞到了,但因为缺乏”全人群 + 多维聚类”的方法论,撞到的样本被默默吞掉,归到”内存坏了""这机器有毛病""换台机器就好了”这类口袋解释里

哪一种更接近真相,我们没有公开数据可以下结论。但这件事本身已经是一个工程层面的论点:没有横向可比的数据集,你无法证明”我们栈里没 bug”。“我们线上没崩过”这种声明,在缺乏跨时间×跨机型×跨代码路径的数据集支撑下,是不严谨的——它既不能排除尾部风险,也不能告诉你当前的尾部风险有多大。

这件事对 libunwind 维护者略尴尬,但放到 C 生态基础设施层的常态里看就不意外了。我们今天仍在用的 glibc、musl、binutils、OpenSSL、systemd、Linux 内核……每一个都是几十年的项目、每个 commit 都可能引入类似的潜在窗口。大多数 commit 是好的,但每隔几年总会有一两个溜进尾部风险里。要命的是,这种尾部风险只有在大规模、高频、跨机型×跨区域的负载下才会显形——而全世界能做到这种负载的工程团队一只手数得过来。

换句话说,C 生态基础设施层的安全网,实际上就是几家头部互联网公司在用自己生产环境的崩溃数据反向兜底。这一次 OpenAI 把这件事摆在了博客里、给一个潜伏 18 年的 bug 报了 reproducer 和 fix,某种意义上是在主动承担这个生态角色。这不是慈善——是头部公司维护自身生态可依赖性的必然产物。

这件事对其他大型 C++ 后端团队也有具体的可迁移启示:如果你的服务在线上大量使用 SIGUSR1/2 类自定义同步信号,值得 grep 一下是否走到了 libunwind 的 setcontext 路径——走到了,要么换 unwinder(OpenAI 这次的做法),要么换信号实现。可以几乎肯定地说,凡是没在内部做过类似交叉对照的团队,都有未结算的尾部风险。

再往上拉一层:这种”无症状 18 年 + 一次显形”的 bug,任何用 GNU libunwind 做 unwinder 的大型 C++ 系统都存在这种潜在风险——不管是你的搜索后端、广告投放、数据管道、量化系统、还是游戏服务器,你都没有证据证明自己避开了,只能说暂时没观测到。这是一个需要主动去做的事,而不是可以默认安全的事。

关键数据与对比

维度数值/现象来源
_Ux86_64_setcontext 竞态窗口100 皮秒(1 条指令宽)OpenAI 博客 + GitHub Issue #927
单次 C++ 异常的崩溃概率1/10^8(100 Hz 定时器下)GitHub Issue #927
引入该 bug 的 commit31440e9(2008 年)libunwind 仓库
OpenAI SIGUSR2 处理器异常频率10^4 次/秒OpenAI 博客
单台高负载主机平均崩溃间隔10^4 秒(几小时)OpenAI 博客
触发该 bug 真正放大的改动SIGUSR2 处理器中加入 timer_getoverrun,扩大栈使用OpenAI 博客
缓解方案切换到 libgcc unwinder + 向上游提交 reproducer 和 fixOpenAI 博客 + Issue #927 状态
受影响 Azure 物理机单一区域、单一机型、新节点下架后消失OpenAI 博客
技术细节折叠区:_Ux86_64_setcontext 竞态机制(展开)

机制简述:_Ux86_64_setcontext 在恢复被中断的上下文时,先 mov %rsp, ... 把目标栈指针装到 %rsp,然后从栈上的 ucontext_t 里读取 RIP 写回 %rip。这两步之间,函数本身正在使用一段临时栈帧(ucontext_t 就放在那里),而这段栈帧因为 %rsp 已经被更新,已经不在”活动栈”范围内——它落在了”红区”(red zone)或上一帧的旧栈位置上。

如果此刻 SIGUSR2 抵达,信号处理函数会跑在新栈上,栈指针会推进到新的安全位置。但因为 ucontext_t 仍然停留在旧地址(它现在的物理内存可能被新栈覆盖了),当 _Ux86_64_setcontext 回头去读 RIP 字段时,可能读到 0(NULL)或一个看似合法但错误的值。timer_getoverrun 之所以放大这个窗口,是因为它会让 SIGUSR2 处理器内部多用几十字节栈,刚好让 ucontext_t 落到更容易被覆盖的位置。

为什么以前的工具很难定位:_Ux86_64_setcontext 没有对应的源代码标注”此处进入竞态窗口”,栈展开器不识别这是 setcontext 中途,race detector 也不跟踪跨信号处理的内存访问——也就是说,这个 bug 在所有常规调试工具的盲区里。timer_getoverrun 只是让窗口刚好够大,能在生产里崩出可见样本,而不是它在理论上造出了这个窗口。

这次事件对工程团队的迁移含义

对 OpenAI 自家:Rockset 后端已从 GNU libunwind 切到 libgcc unwinder,下游查询 API 行为不变,只是异常路径上的内部栈展开更稳。这套缓解在已部署的 Rockset 集群上对外透明。

对收购前的 Rockset 老用户:他们大多数早就用 libgcc unwinder(尤其在 Linux 发行版默认配置里),所以受这个 bug 直接影响的概率很低。但 OpenAI 这次公开的诊断范式值得所有大型 C++ 后端团队借鉴——把”core dump 流行病学”当作 SRE 工具箱里的标配动作。

对整个 C++ 生态:这是过去 5 年里少数几个**“潜伏 18 年、被一家公司撞到、有公开 reproducer”** 的库级 bug。它再一次提醒:任何高频信号 + 库级 unwinder 的组合,都是潜在雷区。如果你的服务在生产里大量用 SIGUSR1/2 之类自定义同步信号,值得 grep 一下是否走到了 libunwind 的 setcontext 路径——如果走了,要么换 unwinder,要么换信号。

把视野拉远一点,这次事件和过去一年里几个事件是同一条暗线:2024 年 Cloudflare 大规模 Terraform 状态漂移,同一种”全人群数据集 + 多维分群”思路;2025 年某大型 LLM 推理栈的偶发 NaN 传播,也是横跨”硬件 + 编译器 + 运行时”三层的复合问题;2026 年 OpenAI 推理集群的多机卡死事件(行业传闻),从有限的官方信息看也是”按机型/区域/时间聚类”才有解的。

LLM 时代基础设施栈比 2018 年厚了一个数量级——GPU + 高速网络 + 定制 runtime + 量化 kernel + 各种库——任何一层都可能埋雷。靠”看一个 core dump 猜一因”的范式,已经被撞得越来越频繁地失败。OpenAI 把这件事写成博客、起了个名字,某种程度上是在对外做工程标准的输出:你们也该这样干。

早报观点

方法论层面:OpenAI 这篇博客最值得读的不是那两个 bug 本身,而是把调试范式从”医生看病”升格到了”流行病学”。18 年老洞 + 单一机型的硬件故障,任何一个资深 SRE 都可能独立撞到;但 OpenAI 之所以能在合理时间内同时识别出两因,是因为它从一开始就没看个案,先建了一张横跨时间×机型×区域×代码路径的全人群大表,然后从多维聚类里直接读出”两个独立子群”的统计证据。这种范式在 LLM 时代——基础设施栈越来越厚、节点规模越来越大、故障耦合越来越深——会从可选项变成必备项。如果你的团队还在靠”资深工程师盯一份 core dump 半小时”调崩溃,大概率已经在用 2010 年代的方法解决 2020 年代的问题。

设计决策层面:OpenAI 自己用 SIGUSR2 做 CPU 计时 + 异常做 ingest 背压,导致每秒 10^4 次的同步信号流——这把一个本来概率 10^-8 的库级 bug 强行放大到小时级可见。这不是 OpenAI 的”错”,因为在那个量级的请求下,异常作为背压信号确实简洁高效。但它提示一个普遍原则:任何”罕见事件 + 高频调用”的乘法式放大,都是对底层库(运行时、unwinder、内存分配器)隐性做尾部压力测试。这套隐性测试在架构评审里几乎从不出现;下次你评审一个”我们用 SIGUSR2 做心跳”的设计,值得追问一句:它和你依赖的 unwinder/分配器的尾部风险做过乘法没有。这才是 OpenAI 这次最实用的副产品——不是找到了一个 bug,而是把”用罕见×高频能放大什么”这件事抬到了架构评审桌上。

历史纵深层面:“潜伏 18 年才被撞到”既是 libunwind 维护者的小尴尬,也是 C 生态基础设施层的一种常态。这个 bug 在 2008 年 commit 31440e9 引入后,理论上有大量高负载 C++ 服务都可能踩到——但绝大多数要么没碰到同步信号 + 高频异常的组合,要么像 OpenAI 这次一样”从未在监控上看到”。这告诉我们两件事:(a) 尾部风险排查在大型 C++ 后端里是真实的、长期的 ROI,因为能造出这种负载的团队一手数得过来,但使用同类库的团队却成千上万;(b) 任何关于”我们栈里没 bug”的论断,在没有跨时间×跨机型×跨代码路径的数据集支撑下,都是不严谨的——它既不能排除尾部风险,也不能告诉你当前的尾部风险有多大。这件事放到整个 C 生态看其实是角色分工的必然产物:头部公司用自己的生产负载反向兜底,这次 OpenAI 主动把 18 年老洞的 reproducer 和 fix 公开提给上游,是这个角色的一个具体表现。

caveat(写在最后,不是观点的第四点):这次能成功,部分因为 OpenAI 把崩溃频率主动放大到了小时级。换句话说,core dump 流行病学是有前提条件的——它需要你能造出一个足够大、足够干净的全人群数据集。如果一个 bug 一年只崩一次,你可能根本凑不出分群所需的样本量;对小团队、单数据中心、单一机型的服务,这不一定可复制。这是这种方法的边界:它处理”频率被设计性放大、症状多发”的复合故障很有效;对”真的罕见到一年一次”的极端尾部,它的样本量本身就是瓶颈——这种情况下最终还是要回到单 case 深度调试 + 历史先例。所以 core dump 流行病学不是替代医生看病,而是在医生看不准的时候,把人话翻译成统计语言。

给读者的三句话总结,而不是清单:

  • 如果你的 C++ 服务在线上大量使用自定义同步信号(SIGUSR1/2 等),值得 grep 一下是否走进了 libunwind 的 setcontext 路径——走到了,要么换 unwinder,要么换信号。
  • 如果你的 SRE 团队还没有”全人群 core dump 数据集”的标准实践,这件事比写更多告警规则更优先——告警规则救的是已经发生的样本,而数据集救的是还没发生的多病因结构。
  • 如果你负责的是一个跨机型/跨区域的分布式系统,值得在下一个季度的可靠性预算里拨一笔”建立分群基础设施”的工时,OpenAI 这篇博客就是 ROI 论据。

接下来看什么

  • libunwind 主干 commit history:OpenAI 提交的 fix 是否已合入 HEAD?合入的版本号、发布日期、是否进入主流 Linux 发行版的包更新——这决定了下游用户的真正受益时间。
  • OpenAI 收购前的 Rockset 老用户:他们有多少在用 GNU libunwind unwinder?这次修复对他们是透明的还是需要主动切换?OpenAI 会不会给出官方迁移指南?
  • Azure 物理机 SKU 披露:OpenAI 会不会在后续文章中公开导致硬件故障的具体 CPU 家族/微码版本?这种披露对整个云行业是稀缺信号。
  • OpenAI 工具化:这套”core dump 流行病学”流水线会不会以工具/平台形式对外开源?如果会,大概率会和 OpenAI 收购的 Rockset 数据栈一起出现在未来的产品里。
  • 方法学复用:OpenAI 后续的工程博客是否延续”流行病学”框架?这条方法论能不能复用到 LLM 推理集群的偶发卡死、向量库的内存破坏、分布式 trace 的丢包等场景?

相关来源