读书笔记 · 约 11,902 字 · 预计推送 2 部分

SQLite 内幕:WAL 机制、B-tree 结构、为什么它是最广泛部署的数据库(主题精读) 未开始

目录

题记与说明


0. TL;DR

SQLite 把"数据库"做成了一个嵌入应用进程的 C 库:没有服务器、没有网络协议,整个数据库就是一个跨平台单文件。支撑它做到"事务安全 + 高并发读 + 极致简单"的是三件事:定长页面 + 两种 B-tree(表/索引)负责数据的磁盘组织与对数级检索;WAL(写前日志)负责事务原子性与读写并发;单写者多读者 + 共享内存 wal-index 负责并发控制。官方对其地位的说法是:部署量"很可能超过其他所有数据库引擎的总和",活跃使用的 SQLite 数据库可能超过 1 万亿个;它的竞争对手不是 PostgreSQL,而是 fopen()。


1. 核心问题

这组材料共同回答的问题是:一个没有任何服务器进程的嵌入式库,凭什么能同时做到 (a) 可靠的 ACID 事务落盘、(b) 读写互不阻塞的并发、(c) 在数十亿设备上成为部署量最大的数据库? 拆开是三个子问题:

  1. WAL 机制:SQLite 的事务原子性如何实现?为什么 2010 年引入的写前日志比最初的回滚日志(rollback journal)更快、并发更好?代价是什么?
  2. B-tree 结构:数据在磁盘上到底长什么样?一个 INSERT 从回车键到字节落盘经历了什么?为什么几十 GB 的表查询仍然只要读几个页?
  3. "最广泛部署":这个说法的证据是什么?SQLite 到底在和谁竞争、为什么赢?

2. 主要主张

2.1 WAL:把"改原件存旧页"反转为"原页不动,新页追加"

"A COMMIT occurs when a special record indicating a commit is appended to the WAL. Thus a COMMIT can happen without ever writing to the original database, which allows readers to continue operating from the original unaltered database while changes are simultaneously being committed into the WAL."(wal.html §2)

2.2 WAL 的运行时机制:end-mark 快照、checkpoint、wal-index

2.3 B-tree:数据库 = 定长页组成的两种 B+ 树

"Two variants of b-trees are used by SQLite. 'Table b-trees' use a 64-bit signed integer key and store all data in the leaves. 'Index b-trees' use arbitrary keys and store no data at all."

即表 b-tree = rowid 为键的 B+ 树(数据全在叶子),索引 b-tree = 任意键、无数据。页类型字节:0x02 索引内部页、0x05 表内部页、0x0a 索引叶子页、0x0d 表叶子页。

"At a tree depth of 4, we can hold about 500³ leaf pages, or about a 476GB database. That means we only need to read 4 pages to find a given record—even in a huge database!"

2.4 为什么是最广泛部署的数据库

"SQLite does not compete with client/server databases. SQLite competes with fopen()."

C/S 引擎卖的是"共享的企业数据中心"(并发、集中管控),SQLite 卖的是"应用本地数据存储"(零配置、可靠、单文件、离线可用)。它赢在赛道不同:所有需要"比裸文件格式更可靠的结构化本地存储"的地方都是它的市场——应用文件格式、IoT、浏览器、消息队列、数据传输容器。


3. 方法

各来源的论证方式不同,恰好互补成"规范—机制—实证—定位"四层:

  1. 官方规范(fileformat2.html):把文件格式定义到字节级——每个头字段给出偏移量、字节数、大小端、合法值;WAL 帧有效性判据(salt 匹配 + 累积校验和)写成算法。这是"可验证"的源头。
  2. 官方机制文档(wal.html):叙述设计动机与取舍——优势/劣势逐条列出,包括失效模式(checkpoint starvation、SQLITE_BUSY 的罕见场景、2026 年新披露的 WAL-reset bug)。
  3. 从业者源码级解读(Fly.io 系列):用 hexdump 展示真实文件的字节,把"规范"翻译成"你亲手能看到的东西";用三明治店类比解释并发模型;用 b+ 树高算术解释"为什么大表查询也快"。
  4. 官方定位论证(mostdeployed / whentouse):先给覆盖面清单与数量级估算,再用"竞争对手是 fopen()"一句话锚定生态位。
  5. 我的实证(本次新增):在 Python 3.12.12 / SQLite 3.50.4 上建库、切 WAL、逐字节核对官方规范声称的头字段与帧结构(方法与输出见 §4.4)。

4. 数据与证据

4.1 关键数字(均出自所引来源)

数字含义出处
3.7.0(2010-07-21)WAL 引入版本wal.html
1000 页(≈4MB @4KB 页)WAL 自动 checkpoint 阈值wal.html §3.1
~1e12活跃 SQLite 数据库数量估算(40 亿手机 × 每机数百库)mostdeployed.html
"前五名/仅次于 libz"官方自评的软件模块部署量排名mostdeployed.html
512B–64KB / 281TB页大小范围 / 数据库上限(2³²−2 × 64KB)fileformat2 §1.2
32 字节 / 24 字节WAL 文件头 / 帧头大小fileformat2 §4.1
0x377f0682/83WAL magic(82=小端校验和,83=大端)fileformat2 §4.1
3007000WAL 格式版本号(3.7.0 起,至今唯一)fileformat2 §4.1
2/5/10/13 (0x02/05/0a/0d)四种 B-tree 页类型字节fileformat2 §1.6
100 字节数据库文件头(仅页 1)fileformat2 §1.3
4 页476GB 库内定位任意记录的最大页读取数fly.io btree 文
32KB / 4096+8192 / ×383%8192wal-index 块大小 / 页号列表+哈希槽数 / 哈希公式fly.io wal 文
130K LOCSQLite 核心代码量(对照:Linux 内核 ~3 千万行)fly.io btree 文
3.7.0–3.51.2 / 3.51.3(2026-03-13 修复)WAL-reset bug 影响范围/修复版本wal.html §11

4.2 关键原文引述(备查)

4.3 交叉验证中发现的细节

4.4 本地实证(本次执行,非引用)

环境:Windows 10(10.0.26200),Python 3.12.12,bundled SQLite 3.50.4(注意:落在 WAL-reset bug 影响区间 3.7.0–3.51.2 内)。命令:python -I sqlite_wal_verify.py(脚本建于工作区,库文件建于其 wal_demo 子目录)。输出摘录:

[1] default journal_mode      : delete
[2] PRAGMA journal_mode=WAL   : wal
[3] wal_autocheckpoint default: 1000 pages          ← 与官方文档一致
[4] db header magic           : b'SQLite format 3\x00'
[5] header bytes 18/19        : 2 2 (1=rollback, 2=WAL)  ← 与两来源一致
[6] page size at offset 16    : 4096 bytes
[7] -wal file size            : 12392 bytes  = 32B 头 + 3×(24B 帧头+4096B 页数据)
[8] WAL magic                 : 377f0682      ← 小端校验和变体
[9] WAL format version        : 3007000       ← 16 年未变的格式版本
[11] salt-1 / salt-2          : 0xd337a56c / 0x81ebf01b
[12] frame#1: page number: 1 | db size after commit: 0 | salts copied: True
[13] reopen -> journal_mode   : wal (WAL 模式跨重连持久)

两个超出预期的实证收获:(a) [3] 直接验证 autocheckpoint 默认值 1000 页;(b) [12] 中第 1 帧的 commit 字段为 0——当场实证了"commit 标记只出现在事务最后一帧、前面帧该字段为 0"的事务边界机制(1 个 CREATE TABLE 事务写 2 帧 + 1 个 INSERT 事务写 1 帧,共 3 帧 = 实测文件大小 12392 字节精确吻合)。


5. 结论

  1. WAL 是 SQLite 并发与性能的分水岭:一次反转(旧页保护 → 新页追加)同时换来了顺序写、更少 fsync、读写并存三大收益;读者靠 end-mark 各自持有快照,写者单线程追加,checkpoint 负责收敛。工程代价集中在一处——共享内存依赖(同机、非网络盘)与 checkpoint 饥饿风险。
  2. B+ 树 + 定长页是"大库快查"的全部秘密:数据组织成以 rowid 为键的表树和任意键的索引树,树高 3–4 层即覆盖 GB–TB 级数据;sqlite_schema 固定在页 1 使"打开库"变成一次 O(1) 的元数据寻址。
  3. "最广泛部署"是一个生态位命题而非性能命题:SQLite 赢在把自己定位成"更好的 fopen()"——零管理、单文件、离线可靠、嵌入一切设备;它明确放弃多写者并发与跨网络场景,换取在数十亿端侧设备上的不可替代性。简单性(130KLOC)是它敢承诺"never corrupt"的资本。
  4. 可靠性文化有其边界:2026 年披露的 WAL-reset bug 表明,即便在以测试强度著称的项目里,紧时序竞态也可能潜伏 16 年。

6. 局限


7. 对我的启发

  1. 选型先问生态位,再问性能:"SQLite competes with fopen()" 是本主题最值得带走的一句话。给我自己的项目(Agent 记忆、工具状态、本地数据集)做存储决策时,先判断数据是否与应用同机、写并发是否低——是,则 SQLite+WAL 几乎总是对的;跨网络/多写者才升级 C/S。
  2. 追加式日志是并发与性能的通用杠杆:WAL 的套路(原数据不动 + 变更追加 + 定期合并回写 + 快照读)与 LSM-tree、Kafka、Redis AOF、Raft log 同构;其中"读事务记住 end-mark 就是 MVCC 的最小实现",理解了 SQLite 的 WAL,就拿到一把理解所有写前日志系统的钥匙。
  3. 学存储系统要下到字节:fileformat2.html 证明"把格式定义到偏移量"才能真正可验证;我这次的 13 项实证(尤其是帧 commit 字段=0 的意外发现)比读十篇二手解读更踏实。以后读任何存储引擎,都值得写一个 hexdump 脚本对照规范。
  4. 简单性是可靠性的前提,也是约束设计:130KLOC 做到 95% 的功能,靠的是明确放弃(多写者、网络盘、改 page_size)。做系统设计时,"列出我不做什么"和"列出我做什么"同样重要——官方 whentouse 页就是一份模板。
  5. 警惕长读事务:checkpoint starvation 是 WAL 模式最现实的坑(监控 WAL 文件大小、给读者留间隙、必要时用 RESTART/TRUNCATE checkpoint),这条可以直接进生产 checklist。

附:来源链接汇总

  1. https://www.sqlite.org/wal.html (WAL 官方文档,2026-08-25 更新,含 WAL-reset bug)
  2. https://www.sqlite.org/fileformat2.html (文件格式规范:页/B-tree/记录格式/WAL 格式)
  3. https://www.sqlite.org/mostdeployed.html (部署量论证)
  4. https://www.sqlite.org/whentouse.html (适用场景与生态位)
  5. https://fly.io/blog/sqlite-internals-wal (Fly.io:WAL 内幕,帧格式/shm 哈希索引)
  6. https://fly.io/blog/sqlite-internals-btree (Fly.io:页与 B-tree,hexdump 实证)
← 资料库今日播报 →