
一、那位画家一步一步地走远, 离开了蘸漆的那个桶。这是一个发生在2001年的老故事, 到了2026年这个时间点, 这个故事再一次让人产生了强烈的感触, 仿佛它真的击中了一样。在二零〇一年, Joel 写了一篇很有名气的经典文章, 文章的标题是《回到起点》, 在这篇文章里面有一个寓言故事, 故事的名称叫作“油漆工的困境”, 这个寓言说的是这样一个情况: 有一位专门负责刷马路标线的油漆工人, 他的漆桶一直放在路的这一头没有移动过位置, 他每次往前去刷一段距离的时候就会让自己离装油漆的桶更远了一点, 于是他又得花费更多的时间来回走动才能蘸取到油漆, 直到他刷完了整条路的结尾部位, 会发现他自己一整天大部分的时间全都消耗在了走路上面, 从表面现象来看, 这位工人采取的每一个步骤似乎都没有什么错误, 但是从整体结果的角度出发分析, 他所增加的总工作量呈现出的是一个呈指数级疯狂增长的状况。过了二十四年的工夫, 这个寓言故事里的场景在一次真实的项目操作里完完全全地重演了一遍。最后砸中的目标对象是全世界范围内使用数量最多的数据库产品。这个数据库的特点就是它是免费的、属于公有领域的产品。而且它被内置在每一部手机以及每一个浏览器程序当中。它的安装总数已经达到了以十亿来计算这样一个非常巨大的规模。这件事来源于开发者本人的复盘内容。他利用 Rust 这种编程语言搭建了一款用于国际象棋分析的服务。在该服务的底层结构中, 采用了特定的存储方案来保存处理结果。每当一批任务开始运行, 系统会处理数百盘对弈棋局。随后, 程序会生成并返回数量大约为四万条的 JSON 格式的数据记录。这些记录最终需要被批量插入到数据库之中。按照该开发者的预先估计, 这个数据插入的执行过程应当是极其迅速的, 耗时可能只有几毫秒, 甚至可能达到亚毫秒级别。但是, 当第一批规模较大的数据涌入时, 实际情况出现了巨大的偏差。这四万条数据记录竟然耗费了完整的几分钟时间才插入完毕。与此同时, 服务器的一个中央处理器核心资源被完全占满, 负荷达到了满载状态。比较让人想不到的一点是, 同样是这段代码, 在 Mac 系统进行运行的时候, 一切都很正常, 可是, 一旦换到 Linux 系统上去运行, 速度就会变得非常慢, 慢得让人难以接受。代码是一行都没有进行改动, 数据库也没有得到升级, 数据的规模也并不是那个表面上看起来最直接导致问题的原因。那么到底是哪里出了问题呢? 问题实际上是出在一个让你完全意想不到的地方。具体来说, 就是在你参与编译的那个编译器身上。这个编译器偷偷地把本身标准库里面的数个C语言函数给替换掉了, 用上了另一套完全不同的实现方式。二、雷是这样埋下的, 具体做法是把四个C函数进行了重写工作, 并且让每次执行调用操作之前都要先把整条字符串的长度重新测量计算一遍。先把这条链路给彻底弄搞清楚, 因为项目本身的代码是用 Rust 写的, 但是里面有一些部分却是使用 C 语言编写的, 这些部分最终是通过引入 sqlx 这个库来实现连接的。作者是为了在一台电脑也就是Mac上面就能交叉编译出来Linux那个版本的东西, 然后用了Zig里面的C编译器这个叫zig cc的工具, 再加上cargo-这些东西来去构建, 就是构建那种在Linux下面能用到的组件, 这一步工作完全不需要去装什么容器环境, 也不需要再去配置专门的构建机器, 只需要敲入一条命令就可以把搞定跨平台这些问题了, 从工程角度来看这本应该是妥妥的能够直接获得的好处或者是优势。问题就藏在 zig cc 带来的那套标准库里。Zig 自带的 正在逐个把 musl 的 C 函数重写成纯 Zig 实现。重写本身思路没错但下面这四个函数重写后的行为多了一步每次被调用先从头到尾量一遍整条字符串的长度。函数原本干什么重写后多做了什么求开头连续匹配指定字符集的长度每次调用先测量整条字符串求开头连续不匹配的长度同上找第一个命中字符集的位置同上找子串首次出现的位置同上你量一次长度的时候, 那个时间复杂度是 O(n), 听着好像没什么大不了的, 但是你要命的地方在于这些函数的典型用法就是分词器。你在解析 JSON 数据的时候, 分词器的干活模式就是先把一段空白字符给跳过去, 然后记一下当前的位置, 接着再去处理一个叫令牌的东西, 处理完了之后再跳过一段空白处, 就这么一直循环下去反反复复的。而这里所说的去跳空白的这个操作在底层调用的恰恰正是那一族的函数。于是呢, 每个分词的这个步骤都会去触发一次行为, 那就是去重新量整条字符串的长度。文件越是长, 那么每一步去做测量长度这个事情的代价就越大, 而且步数还显得越来越多。这呈现出一种平方级的增长趋势, 就像那个画家走着越走越远的漆桶的剧情一样, 是丝毫不差的情形。这里必须把口径给弄明白一点, 作者实际测量出来的那个数值差不多是慢了一万两千倍, 这个东西具体指的是在一兆二百KB大小的JSON文件这个特定的处理环节当中进行对比以后所呈现出来的结果, 而并非是说从开始到结束整个插入过程的总耗时之间存在着一万两千倍的差异, 但是仅仅针对这一个单独的环节而言, 它已经具备足够的力量把包含四万条记录的数据插入操作从一个毫秒能够完成的速度直接拖拽到一个分钟才能完成的程度, 并且还有一个明显的趋势, 那就是随着文件体积变得越来越大, 这种性能恶化的情况也会变得更加迅速。使用一段可以运行的代码, 将该机制演示出来。这里涉及对模拟的“跳空白”分词器在两种不同行为下的字符访问量情况的展示。其中, 关于 blank空白密度这一项数据, 是按照已经排版好的 JSON 样式进行近似处理的。import randomrandom.seed(42)def count_accesses(n_chars):s .join(random.choice(abc \n ) for _ in range(n_chars))ws { , \n, \r, \t}n len(s)# 写法A:每跳一步都先重新量一遍整条字符串有多长(重写版行为)cost_a 0; i 0while i n:cost_a n - i # 量长度:走到结尾,O(n)while i n and s[i] in ws:i 1i 1 # 吃掉下一个 token# 写法B:长度只量一次(musl 原版行为)cost_b nreturn cost_a, cost_bfor size in [300_000, 600_000, 1_200_000]:a, b count_accesses(size)print(f文件 {size//1000:6} 万字符 | 每步重量长度 {a:14,} 次访问f | 只量一次 {b:9,} | 倍数 {a/b:10,.0f}x)实跑输出文件 30 万字符 | 每步重量长度 22,449,753,118 次访问 | 只量一次 300,000 | 倍数 74,833x文件 60 万字符 | 每步重量长度 90,003,839,279 次访问 | 只量一次 600,000 | 倍数 150,006x文件 120 万字符 | 每步重量长度 359,909,892,130 次访问 | 只量一次 1,200,000 | 倍数 299,925x看一下绝对数值: 当文件的数量每翻一番, “每一步都要去测量长度”这种写法的总访问次数就会上涨到原来的 4 倍——这看上去就是平方级的增长趋势。至于为什么在 Mac 平台上没有出现这个问题, 原因是 Mac 的构建过程完全不走 zig cc 这个环节, 使用的还是原生标准库, 所以这条有问题的代码路径根本没有被触发过。三、辩证重写没有错错在能跑和跑得一样快被划了等号先把那些应该被肯定的地方肯定下来。Zig cc 这个工具解决的是真实存在且十分明显的痛点: 因为传统的交叉编译过程非常繁琐, 开发者需要花费一整晚的时间去安装目标平台的工具链, 并反复进行配置与调试以排除链接错误, 而使用 Zig cc 则可以通过一条命令来完成所有工作, 诸如 Bun 和 Uber 这样的大型项目也都在采用它。去掉对 C 语言的依赖并用纯 Zig 的语言特性完全重写标准库这一做法, 在方向上也是符合逻辑的工程决策, 因为这样能够有效减少对外部现有项目的依赖, 而这种行为本身并不存在任何不可接受的问题。可是, 这一次发生的事故把大家都心照不宣却不敢明说的模糊地带给戳破了, 这个叫“行为正确”的验收标准和那个叫“复杂度不变”的验收标准完全是两回事儿。在musl库里面处理字符串长度计算这一件事, 所产生的开销都被分摊到了内部实现的各种细节里去。但是重写版本把它提升到了每一次调用函数都要面对的问题面前。难道是功能测试一个都过不了关吗? 并不是这样, 因为所有的单元测试都能顺利跑通, 它们得到的字符串结果跟原来一模一样。只有在特定的调用模式下, 性能才会出现崩溃的情况, 而单元测试几乎从未针对这一点进行过测试。还有一层情况是更加值得去认真思考的, 这不是某个人所犯的非常低级的失误问题, 而是一个属于教科书级别的模式的再次出现。在二十多年前他去写这个东西的时候, 是因为在那个年代他见过太多次这样的情况了而早在 基准测试 那么一个时代到来的时候就已经踩过这一类相同的坑了——字符串拼接的处理方式让这个程序在性能排行榜上成为了排在最后面的一位。每一个年代的工程师们都会重新去发明它一下, 因为从单独的视角来看, 每一个关于“要增加一定数量的时间长度”这样的决定都是看起来无比合理的。作者最后弄出来的那两条修复办法, 其实也特别能说明问题在哪地方。第一个办法是在业务那边, 预先就把 JSON 里面的那些空白字符给剪掉了这样子一来就能从根源上面减少分词的这个步骤。第二个办法更加简单粗暴, 就是干脆把 zig cc 给换掉, 在 Linux 系统下面搭建构建环境的时候, 就转而使用容器这种模式去搞。大家得注意啦, 官方这边实际上是没有出任何修复方案的, 原因是他们觉得这个东西本来就没有坏掉。至于那个重写的事情, 目前呢还处在正在进行的这个阶段当中, 这四个函数现在所使用的当前的具体实现代码, 暂时还没有得到来自官方层面的任何改动和更新。四、现实意义你的构建链里悄悄住着谁其实这个事情对大多数的普通开发者来说, 它的真正价值所在的地方, 并不是在于它有多么了不起的一个结果啊, 也不是去专门地研究一下 Zig 这个编程语言本身到底是怎么回事儿, 而是有三个很实在的行为准则可以给你直接拿过去照着做就行了。第一件事儿, 在跨平台进行了构建操作之后, 那个性能基线必须要在目标所在的平台上去重新进行测量。刚才提到的那种在 Mac 上运行起来一切都挺正常、挺好的情况, 在这次发生的这场事故里面来看的话, 就完全是一种错觉了。之所以会产生这种被掩盖了的问题的情况, 就是因为 Mac 这个平台并没有去走那条特定的编译路径。所以说, 只要是关于构建链路方面的东西发生了任何改动, 比如说是换了一样的编译器, 又或者是换了一个标准库, 再或者是更换了链接的方式这些种种情况都算在内, 那么在准备上线之前进行的基准测试这项工作, 就得像是处理一个新的项目那样地去认真对待和处理才行。第二, 如果你遇到CPU里面的核心被完全占满的情况, 却查不清是由哪些业务原因造成的时候, 需要把那些被偷偷更换掉的基础设施列入到可疑对象名单里面去检查。虽然日志信息里并没有任何报错的内容, 功能方面也是完全正确的, 问题的出现仅仅局限于某个特定的平台这种类似幽灵一样影响性能的案件的第一个怀疑人选, 通常并不是你自己的代码部分, 而是像编译、链接操作以及依赖关系解析这些领域中的内容环节, 这些部分是你平时根本不会去触碰或者关注的地方。在第三个方面, 为了给把大规模的 JSON 数据存入库表这个过程留下一些余地, 我们要采取提前剪掉空白字符这样一个做法, 当 JSON 被紧凑化处理之后, 它的体积会变得比较小, 那么需要执行的关于分词这个步骤的数量就变少了, 不管是底层的实现方式怎么改变都行, 这样一条路子走下来只会产生收益而不会亏损。2001 年发生的那个教训, 在 2026 年一模一样地又发生了, 下一个的二十四年恐怕也难免会重复同样的事情。每当工具链做出那种让你什么都看不见的“透明”升级的时候, 都值得你问一个问题, 那就是: 它在看似透明的表象之下, 具体替换掉了哪些内容?五、互动话题请问你的那个项目, 在实际情况当中, 是否曾经出现过, 明明代码的那一行内容, 完全没有进行任何改动, 只是更换了所使用的编译方式, 或者更换了所部署的运行平台, 然后发现性能表现就直接发生了严重的崩溃这种情况?如果是出现这样的情况的话, 最终是通过什么方法来对问题进行定位的。希望能够在评论区来进行交流, 分享一下讨论。你所遇到的这类坑, 可能正是别人目前正在经历的情况。