SQLite 的可靠性经验

标题:Reliability Lessons From SQLite

日期:2026/08/03

作者:Richard Hipp

链接:https://www.youtube.com/watch?v=V_qzqY1bb7I

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。

备注一:谷歌的大数据真可怕啊,偶然搜索了 SQLite 作者的经历,没几天就推给我视频了。

备注二:作者聊到了 SQLite 的 VFS 机制,这也是我初次接触 SQLite 时用到的特性,非常好用。


SQLite 的起源与现状

谢谢你,Isaac。你太抬举我了。真的,说真的。我这里有很多幻灯片。大家可能需要跟紧一点,我也许会讲得很快。我也不知道。这是一场非常硬核的技术演讲,我喜欢做技术演讲,所以我们接下来要聊点纯技术的内容。

SQLite 的诞生,是因为 Informix 罢工了。故事回到 20 世纪 90 年代。我当时是个分包商,和最终客户之间隔着两家大公司。我的工作是写一些客户端程序,这些程序要和 Informix 数据库通信,从中提取数据,为一个 NP 完全问题找到绝佳的近似解,并以引人入胜的图形界面展示出来。那是个很有趣的问题,我也很享受那段工作时光。夹在中间的那两家大公司拿走了所有的利润,而活儿全是我干的。但过程中我也收获了很多乐趣。

但在这期间,Informix 是客户提供并指定使用的。我没得选,也控制不了它。有时 Informix 会宕机。后来查明这是一个配置问题,系统管理员没有正确配置之类的情况。但是,当 Informix 服务器宕机时,我的程序就得弹出一个对话框。在你们吐槽我这个对话框之前,我得说,那可是 90 年代最先进的对话框设计了。

那么除此之外你还能做些什么呢?我的意思是,我无法获取我需要的数据,我只能把这个对话框弹出来。但是最终用户看到这个之后,就会给我发 bug 报告。这让我非常沮丧。用 Andrew 的话来说,这让我感到痛苦。所以我想,我们真的需要那个服务器吗?为什么我们不能直接与磁盘上的数据对话,摆脱中间这个让我痛苦的服务器呢?我为什么需要它?于是我四处寻找,我找不到任何类似的东西。我想,好吧,我就自己写一个数据库引擎。这能有多难呢?

事实证明,这比你想象的要难。所以这基本上就是 SQLite 的起源故事。它的诞生是因为现有的软件无法正常工作。多年来它不断发展,我也花了一段时间才让它步入正轨。但以防你们没听说过它——我做这些幻灯片时,没意识到大家都知道它是什么。但以防你没听过:它是一个全功能的 SQL 实现。它具有电源故障安全性(Power-safe),支持 ACID 事务,是一个 C 语言库。它不是一个系统,不是那种会出错的外部服务。数据库就是磁盘上的一个单一文件。你只需要把这个文件移来移去就行了。你甚至可以把它通过邮件发给你的朋友,这非常酷。它有一个定义良好的文件格式。你可能知道,美国国会图书馆已经将 SQLite 数据库文件指定为长期归档二进制数据的首选存储介质。我以前自己都不知道这件事,直到社交媒体上有人指出来并引起我的注意。它是向后兼容的,并且属于公有领域(Public Domain)。我们免费发布一切。

我们有一个团队在维护它,这是一个小团队。目前包括我自己在内,只有三名代码提交者(committer)。所有的代码都在一个单独的 C 文件中。所以它非常容易集成到你的项目中。这是一个很大的文件,超过 25 万行。但通常我们实际上并不直接编辑那个大文件。我们有许多独立的源文件,我们的构建过程有点像是把这些 C 代码拼接在一起,生成那个大文件。它全都是手工编写的。好吧,我这么说,但我们确实编写了“用来写 C 代码的程序”。我们没有使用 AI 智能体(agents)来写代码,而是写了自己的程序来生成代码。所以有一部分代码是这样构建出来的。总之,这个项目的设计寿命是 50 年。第一行代码写于 2000 年 5 月 29 日。我所有的朋友都在退休,花时间陪孙子孙女。他们就问:“Richard,你打算什么时候退休?”嗯,2050 年。我还有 24 年要干。至少计划是这样的,到时候再看吧。

令我非常惊讶的是,它可能已经成为世界上被使用最广泛的软件了。正如 Isaac 在开场时指出的那样,他偷了我的演讲幻灯片。它现在就被用在你们所有的移动设备中,就在你们坐着的地方周围。在你们周围五六英尺的范围内,可能就有数百个 SQLite 实例在静静地运行,让你们的生活变得更好。这简直太惊人了。因为它是免费的,大多数人使用它甚至都不会告诉我,所以根本无从知晓具体数字。但保守估计,目前有远超一万亿个 SQLite 数据库在活跃使用中。据苹果和谷歌的员工负责任地告诉我,你们手机上大约一半的文件系统 I/O 都是 SQLite 产生的。剩下的大部分是媒体文件,那些在后台传满传去的大型媒体文件。当然,也有人写 XML 或使用其他数据库引擎,但绝大多数都是 SQLite。

为什么 SQLite 如此受欢迎?

那么,这一切是如何发生的呢?几年前,出现了这样一幅漫画。哦,他们是在谈论 SQLite。好吧,我不确定,我实际上从未去过内布拉斯加州(注:引用 XKCD 关于现代数字基础设施依赖无名项目的漫画)。我确实有人帮我,但这大概能让你体会到发生了什么。那么它为什么会被如此广泛地使用呢?SQLite 到底有什么特质让它变得如此受欢迎,以至于能像病毒一样传播?

首先,它很有用。它解决了一个问题。人们需要存储他们的数据。俗话说,没有数据,你要么是全凭运气,要么就是错的。所以你需要存储你的数据,每个人都想这么做。它很容易部署。它只是一份 C 代码文件。你只需把它扔进你的程序里,编译它,然后就可以跑了。它是开源的,处于公有领域。它就在那儿,你可以用它做任何你想做的事。人们确实用它做了一些非常违背常规的事情。但这正是伴随它而来的自由。而且你不需要支付任何费用,这促进了它的采用。它没有底层依赖。我是说,它只使用了标准的 C 库。它没有庞大的供应链需要操心。它占用的资源非常少。它能在你的 iWatch 上运行,能在小型嵌入式设备上运行。而且它通常不会崩溃。

所以,我认为它变得受欢迎的原因,是因为它解决的问题比它制造的问题多。总的来说,这也是一个很好的人生哲学。我的意思是,如果你想在你的职业生涯中取得成功,如果你想拥有一个好的职业,如果你想做成一番事业:解决的问题要比你制造的问题多。

另一种思考方式是,它就是能正常工作(it just works)。而且自从我来到这里,我发现,有一个庞大的社区非常热情地拥抱 SQLite。我保存了几年前的这条推文。我不知道他们当时在谈论什么。我不知道 Krishnikov 先生是谁。他写道:“我同意,除了 SQLite 之外,所有现代技术本质上都是垃圾。” 他对 SQLite 的评价可能过于正面了,但我非常感谢他的热情。非常感谢您,先生。总之,我花了一段时间才让它发展成现在这样。

遇到瓶颈与 DO-178B 航空级标准

事情是这样的,我们当时推出了 SQLite 第 3 版,那是在 2004 年。在此之前有第 1 版和第 2 版。你要知道,我从未接受过任何关于数据库技术的正规培训。而 Dan,他可能贡献了将近一半的代码,也完全没有任何计算机科学背景,他是一名机械工程师。所以在那几年里,我们不得不学习很多东西。但是当我们推出第 3 版时,它真正开始被那个时代的科技巨头所采用。还有人记得美国在线(America Online)吗?他们当时是一个大客户,他们给了我们一些钱去开发一些功能。当时世界上最大的手机制造商是摩托罗拉,诺基亚也参与了进来。

但在此之前,我一直认为,我可以写出几乎没有 bug 的代码。它跑起来就行了,永远不会有任何问题。但大约在那个时候,它开始像病毒一样传播,山景城(Mountain View)一家默默无闻的初创公司开始使用它。你可能听说过这家公司,他们叫 Android。他们几乎每天都会给我发一份 bug 报告。这太让人沮丧了。我开始意识到,好吧,也许我终究写不出完美的软件。那他们能对此做些什么呢?

当时我也在为罗克韦尔柯林斯(Rockwell Collins)做一些工作。那是一家航空电子设备制造商。他们向我介绍了一份名为 DO-178B 的文档。有人听说过这个吗?有哪些人熟悉这个?只有几个人。它现在已经过时了。显然现在有了 DO-178C,但我从没买过那一份。我喜欢这份文档的原因是,它非常简洁。它只有 84 页长。大多数的质量保证文档都像书架一样能塞满一堆东西。而这份文档非常简短。你可以上网买到它。它的价格是每页 6.50 美元。我没瞎编,他们对自己的 PDF 评价非常高。

但它为你提供了一种非常酷的思考软件的方式,从航空电子的角度来看。主要有三个核心要素供他们考察:

  1. 他们考察需求分析与追踪。我们在 SQLite 中有做这些,但我今天不打算谈这个。

  2. 他们谈到了配置与生命周期管理。关于这个,我有一张幻灯片。

  3. 本次演讲剩下的大部分内容,将是关于测试的。以及我们是如何进行测试的,测试如何让软件变得更可靠,我们所遇到的所有问题,这有多么困难,但为什么这才是你真正应该做的事情。

配置管理与 Fossil 版本控制系统

所以,我关于配置管理的唯一一张幻灯片是关于这点的:DO-178B 启发了我为 SQLite 编写属于我自己的版本控制系统。它叫 Fossil。人们都说我疯了才这么干。我应该把一切都放在 Git 里。但这和 Git 是同一时期的。Git 和 Fossil 是同时起步的。它们都衍生于另一个你可能从未听说过的版本控制系统,叫做 Monotone。有人听说过 Monotone 吗?有几个人听过。Monotone 最初提出了使用加密哈希来标识工件(artifacts)的想法。Git 用了这个方法,Fossil 也用了。这是同样的基本理念,只是实现方式不同。关于 Fossil 可以单独开一场完整的演讲了。如果你希望我在这次大会之后私下给你讲讲,请告诉我。

但从 DO-178B 中学到的最重要的一点是:如果它没有经过测试,它就不起作用。

100% MC/DC 测试覆盖率

因此,SQLite 的规矩是,我们要遵循 100% MC/DC(修正条件/判定覆盖,Modified Condition/Decision Coverage)测试覆盖率。MC/DC 这个词有点绕口,它有一个非常技术性的含义。如果你想了解其技术含义的细节,可以去维基百科上查阅。为了本次演讲以及在 SQLite 的实际应用中,我将其浓缩为两点:

  1. 机器码级别的每一个分支操作都在正反两个方向上被测试过。

  2. 位掩码(bit mask)测试中的每一个位都会对结果产生影响。

举个例子,如果你有一段代码写着——显然这是虚构的代码:

A == 5 && (B == 7 || C == 11)

你至少需要四个测试用例来验证它是否有效。你必须提供一个 A != 5 的情况。而且由于短路求值的特性,在这种情况下你不需要对 B 和 C 做任何事。但如果 A == 5,你必须测试 B == 7B != 7 && C == 11,以及 B != 7 && C != 11。你看明白这是怎么回事了吗?你必须验证你覆盖了所有这些情况。你必须有覆盖所有这些条件的测试用例。

而如果你在进行位掩码测试,比如 (X & 7) != 3,你需要确保在该测试中,(掩码涉及的)每一位在被置位(set)和被清零(clear)两种情况下都经过了单独的测试。这其中还有其他细节,但这就是你必须要做的核心理念。

那么我们实际上是如何在我们的产品中测量这一点的呢?事实证明,GCC 允许你以一种特殊的方式编译代码,然后你运行该程序,一个叫做 GCOV 的工具会生成一个巨大的文件。在 SQLite 的情况下,这个输出文件大约有 15.5 兆字节,长达 32 万行,显示了每个分支操作被执行的次数。然后你要开发一个脚本或其他工具来处理这个文件,它会向你展示每个分支被采纳了多少次。在给定的这个例子中,我们在那行特定的代码上拥有 100% 的 MC/DC 覆盖率,因为所有的分支都被执行过了。但脚本寻找的是执行次数为零的情况。

但是,你该怎么做位掩码测试呢?我们有一个叫做 testcase 的宏。当我们测试覆盖率时,testcase 实际上会在代码中插入另一个分支。我们必须给它一些可执行的指令。我在这里写了 noop(空操作),但它必须是某种能防止优化器将其优化掉的操作。你必须给它安排点事做。然后你在代码各处放上这些 testcase 宏,它们会验证,就位掩码而言,我们关心的那几个位在置位和清零两种情况下是否都已被测试到。

testcase( ... );

但请注意,在最终的交付版本中,testcase 什么都不是(它被宏展开为空)。它只在我们检查测试值是否正确时运行。你还可以使用 testcase 来检查边界条件。这对于确保你没有产生差一错误(off-by-one errors)也非常有用,它促使你写一个测试用例,覆盖不等式的两边。

测试最终的交付目标代码

但在这里需要认识到的是,DO-178B 对此进行了非常详细的说明:你实际上要在三个不同的层面上进行测试。你关注的是三种不同类型的测试:

  1. 验证你的测试用例本身: 你必须确保你的测试用例确实在测试你想要测试的东西。你必须确保你的测试用例是正确的,它们正在测试所有的需求,并且你具有完整的测试覆盖率。

  2. 测试源代码: 这有很多种方法。你想确保源代码得出正确的答案,此外你还有各种消毒剂(sanitizers)、静态分析器(static analyzers)之类的东西。

  3. 测试最终交付的目标代码(object code): 在 DO-178B 中,我们是不信任编译器的。仅仅因为源代码是正确的,并不意味着编译出来的代码就是正确的。

巧合的是,SQLite 在其发展历史中,曾在 GCC、Clang 和 MSVC 中都发现过 bug。你现在仍然可以去查看源代码,找到针对这些 bug 的临时解决方案(workarounds)。我是说,这些 bug 现在在最新版本的编译器中都已经修复了,但人们仍然在使用旧版本的编译器,所以我们保留了针对这些 bug 的变通代码。你可以在 SQLite 源代码中找到它们。

故障注入与极限场景测试

那么我们究竟如何测试产品呢?它是一个 C 库。所以通常情况下,一个应用程序会调用这个 C 库,然后这个 C 库再调用你的文件系统来获取结果。

但 SQLite 并不是一个单体怪物。我们有这些插件设计,有各种我们可以使用和控制的子组件。因此,当我们编写一个测试程序时,它就像另一个普通的应用程序一样,对吧?我是说,它只是与该库进行通信,因为“我们所测试的正是我们将要部署的内容”。测试程序能做的是,我们有一些特殊的接口可以深入进去,我们可以替换这些可插拔接口的各种变体,这就允许我们做一些有趣的事情。

例如,我们有一个叫作 VFS(虚拟文件系统)的模块,这是我们用来与操作系统通信的组件。我们可以插入替代的 VFS 来注入错误。我们用来做这件事的定制 VFS,它们对操作系统接口的调用是通过指针进行的。换句话说,我们不会直接调用操作系统的接口。我们有一个指向操作系统入口点的指针表。而且我们提供了一个公开的 API,允许我们修改这些指针。我会给你们看。这是真实的运行代码。我们有一张 API 指针表。

你如何让系统调用 open 以可预测的方式失败?这很难做到。但我们能做的是,我们可以通过一个知名的 API 在那里替换一个替代指针,插入一个被我们动过手脚的 open 函数,让它以一种可预测的方式抛出错误,以此来测试产品。然后我们可以验证产品是否正确地处理了该错误。

内存耗尽(OOM)测试是一件大事。 我知道你们很多人在 Linux 上编程,Linux 进程永远不会、malloc 永远不会失败。不,说真的,它不会的。因为如果你分配了太多的内存,它只会随机杀死你机器上的其他一些进程并抢夺它们的内存。对吧?我的意思是,现在事情就是这样的。甚至,如果我错了请纠正我,但我认为大多数情况下,如果你用 Rust 编程并且内存耗尽了,它只会把整个进程杀掉,对吧?但是 SQLite 会在你的手表之类的设备上运行,在这些设备上内存耗尽是件很糟糕的事,而且它们一开始就没多少内存。所以我们必须处理 malloc 失败的情况。当你耗尽内存时会发生什么?我们希望优雅地展开调用栈,返回一个错误,而且在这个过程中不泄漏内存。

所以我们这样做了,你可以替换一个替代的内存分配器。当然默认是直接使用系统的 malloc,对吧?但你可以在启动时替换一个替代方案,我们就是这么干的。我们精心设计了这一切,替换了一个被做了手脚的替代方案,它会故意让内存分配失败。我们在一个循环里做这件事。我们有一个测试模块和一些想要测试的代码,我们把这放入一个循环中。我们说:“在第一次分配时失败。” 好吧?确保你收到了错误信息。然后:“在第二次分配时失败。” 以此类推。你一直这样做,直到完整通过测试而没有发生任何失败。换句话说,你已经完成了所有的分配情况。通过这种方式,我们在内存耗尽的代码路径中发现了许多错误。

针对 I/O 测试也是同样的道理。我们可以插入替代的 I/O 机制,拦截对操作系统提供的真实 I/O 调用的请求,并注入错误。我们把它放在一个循环里,在测试的各个环节注入错误,以确保所有这些错误都被捕获并得到恰当处理。

那么,我不知道你们是否还记得我前面的一张幻灯片,SQLite 的需求之一是“电源故障安全性(Power-safe),ACID 事务”。“电源故障安全”这个词,是我自己造的。严格来说,任何 ACID 事务都理应是具备电源故障安全性的,但我强调这个词是为了突出这样一个事实:你可能正处于一个事务的中间,一部分已经写入了文件系统,一部分还在内存中等待写入,突然断电了,或者操作系统崩溃了,无论什么原因导致数据没有全部写入磁盘。SQLite 的设计规范是,即使重新供电后,要么整个事务完美地写入了磁盘并顺利完成,要么整个事务彻底回滚。这对于数据库来说是一个非常重要的属性。

你怎么测试这个呢?我知道在加利福尼亚州的库比蒂诺,苹果公司有装满了机器的房间或大楼,这些机器连接在定时开关上,每隔一段时间就自动切断电源然后重启,专门寻找此类错误。我是说,如果你有成百上千万的资金可以投入到这种“抽奖游戏”里,你可以这么做。我可没有那么多钱。那我们要怎么做呢?

思路是一样的。针对崩溃测试,我们可以插入一个替代的后端,一个“伪造”的文件系统,它记录所有的文件操作。它在每次系统调用后对这个文件系统进行快照,然后提取这些快照,模拟断电可能发生的方式来破坏文件系统。它会模拟乱序写入,即把写入操作打乱成随机顺序,然后丢弃其中一半或一部分。它乱序执行,把一切都搅乱。它一遍又一遍地重复这个过程,确保事务要么完全正确地存入磁盘,要么完全回滚。那是巨大的测试量。

为可测试性而设计

所有的这些都汇集到测试体系中。我想说的是,当你编写测试时,你不能说先有一个完整的产品,然后再为你已经完成的产品编写测试。产品从一开始设计时,就必须具备可测试性。

因此,当我为 SQLite 开发测试时,我不得不亲自去对 SQLite 本身进行彻底的修改。我不得不添加的接口之一是 sqlite3_test_control。这是一个公开的接口。在你们应用程序中使用的每一个 SQLite 版本里都能找到它。可能你们并没有使用它,你们也不应该使用它。事实上,针对这个接口的文档写着:“此接口仅供测试使用。您不应该使用它。”但它被包含在了构建版本中,因为否则我们就无法测试我们所部署的内容。

它可以做一些比如开启/关闭查询优化、调整查询参数之类的事情。我稍后会讲一讲故障模拟(fault sim)的事情,那非常酷。但比如说 SQLite 内部有一个伪随机数生成器。你可以利用这个接口,给它设定一个已知的种子值,这样你的测试就可以重现了。SQLite 内部有一些模块本身很难测试,它们有自己的内置单元测试,我们可以利用 test_control 来运行这些单元测试。还有很多很多其他功能。

接下来,SQLite 中有一个内部函数。设计规则是,以 sqlite3_ 开头的函数是公开 API,而那些以 sqlite3 开头但后面没有下划线的,是仅供内部使用的函数,且随时可能更改。这个函数,用于故障模拟,在生产环境中,它始终返回 false。在生产构建中它绝对不可能是 true。但是通过 test_control API,我们可以让它返回非零值。我们可以让它返回任何我们想要的值,并且它不需要每次都这样做。它可以在某些时候这样做,比如每 N 次调用才触发一次之类的。

这是其实际应用的一个例子。我的意思是,我们需要确保 SQLite 不会动辄生成并抛弃线程。事实上,除非你明确要求,否则它永远不会创建线程。但是,如果 pthread_create 不起作用,你该如何测试针对它的错误恢复机制呢?除了这种方法,你还能怎么测?所以在这里,我们设置了一个故障模拟,让它假装 pthread_create 无法创建线程,然后你要如何从中恢复呢?在最新的代码中有 24 处类似这种用法的实例,欢迎大家自己去查阅。

TCL 与 TH3 测试框架

所以,稍微介绍一点背景。你们中的一些人可能对 TCL 语言很熟悉。有人了解 TCL,或有过使用经验吗?那是一门非常棒的语言。John Ousterhout 大约在 1988 年编写了它。阅读他的代码真正教会了我如何用 C 语言编程。我是说,我以前也懂 C。你可以对比一下我阅读他的代码之前和之后写的代码,简直是天壤之别。那完全是另一个世界。我对此非常感激,实际上我曾有一段时间是 TCL 核心团队的成员。

总之,你可能不知道的是,SQLite 最初真的只是一个 TCL 扩展。抱歉,你知道的,它以前叫 Tcl,后来发生了一件事叫 Me Too,结果人们觉得 Tcl 听起来有点太冒险了,所以现在他们把它重塑成了 TCL。我旧习惯很难改。但无论如何,SQLite 是一个“逃逸到野生环境”的 TCL 扩展。它一开始真的只是给 TCL 用的,那曾经是它的全部目的。

因此很自然地,SQLite 中的所有测试都是用 TCL 完成的,至少最初几年的测试都是这样做的。而且它们现在仍然在那儿。如果你拉取源代码并输入 make test,它就会运行这些 TCL 测试。我想大概会有 1450 万个不同的测试被执行。当然实际上只有大概 20,000 个用例,但其中很多被放在了带有变量参数的循环中,所以完整跑下来结果达到了 1450 万次。我想大概需要 12 个 CPU 小时左右。它是源码树的一部分。完成所有这些测试的代码,体积是 SQLite 本身的 6.5 倍。

但这需要一个定制化的构建。换句话说,为了使用 TCL 测试,你必须在编译时给 SQLite 提供一些你在生产环境中通常不会使用的特殊选项。这实际上是一种源代码级别的测试。它不是目标代码测试,因为它测试的代码并非完全是你交付给用户的那些代码。

但是,受 DO-178B 的影响,我们希望准确测试最终交付的代码。于是我萌生了编写一个全新测试框架的想法,我们称之为 TH3。是的,曾经有过 TH1 和 TH2,但它们都失败了。我在 2008 年开始了这项工作,并在 2009 年实现了 100% 的 MC/DC 覆盖率。那是极其艰难的一年。漫长的一年,每天要工作 80 个小时,我甚至在想,我为什么要这样折磨自己?

但这确实非常有趣。一旦我们达到了 100% 的 MC/DC 覆盖率,来自 Android 和其他地方的那些 bug,基本上就停止出现了。它们真的消失了。我当时非常震惊。在这之前我感觉自己像是在悬崖边上,并不真的相信这会发生,但它非常非常有效。在那之后的几年里,SQLite 真的再也没有出现过 bug。我们真的做到了。没有 bug 的代码,至少当时我是这么认为的。

是的,那是一段快乐的日子。我们可以专注于让产品变得更好。但请理解,TH3 并不是一劳永逸的。你不能想:“哦,我现在拥有了 100% 的 MC/DC 测试了,我大功告成了,这件事彻底结束了。” 不是的,这需要持续不断地维护。前两年工作量巨大,但即便是直到今天,维护工作仍在持续进行。我们面临着大量的维护工作。每一次我们进行修改,每一次我们添加新功能,每一次发现新 bug,我们都必须同时更新 TH3。所以这里面存在着维护负担,但它带来了丰厚的回报。

注释、断言(Assert)与代码不变量

那么,100% 的 MC/DC 测试,并不是我们做的唯一一种测试。我想抛出一个大胆的论断:你的代码注释,也是你测试的一部分。

代码注释非常重要。我听到很多人说:“哦,如果你把代码写得很好,你就不需要写任何注释,因为代码是不言自明的。”我认为这是不对的。我坚信,你应该为所有的函数、过程和变量添加人类可读的自然语言注释。要简洁,不要用八股文形式。不要陷入那种特定格式的刻板注释陷阱。这是为了让尚未出生的人能够理解你写了什么。此外,这也是为了让我自己在写完代码一周后,还能理解我为什么这么做。

这也是因为,作为人类,我们的大脑在使用形式语言(写代码)和使用自然语言时,激活的是不同的神经通路。你们可能有过这样的经历:“嘿,Carl,跟我一起看看这个问题。我在这上面耗了几个小时了。X 显然不能是零,因为 Y 的……等等,等等,不对。哦,我懂了。没事了。谢谢你,Carl。”你们都经历过这个,不是吗?这是因为当你写代码时,你是在用形式语言思考,你使用的是不同的神经通路。反过来也是一样的。如果你用自然语言思考,觉得某个设计绝对能成,然后当你坐下来写代码时,你立刻就会意识到这根本行不通。这在两个方向上都适用。你需要双管齐下,动用你的全脑。SQLite 源代码中大约有三分之一是注释。这些注释绝非废话,它们的存在至关重要。

我们大量使用了断言(assert)。这是一件备受争议的事情。我从未想过 assert 也会如此具有争议性。事实上,我们因此还和 Go 语言社区吵了一大架。

显然,assert 的核心思想是它是某种不变量。如果一个 assert 失败了,那就是你的程序里有 bug,对吧?你可以把 assert 当作一种“可执行的注释”。当我在读代码时,我看到一段注释和一些代码,我不会理所当然地认为注释和代码是一致的。但如果我看到了一个 assert,而且我知道这东西能通过测试,那我就相信这个 assert。但是,Go 的开发者们,他们的想法是:“我们看到很多人用 assert 来验证输入参数的合法性,这是错误的。所以我们将禁止在 Go 语言中使用 assert。” 因为这个,我在我的网站上对 Go 进行了一些尖锐的批评,而 Go 的人对此感到非常生气。我们开了一次会,最后他们同意软化针对 assert 的言辞,而我也同意软化对 Go 的批评。皆大欢喜。

SQLite 极度依赖 assert。现在的代码库中有超过 7500 个断言。事实上,断言通常通过宏魔法变成了默认关闭,因为当断言开启时,代码运行速度会慢四倍。你必须编译开启调试模式才能启用断言。而且它不仅仅是简单的 assert 语句。我们还有专门用于检查不变量的完整子程序或过程,这些在生产构建中是会被剔除的。以便我们知道内部机制(比如页面缓存)没有那种甚至在测试里都暴露不出来的隐蔽问题。

我们还有类似断言的宏,名为 NEVERALWAYS,我们可以把它们套在测试条件上。

if( NEVER( ... ) ){ ... }
if( ALWAYS( ... ) ){ ... }

在生产代码中,它们就是空操作,直接穿透过去。但是,NEVER 标识了一个我们预计永远为假的分支或条件。而 ALWAYS 则相反。这是一种非常强大的错误检查方式。就像你想对数组做边界检查,你肯定会插入一些永远不应该被命中的分支。

它们的行为模式取决于你当下在做什么:如果你在测量测试覆盖率,ALWAYS 会被硬编码为 1,NEVER 被硬编码为 0;如果你在调试,当条件错误时它们会触发崩溃;但在生产环境,它们会直接被优化掉。这背后的理念是,也许你漏掉了什么 bug,可能最后就是这个 NEVER 挽救了你。

因此,我们有 testcaseALWAYSNEVERassert,我们到处都在用它们。它们的行为取决于你是在测试用例、测试源代码还是在生成生产代码。我认为这也与 Go 语言的“代码应该只以唯一一种方式被编译”的核心教条相冲突。但是,除此之外我不知道还有什么其他办法能做到 SQLite 中的这种测试水平。

变异测试与其他测试手段

好了,我们讨论了 MC/DC 测试。你还可以走得更远,进行变异测试(mutation testing)。你对源代码或目标代码进行一个微小的修改,然后验证你的测试是否会因此崩溃。这是一种非常强大的手段,它是在测试你的测试程序是否在非常严格地校验你的程序。

但它也有问题。比如实现哈希函数“不等于”操作符的分支指令,如果被篡改成了一个无条件跳转指令会怎样?那么这个哈希函数就永远返回 0。它依然能工作,只是没那么快了。那么你该如何编写一个测试用例来验证那个分支在做正确的事情呢?这是很难搞对的。通常,在 SQLite 的很多地方,我们会提供一个非常快的算法,但如果出现了假阴性,没有走快速捷径而是进入了通用慢路径,它依然会得到正确的答案,我们就无法真正察觉到代码已经被破坏了。所以,我至今依然无法可靠地让代码达到 100% 的变异测试覆盖率。

我们还做了一系列其他的测试。我们在不同的平台、不同的编译器、不同的架构上进行测试。注意区分大端和小端。在我刚开始做项目时,大端处理器还比较常见。到了今天,为了测试,我专门去 eBay 买了一台大约是 2004 年产的 MacBook,就是为了能在 PowerPC 上做大端测试。最后,我们禁用优化功能进行测试。

我们有数不清的测试,并用检查清单(checklists)来跟踪这一切,因为你不可能把所有事情都记在脑子里。所以,我们把所有时间都花在了测试上。几年前,我和一位刚毕业的年轻女程序员交谈,她很不好意思地说:“我只是一个写测试的。”我停顿了一秒钟,想:“你要知道,那差不多也是我唯一在做的工作。”

全面测试带来的意想不到的收益

而且说真的,你要知道,Anthropic 认为这就是每个人该干的事——你来写测试,然后让 Claude 去写代码。我不确定这是否正确。但在做所有这些测试的过程中,我收获了许多未曾预料到的好处。

  1. 小团队维护超级代码库: 我们能够仅凭三名提交者,就能维护这个被全世界所依赖的代码库。Postgres 有多少名代码提交者?一百个?两百个?任何其他有这种规模却只有三名提交者的项目吗?我们之所以能做到这一点,唯一的途径就是我们拥有这样的测试覆盖率。我们可以放手去重构剥离整个子系统,重新写一遍,然后在小版本更新里发布,并高度自信没有破坏任何东西。

  2. 极致性能提升: 它使代码运行得更快。不开玩笑。从 2009 年我们首次实现 100% MC/DC 覆盖率算起。因为有了测试网,我们可以做成千上万个极其微小的优化(micro-optimizations)。在这几年的时间里,我们将产品的性能提升到了原来的三倍多。

最近我在社交媒体上看到了一种谬论:认为源代码与测试代码是截然不同的。 有人利用 Postgres 的测试用例,用 Rust 彻底重写了 Postgres(由 AI 智能体完成的)。大家都在假设测试用例和代码是完全分离的。但至少在 SQLite 里,测试用例和代码之间有着显著的重叠(大约有 15% 到 20% 的重叠)。

你要知道,在新 CPU 上大约有 15% 到 20% 的晶体管仅仅是在生产制造阶段测试时才会被用到。等封装好之后它们就再也不会被使用了。所以,你的代码库里有 15% 到 20% 是专用于测试的,也许这正是你应该追求的目标。如果按 SQLite 的模式来扩展,测试代码其实比源代码要大得多。这么多人告诉我只需要写测试然后找个智能体来写代码就能极大提高生产力,我实在看不出这究竟能带来多大帮助。

模糊测试(Fuzzing)与 AI 发现的漏洞

好的,一切都顺风顺水地发展了一段时间,但随后在 2013 年左右我们遇到了 PGO 模糊测试(profile guided fuzzing,比如 AFL 和 libFuzzer)。

从 2009 年到 2013 年我们几乎没有 bug。但是这个时候 bug 开始爆发了。事实证明,经过 100% MC/DC 验证过的代码,在面对模糊测试工具时是非常脆弱的。因为当你做 MC/DC 测试时,你把很多不可达分支变成了 NEVERALWAYSassert。但 fuzzer 异常擅长寻找极具创意的方法强行让那些分支翻转。那几年它们给我们带来了无尽的折磨。最终,我们编写了专门针对 SQLite 的定制模糊测试工具,不仅测文件还同时测 SQL 语句。搞定这个之后,外部发现的 bug 就基本停止了,因为我们自己能先找出来。

几年之后,Manuel Rigger 提出了语义模糊测试(Semantic Fuzzing)的想法。他想到去测试 SQL 中的不一致性。比如,如果顶层的一个查询返回了一个元组,把它作为子查询时,你应该得到同样的元组,对吧?他用这种方法发现了很多隐藏极深的 bug,所以我不得不再次增强我们的模糊测试工具来处理语义模糊测试。

更近期的事态是,我们面临着一场由 AI 发现的 bug 的雪崩。它们发现了所有常规测试都无法找到的漏洞。

前几天就有个典型例子。SQLite 计算中位数(median)时,收集输入放入数组并进行快速排序。我自己写了一个快速排序,以避免标准库 qsort 的回调函数开销,速度快三倍。结果,AI 找到了一个具有病态特征的 100 万条输入记录的用例,直接导致该快速排序发生了 CPU 栈溢出,引发段错误。无论是 Zig、Rust、Go 还是其他语言都帮不了我,这单纯就是逻辑问题。

AI 擅长发现漏洞,但并不擅长修复。AI 建议的修复方案是给快速排序加一个 depth 参数限制递归深度。我仔细思考后意识到:如果在做快速排序时,你只对较小的那个分区进行递归(保证 $O(\log n)$ 的递归深度收敛),然后对于较大的那个分区使用尾递归优化(直接用循环处理),问题就解决了,而且变得更快。事后复盘发现,这其实在文献中早已是众所周知的,由 Robert Sedgwick(高德纳的学生)多年前发现。只是我以前不知道而已。

因此,我们同样会逐步解决由于 AI 带来的挑战。这就是我们一路走来的历程。

总结:构建可靠软件的经验教训

那么,我在这一切中学到了什么呢?这是总结。

  1. 为了实现可测试性,你必须把产品设计成是可测试的。 必须在脑海中带着测试的理念去开始。

  2. 100% 的 MC/DC 测试确实有效。 虽然需要大量工作,但它确实管用。

  3. 不要害怕让你的测试代码比产品代码大 10 倍。 不要害怕让源代码中的 10% 到 20% 仅用于测试目的。这完全没有问题。

  4. 清晰简洁的文档。 如果你在使用 AI,你的注释应该足以作为提示词(prompt),让 AI 能够重新生成这段代码。并预料到你要花绝大部分时间在提问和质疑上。

  5. 态势感知(Situational awareness)。 了解你的项目里到底在发生什么。你需要良好的版本控制系统(Git 还凑合,但还有更好的)。

我还要在此给出一个未经严格证明的论断:这些都是打造成功软件的必要条件,但它们并不是充分条件。要拥有像 SQLite 这样声名远扬且被极其广泛采用的软件,其实这期间发生了很多事情,是我完全无法控制的。它们就那样发生了,促使项目不断增长并帮助了那么多人。所以我十分确信地说,一个项目想要成为像 SQLite 这样,真的需要一种来自上天的眷顾(providence)和运气的成分。

这就是我关于 SQLite 的故事。我超时了吧?抱歉。谢谢大家!