你从来没有安装过它,但你身上大概带着几十份它。
手机里的短信、通话记录、相册索引、每一个应用的本地缓存;浏览器的历史记录、Cookie、书签;桌面系统的字体缓存与照片库;汽车的导航数据、机上娱乐系统的片单、医疗设备的日志。这些东西背后往往是同一段 C 代码。SQLite 官方的估计是:全世界处于活跃使用状态的 SQLite 数据库超过一万亿个。
它由 D. Richard Hipp 在 2000 年春天写成。当时他在 General Dynamics 承接美国海军的合同,做导弹驱逐舰上的损管系统——一个不能假设现场有数据库管理员、也不能假设有服务器进程能一直活着的环境。这个出身决定了它后来的每一个设计选择。
这篇文章要讲的是:这一个文件里到底装了什么,事务是怎么在没有服务器的情况下做到原子的,"不要服务器进程"这个决定放弃了什么,以及为什么这个项目最值得学的部分不是它的代码,而是它的测试。
破除误解
误解一:SQLite 是"轻量版数据库",做原型可以,正经场景要换成真数据库。
它不是任何东西的简化版。SQLite 的 ACID 保证是完整的:默认隔离级别是可串行化(serializable),崩溃后的原子性由回滚日志或 WAL 保证,PRAGMA integrity_check 可以逐页校验 B 树的结构不变量。在断电一致性这个维度上,它被验证的严格程度高于绝大多数"正经"数据库——原因见后文的测试一节。官方自己的定位是:SQLite 不与 MySQL、Oracle、PostgreSQL 竞争,它与 fopen() 竞争。它要替代的是你手写的那个二进制配置文件、那个用 JSON 存了三万条记录的目录,而不是你的订单库。
误解二:SQLite 不支持并发。
要分清两件事。从 3.7.0(2010-07-21)引入 WAL 模式起,多个读者与一个写者可以完全并行,读不阻塞写,写不阻塞读。SQLite 真正不支持的是多个并发写者:整个数据库文件在任一时刻只允许一个写事务。所以"一个后台任务在写、一百个请求在读"完全没问题;"一百个请求同时写"就会撞上 SQLITE_BUSY。把这两句话混成一句,就会既高估它也低估它。
误解三:因为没有服务器进程,所以它慢、或者不安全。
恰恰相反,没有进程边界正是它快的原因。一次 SELECT 在 SQLite 里是一次普通的函数调用:解析、执行、拿到结果,全程在你自己的进程内,最坏情况是几次 read() 系统调用,常见情况是操作系统页缓存直接命中。客户端-服务器数据库的同一次查询要经过参数序列化、套接字往返、服务端解析、结果集序列化、客户端反序列化。SQLite 官方有一篇测量报告,标题就叫《比文件系统快 35%》——讲的是读取 10 KB 量级的小对象时,从 SQLite 里读比从各自独立的文件里读还要快,因为省掉了每个文件的 open/close 与目录项查找。
至于"不安全",真正的问题不在这里,而在于它没有用户与权限系统——这个后面会展开。
一个文件里的全部:页、B 树与 freelist
打开任何一个 SQLite 数据库文件,前 16 个字节永远是 ASCII 字符串 SQLite format 3\0。这个格式从 SQLite 3.0.0(2004-06-18)定型至今没有做过破坏性变更,开发者的公开承诺是支持到 2050 年,美国国会图书馆在 2018-05-29 把它列为数据集的推荐存储格式(另外三个是 XML、JSON、CSV)。
页:唯一的分配单位
文件被切成等长的页(page),页号从 1 开始。页大小必须是 2 的幂,范围 512 到 65536 字节;默认值在 3.12.0(2016-03-29)从 1024 改成了 4096,理由很朴素:2003 年设计格式时 1024 合理,现代硬件上 4096 更快。
前 100 字节是文件头,里面几个字段值得记住:
| 偏移 | 长度 | 含义 |
|---|---|---|
| 0 | 16 | 魔数 SQLite format 3\0 |
| 16 | 2 | 页大小 |
| 18 | 1 | 写格式版本:1 = 回滚日志,2 = WAL |
| 28 | 4 | 数据库总页数 |
| 32 | 4 | freelist 第一个 trunk 页的页号 |
| 36 | 4 | freelist 总页数 |
注意偏移 18 那个字节:一个库是不是 WAL 模式,是写在文件头里的持久属性,不是连接级的临时设置。这解释了一个常见困惑——为什么执行一次 PRAGMA journal_mode=WAL 之后,所有新连接都自动是 WAL 模式了。
两种 B 树,不是一种
页 1 除去开头 100 字节,剩下的部分是 sqlite_schema 表的根页——数据库自己的目录也是一张普通的表。每一页的第一个字节标明它是什么:
0x0d 表 B 树的叶子页 0x05 表 B 树的内部页
0x0a 索引 B 树的叶子页 0x02 索引 B 树的内部页
```关键在于这两类 B 树的结构不同:
- 表 B 树是 B+ 树:键是 64 位整数 rowid,数据只存在叶子页,内部页只有键和子指针。
- 索引 B 树是经典 B 树:键是任意长度的值,所有层级的页都存键,但不存行数据。
为什么区分?因为内部页不背数据,同一页就能塞下更多的键,树因此更矮——一次按主键的查找往往只要两三次页读取。而全表扫描时,叶子层是连续的,扫完叶子就够了,不必反复上下穿越。这是一个非常古典的空间换深度的权衡,SQLite 只是把它做到了字节级别。
一行数据长什么样
叶子页里的每条记录都是自描述的:先是记录头,声明每一列的类型与长度,然后才是数据。整数一律用变长编码(varint,最高位为 1 表示"还有下一字节"),所以小数字只占 1 字节。
-- 一行 (2, 'hi') 的记录体,一共 6 字节:
03 01 11 02 68 69
│ │ │ └───┴───┴── 数据区:整数 2,以及 UTF-8 的 'h' 'i'
│ │ └── 第 2 列的序列类型 0x11 = 17 = 2×2+13,即"长度为 2 的文本"
│ └── 第 1 列的序列类型 1,即"1 字节整数"
└── 记录头总长 3 字节(含这个长度字节自己)
```序列类型的编码方式很能说明这个项目的性格:0 表示 NULL,1/2/4/6 表示不同宽度的整数,7 表示浮点,8 和 9 表示整数 0 和整数 1——后两个不占任何数据字节。布尔列因此近乎免费。文本和 BLOB 用 2N+13 与 2N+12 编码长度,一个数字同时携带了类型和长度。
一条记录太长放不下一页时,超出部分会溢出到溢出页,溢出页之间用前 4 字节串成链表。
freelist:为什么删数据不会让文件变小
删掉一百万行以后,.db 文件的大小纹丝不动——这是最常被当成 bug 的行为。
被腾空的页不会还给操作系统,而是进入 freelist:一个由 trunk 页和 leaf 页组成的链表。trunk 页的头 4 字节指向下一个 trunk 页,接着 4 字节写明本页记录了多少个空闲页号,后面就是这些页号。leaf 页本身不存任何信息,SQLite 连读都不读它们——这是一个刻意的 I/O 优化。
PRAGMA page_size; -- 4096:每页多少字节
PRAGMA page_count; -- 文件一共多少页
PRAGMA freelist_count; -- 其中多少页是空闲的
PRAGMA journal_mode; -- delete(回滚日志)还是 wal
```这个设计的取舍很清楚:删除变成了 O(1) 的链表操作,不需要搬动任何数据,代价是文件不缩、且空间的局部性会逐渐劣化。想真正回收,要么 VACUUM(重建整个文件,需要约等于库大小的临时空间,且期间独占);要么开启 auto_vacuum,让 SQLite 维护额外的 ptrmap 页来记录每一页的父页,从而能在提交时把尾部空闲页截掉——代价是每次事务都要多写 ptrmap,写放大变大。第三条路是 VACUUM INTO(2019 年加入),把库整理进一个新文件,不动原文件。
到这里可以概括:页是分配单位,B 树是组织方式,freelist 是回收方式,全部在同一个文件里。没有表空间、没有日志目录、没有配置文件、没有 socket。这就是"把数据库当成应用的文件格式"这句话在物理层面的含义。
事务:从回滚日志到 WAL
回滚日志:删文件就是提交
SQLite 的默认模式叫 delete,机制是回滚日志:改一页之前,先把这一页的原始内容抄进 xxx.db-journal 并 fsync,然后才把新内容写进主库。提交的那一刻做的事是——删除 journal 文件。
这个删除动作就是原子提交点。崩溃发生在删除之前,主库里可能是改了一半的乱账,但 journal 里有全套原件;下一个打开这个库的进程会发现一个"热日志"(hot journal),先拿 EXCLUSIVE 锁把原件抄回去,再放行读取。崩溃发生在删除之后,事务已经完整落盘。中间不存在第三种状态。
协调多个进程靠的是操作系统的文件锁,共五档:
| 状态 | 含义 |
|---|---|
| UNLOCKED | 不持锁 |
| SHARED | 可读,多个进程可同时持有 |
| RESERVED | 我打算写,但现在还在读;同一时刻只能有一个 |
| PENDING | 我要写了,不再接纳新读者,等现有读者退场 |
| EXCLUSIVE | 独占,写入进行中 |
RESERVED 和 PENDING 的分工是这套锁最精巧的地方:RESERVED 让写者提前宣告意图却不驱赶任何人,PENDING 则关上进门的闸,防止源源不断的新读者把写者永远饿死。
但回滚日志有一个无法回避的限制:提交必须拿到 EXCLUSIVE 锁,也就是说写者提交的那一刻,所有读者必须已经离场。读和写在时间上互斥。对一个后台在同步数据、前台在滚动列表的手机应用来说,这就是卡顿的来源。
WAL:把方向反过来
WAL(Write-Ahead Logging,预写日志)在 3.7.0(2010-07-21)引入,把整件事翻了个面:主库文件在事务期间完全不动,新的页内容追加到 xxx.db-wal 文件末尾,提交就是往 WAL 里追加一条提交记录。
于是有了第三个文件 xxx.db-shm——共享内存映射,里面是 WAL-index,一张哈希表,作用是让读者用极少的 I/O 回答"第 4712 页最新的版本在 WAL 的第几帧"。
读者在事务开始时记下一个 end mark:WAL 中当时最后一个有效提交点。此后读取遵循一条规则——先在 WAL 里找(只找 end mark 之前的帧),找不到再回主库读。这就天然构成了快照:写者继续往 WAL 尾部追加的新内容,越过了 end mark,对这个读者根本不可见。不需要为读者复制任何数据,快照隔离是位置关系的副产品。
WAL 不能无限长。检查点(checkpoint) 负责把 WAL 里的页搬回主库,默认在 WAL 累积到 1000 页(约 4 MB)时自动触发,模式默认是 PASSIVE——不打扰任何人,搬不动就搬到哪算哪。
| 回滚日志(默认 delete) | WAL(3.7.0 起) | |
|---|---|---|
| 提交时写哪里 | 旧页进 -journal,新页写主库 | 新页追加进 -wal,主库不动 |
| 原子提交点 | 删除 -journal 文件 | 向 -wal 追加一条提交记录 |
| 读者与写者 | 提交瞬间互斥 | 完全并行 |
| 崩溃恢复 | 回放热日志,把原件抄回 | 按帧校验和截掉 -wal 尾部无效帧 |
| 常驻文件 | 仅事务期间有 -journal | 长期存在 -wal 与 -shm |
| 网络文件系统 | 勉强可用(锁实现常有 bug) | 不可用 |
| 跨 ATTACH 库的事务 | 原子 | 每库原子,跨库不原子 |
安全的 synchronous | 需要 FULL | NORMAL 即不会损坏 |
最后一行是 WAL 快的一大来源:在 WAL 模式下 synchronous=NORMAL 是安全的——断电可能丢掉最近几个已提交事务,但数据库文件不会损坏;回滚日志模式要拿到同等的完好性保证则必须 FULL,也就是每次提交都 fsync。少一次 fsync,在机械盘时代意味着少一次几毫秒的等待。
WAL 会在什么地方失效
只讲优点的介绍到此为止。WAL 的代价是实打实的:
- 它需要共享内存,因此在 NFS、SMB 这类网络文件系统上根本不能用。所有访问同一个库的进程必须在同一台机器上。
- 它多了两个文件。"一个文件就是整个数据库"这个卖点被削弱了:
-wal里可能有还没检查点的已提交数据,把.db单独拷走等于丢数据。 - 跨库事务不再原子。ATTACH 多个库时,回滚日志靠一个 super-journal 协调全体,WAL 做不到,只能保证每个库各自原子。
- 不能修改
page_size,得先切回回滚日志模式。 - 大事务上会输。官方的经验值:超过 100 MB 的事务用回滚日志可能更快,超过 1 GB 的事务在 WAL 下可能直接报 I/O 错误或磁盘满。
- 最常见的生产事故:WAL 无限膨胀。检查点无法越过任何一个活跃读者的 end mark。只要有一个连接开了读事务忘了关(ORM 里一个没有释放的游标就够了),检查点就永远推不到底,WAL 文件一路涨到几十 GB。这个故障的表现是磁盘满,根因却在一行没写
commit的代码里。
至于"多个写者并发",SQLite 主干至今没有。相关的探索(BEGIN CONCURRENT、hctree 存储引擎)长期存在于独立分支上,没有进入正式发布版本。
为什么放弃服务器进程
现在回答那个根本问题:为什么不像 PostgreSQL 那样跑一个守护进程?
因为 SQLite 要解决的问题里,"再起一个进程"本身就是不可接受的成本。驱逐舰上的损管系统、遥控器里的机顶盒固件、你手机上那个 3 MB 的应用——它们没有地方安放一个需要配置、需要启动顺序、需要有人监控的服务。
于是 SQLite 是一个库:官方发布的 amalgamation 把整个引擎合成一个 sqlite3.c 文件,编译进你的可执行文件。数据库不是"一个你连接过去的地方",而是"一段跑在你自己栈上的代码 + 一个你自己打开的文件"。进程之间的协调不靠中心仲裁者,靠操作系统的建议性文件锁。
这个选择放弃了什么
| 维度 | SQLite(库内嵌) | 客户端-服务器(PostgreSQL 等) |
|---|---|---|
| 一次查询的路径 | 函数调用 → 页缓存或 read() | 序列化 → 套接字 → 服务端解析 → 序列化回传 |
| 并发写 | 全库同时只有一个写者 | 行级锁 + MVCC,多写者 |
| 访问控制 | 文件系统权限,没有 GRANT | 用户 / 角色 / 对象级授权 |
| 多机共享 | 官方明确劝阻 | 原生能力 |
| 故障域 | 与应用同生共死,共享地址空间 | 独立进程,应用崩溃不波及 |
| 缓存 | 每个进程一份页缓存 | 服务端共享缓冲池 |
| 运维 | 没有进程可运维 | 备份、升级、监控、连接池 |
逐条说清楚代价:
没有权限系统。 打开文件的人就是这个库的最高权限者,没有"只读用户"、没有列级授权、没有审计日志。访问控制的最小粒度是文件系统的 rwx。任何需要"不同用户看到不同数据"的场景,这层保护必须由应用自己实现,数据库帮不上忙。
不能跨机器。 官方的判据非常直白:同一个库是否会被多台机器同时直接访问?是的话就别用 SQLite。 原因不是没实现,而是网络文件系统的建议性锁实现历史上普遍不可靠——你无法在一条会静默失败的信道上实现互斥。
没有常驻进程,就没有后台工作。 没有自动 vacuum 守护线程,没有跨进程共享的查询计划缓存,没有连接池预热。十个进程打开同一个库,就是十份互相独立的页缓存,内存翻十倍。
故障域不独立。 数据库运行在你的地址空间里。一个野指针可以踩坏页缓存,一次 abort() 会把整个"数据库服务"一起带走。磁盘上的数据仍然受回滚日志或 WAL 保护,但进程内的一切都跟应用共命运。
单写者是硬上限。 写并发的天花板是 1。应对手段只有 busy_timeout 排队、把写操作收敛到单一线程、或者按用户分库成多个文件。
极限规模。 理论上限是 281 TB(2⁴⁸ 字节),但单文件形态在接近 TB 级时就已经不实用了。
换来了什么——包括一个不明显的收获
明显的收获是零配置、零运维、部署即拷贝、启动时间以微秒计。
不明显但更重要的收获是:整个数据库变成了一个可以被完全控制的实验对象。SQLite 所有的磁盘 I/O 都走一层叫 VFS(虚拟文件系统)的接口。因为没有独立进程、没有内核态组件、没有网络,测试代码可以替换掉这一层,精确地让第 137 次 write() 失败,或者让机器在某个字节写到一半时"断电"。
这一点直接通向下一节。
被低估的部分:测试
按 SQLite 3.42.0(2023-05-16)的计量,库代码约 155.8 KSLOC,测试代码与测试脚本约 92,053 KSLOC——比例约 590 : 1。这个项目 99.8% 的代码是用来测试另外 0.2% 的。
四套彼此独立的验证体系
- TCL 测试:随源码发布的开源测试套件,约 51,445 个用例,日常开发用它。
- TH3:闭源,用 C 写成(约 1,055 KSLOC),专为嵌入式与受限平台设计,约 50,362 个用例,一次全覆盖运行展开成约 240 万个测试实例,发布前的浸泡测试可达约 2.485 亿个。它负责达成 100% 分支覆盖与 100% MC/DC 覆盖。
- SQL Logic Test:把约 720 万条查询同时跑在 SQLite 与 PostgreSQL、MySQL、SQL Server、Oracle 上,比对结果。这是在用别人的实现当参照系。
- dbsqlfuzz:基于 libFuzzer 的结构感知模糊测试器,同时变异 SQL 语句和数据库文件本身,16 个核心持续对主干轰击,每天约 10 亿次变异。
MC/DC 是什么,为什么它不是"覆盖率"的同义词
行覆盖问的是"这行执行过吗",分支覆盖问的是"这个 if 的两条路都走过吗"。MC/DC(修正条件/判定覆盖)问的是另一件事:对于 if (a && b && c) 这样的复合条件,必须为每一个子条件找到一对测试用例,证明只翻转这一个子条件就能改变整体判定结果。
区别是实质性的。分支覆盖只需要两个用例就能同时覆盖 if (a && b) 的真假两支——但其中 b 可能自始至终没有起过作用,它写错了也不会被发现。MC/DC 强制你证明每个条件都真的在参与决策。
这个标准出自 RTCA DO-178B/C,是民航机载软件 A 级(失效会导致灾难性后果)的适航要求。一个数据库库主动去满足航空电子的验证标准,这件事本身就是这个项目最值得说的部分。
真正的重头戏:异常注入
正常路径上的 bug 靠常规测试就能抓。SQLite 的测试体系有一大半是在制造灾难:
- OOM 注入:让第 N 次
malloc()失败,跑两轮——只失败一次的,和从此永远失败的;然后 N 递增,一直推到操作正常完成为止。目的是验证每一条内存分配失败的返回路径都不泄漏、不崩溃。 - I/O 错误注入:用自定义 VFS 让第 N 次 I/O 操作报错,同样两种模式、同样递推。每次失败之后跑
PRAGMA integrity_check,确认数据库结构仍然自洽。 - 崩溃测试:子进程在写到一半时被杀死,并且模拟真实断电特有的损坏模式——不只是"停在这里",还包括扇区被部分写入、被写入随机垃圾。父进程随后检查:事务要么完整生效,要么完全没发生。
- 复合故障测试:在崩溃恢复的过程中再注入一次 I/O 错误。因为现实中的事故从来不是单点的。
另有约 6,754 条 assert()、1,184 处 testcase() 宏(用来强制验证边界值两侧都被测过)、每次发布前约 200 项人工核对的检查清单,以及"变异测试"——反过来验证每一个分支的存在都真的会改变输出,没有死代码在虚报覆盖率。
架构与可测试性是同一件事
这里是本文想强调的核心:这套测试之所以做得到,正是因为 SQLite 没有服务器进程。
如果数据库是一个独立守护进程,"在第 137 次写盘时断电"就是一个需要专用硬件、且无法精确复现的实验。因为它是一个库、所有 I/O 都经过可替换的 VFS 层、整个系统在单一进程的确定性控制之下,断电才变成一次普通的函数返回值。无服务器架构不只是一个部署优势,它是这种验证强度的前提条件。
反过来,dbsqlfuzz 变异数据库文件这件事,也重塑了代码的写法:既然攻击者可以递给你任意一个 .db 文件,那么文件格式里每一个页号、每一个长度字段都必须当成敌意输入来校验,而不能相信"这是我自己写出来的文件"。
这套体系的代价
三条真实的代价,不该被略过:
第一,TH3 是闭源的。那套达成 100% MC/DC 的测试并不随源码发布,它是 SQLite 通过 Consortium 会员与商业授权维持生计的一部分。开源用户拿到的是全部代码,但不是全部证据。
第二,100% 分支覆盖不等于没有 bug,项目自己也这么说。覆盖率保证的是每个分支都被执行过,不保证每种输入组合都被验证过。SQLite 历史上出过安全漏洞(2018 年经由浏览器 WebSQL 触发的 Magellan 系列即是一例)。
第三——也是影响最深远的——这套测试让改动变得极其昂贵。任何一个补丁都要通过数以亿计的测试实例,加上"文件格式与 C 接口兼容到 2050 年"的承诺,结果就是 SQLite 演进得非常慢、非常保守。很多在外人看来"显然该加"的东西拖了十几年:严格类型的 STRICT 表直到 3.37.0(2021-11-27)才有,ALTER TABLE DROP COLUMN 直到 3.35.0(2021 年)才有,并发写至今在分支上。这不是团队怠惰,这是"承诺永远兼容"这个选择在时间轴上的账单。
它与"数据库"的通常印象哪里不同
把上面的东西合起来,会看到 SQLite 与人们心里那个"数据库"的形象在四个层面上不是一回事。
数据库通常是一个地方,SQLite 是一种格式。 你不"连接"到 SQLite,你打开一个文件。这意味着数据库可以被邮件发送、放进 U 盘、提交进 Git、当成 ZIP 的替代品做数据分发。Hipp 反复推荐的用法是把 SQLite 当作应用的存档格式:File/Open 就是 sqlite3_open(),编辑随时落盘,File/Save 这个菜单项在概念上不再需要。
数据库通常有静态类型,SQLite 是动态类型。 列上声明的 INTEGER 只是一个"亲和性"建议,你照样能往里存字符串。这是 2000 年那个决定的遗产,今天多数人认为它是个错误。但兼容性承诺意味着默认行为不能改,只能新增一个可选的 STRICT 表让愿意的人加入约束。一个二十年前的可疑决定被永久固化下来——这是"承诺到 2050"最直观的代价。
数据库通常有运维,SQLite 没有可运维的东西。 但这不等于没有陷阱:直接 cp 一个正在被写入的库会得到损坏的副本,正确做法是 .backup 命令、在线备份 API 或 VACUUM INTO;WAL 模式下还必须把 -wal 一起带走。
数据库通常是产品,SQLite 是公共领域。 它没有许可证——不是 MIT、不是 BSD,是彻底放弃版权,连署名都不要求。这带来了一个反直觉的后果:为了保证代码来源清白,项目几乎不接受外部贡献,核心团队长期只有三个人,源码托管在 Fossil(Hipp 为 SQLite 另写的版本控制系统)上而非 GitHub。最自由的许可,配上最封闭的开发流程。
近年来 Litestream、LiteFS、Turso、Cloudflare D1 一类项目正在通过复制 WAL 或加一层代理,把 SQLite 推回到服务端与边缘节点。这些尝试有意思的地方在于:它们要补的恰恰是 SQLite 主动放弃的那两样东西——网络与多写者。补上以后它是否还是 SQLite,是个开放问题。
跨域连接
- 安全工程:SQLite 追求的 MC/DC 覆盖不是软件行业自己发明的指标,而是 RTCA DO-178B/C 对民航机载软件 A 级(失效即灾难)的适航要求。安全工程的一条基本认识是:证据的强度必须与失效后果匹配,而"测过了"从来不是证据——你必须说明测试覆盖了哪些失效路径、以何种置信度覆盖。分支覆盖之所以不够,是因为
if (a && b)的两条路都走过时,b完全可能从未起过作用;MC/DC 要求逐个条件证明其对判定结果的独立影响力,把"执行过"升级成"起过作用"。SQLite 引入这个标准是一次明确的自我定位:它知道自己跑在起搏器、飞机娱乐系统和汽车导航里,那里的失效后果不由它的开发者承担,因此举证责任必须提前到发布之前。 - 失效分析:崩溃测试的本质是把"断电"从一次无法复现的事故,改造成一个可编程、可重复的实验条件。失效分析(尤其是 FMEA 方法论)的核心动作是系统性枚举失效模式,而不是等失效自己找上门:内存分配失败、写系统调用返回错误、扇区被部分写入、断电瞬间写入随机垃圾——SQLite 把这份清单逐条变成了注入器,并且沿着 N = 1, 2, 3 … 穷举失败发生的时刻。更关键的是它测试复合失效:在崩溃恢复的过程中再叠加一次 I/O 错误。这正对应失效分析中最难的一类问题——多重失效的联合概率虽低,但恢复路径恰恰是最少被执行、也最少被验证的代码,事故往往就发生在那里。
- 电信网络:SQLite 的性能主张不是"算法更好",而是"把通信距离压到零"。通信网络中的延迟有硬下界——传播时延受光速约束,排队时延受负载与缓冲策略约束,二者都不随 CPU 变快而消失。一次跨进程或跨机器的查询往返,成本比一次本地函数调用高出好几个数量级,而且这个差距是物理性质的,优化不掉。SQLite 的做法是取消这条链路:查询在调用者自己的栈上执行。但同一条网络原理也划出了它的边界——一旦数据必须被多台机器共享,通信就回来了,而 SQLite 赖以互斥的建议性文件锁在 NFS/SMB 上会静默失效。你无法在一条会无声出错的信道上实现可靠的互斥,这不是实现质量问题,这是通信理论层面的结论。
- 知识产权:把代码彻底置于公共领域,是一个法律工程决策,不是一次姿态。对嵌入式厂商而言,任何开源许可证都意味着合规成本:署名义务、传染性条款、以及一次法务评审。公共领域把这项成本清零,这是 SQLite 能进入几乎所有闭源固件的直接原因。代价同样落在法律层面:没有许可证也就没有可主张的权利与可追究的责任,而且为了保证版权来源干净,项目必须拒绝随手提交的外部代码——贡献者要签署放弃版权的声明,于是核心团队二十多年只有三人。更微妙的是,"没有许可证"本身会让部分企业法务不安(他们的流程要求每份依赖都有一份文件),项目因此提供有偿的所有权保证书。放弃产权反而制造出一门与产权有关的生意,这是知识产权制度中一个相当典型的反身效应。
- 公共资源治理:SQLite 是"零许可 + 零民主"的组合,这个搭配用奥斯特罗姆的框架看恰好成立。公共池塘资源研究最反直觉的结论是:公地要想不悲剧,靠的不是彻底开放,而是清晰的边界、明确的规则制定权与可信的监督机制。SQLite 把使用权开放到极致——任何人任何用途,无需告知任何人;同时把治理权收紧到极致——不收 pull request、不做社区投票、路线图由三人决定、资金由 Consortium 会员提供而非用户众筹。二者不矛盾,而是互为条件:正因为没人能对代码主张权利,谁有权修改它才必须由一套外生的、极其严格的规则来界定,否则那 590 : 1 的测试体系所维护的质量承诺立刻失去主体。这也解释了为什么它选择自建 Fossil 而非托管在 GitHub——托管平台的默认工作流预设了开放贡献,而这正是该项目治理结构要拒绝的东西。
参考文献
- SQLite. Database File Format(sqlite.org/fileformat2.html)——页布局、B 树页类型、记录序列类型与 freelist 结构的一手定义。
- SQLite. Write-Ahead Logging(sqlite.org/wal.html)——WAL 于 3.7.0(2010-07-21)引入,WAL-index、end mark、检查点与全部已知限制的官方说明。
- SQLite. File Locking And Concurrency In SQLite Version 3(sqlite.org/lockingv3.html)——五档锁状态、热日志恢复与 super-journal 的机制描述。
- SQLite. How SQLite Is Tested(sqlite.org/testing.html)——3.42.0(2023-05-16)的 155.8 KSLOC 对 92,053 KSLOC、四套测试体系、MC/DC 与异常注入方法的出处。
- SQLite. Appropriate Uses For SQLite(sqlite.org/whentouse.html)——"与 fopen() 竞争"的定位,以及应改用客户端-服务器数据库的四类判据。
- Hipp, D. R. The Untold Story of SQLite. CoRecursive Podcast, Episode 66, 2019.——海军合同起源、公共领域决策与开发模式的第一手叙述。
延伸阅读
- RTCA. DO-178C: Software Considerations in Airborne Systems and Equipment Certification. 2011.——MC/DC 覆盖标准的原始出处,理解 SQLite 测试目标的参照系。
- Kleppmann, M. Designing Data-Intensive Applications, Ch. 3 "Storage and Retrieval". O'Reilly, 2017.——B 树、预写日志与隔离级别的通用理论背景。
- SQLite. 35% Faster Than The Filesystem(sqlite.org/fasterthanfs.html)——项目方对"读小对象比读独立文件更快"的测量与方法说明。