土法炼钢兴趣小组的算法知识备份

SQLite 是怎么做到十亿行每秒的

目录

“SQLite 太简单了,性能不行吧?”

这是多数人的第一反应。没有客户端/服务器架构,没有连接池,没有后台线程,没有共享内存缓冲池——按照”功能越多越强”的直觉,SQLite 应该是性能最差的数据库。

但事实正好相反。

SQLite 官方的 speedtest1 基准测试在特定的 CPU-bound 场景下跑出了十亿行级别的吞吐——但那是在数据全部驻留内存、查询极其简单的前提下。2024 年 Gunnar Morling 发起的 One Billion Row Challenge 也表明,在”读一个文件、聚合十亿行”这类单机批处理任务上,SQLite 可以和专门的列式引擎在同一量级竞争。

更关键的是,在嵌入式场景(单机、中小数据量、读多写少)的随机点查和批量插入中,SQLite 对 MySQL 和 PostgreSQL 的延迟优势可以达到 5-10 倍,主要来自省掉了 IPC 和连接管理开销。这个倍率会随着查询复杂度上升而收窄。

这不是因为 SQLite “简单所以快”。恰恰相反,src/btree.c 单个文件就有近万行代码,src/vdbe.c(虚拟机引擎)同样体量。SQLite 的速度来自一组精心选择的架构约束

  1. 单文件 — 砍掉网络协议栈、连接管理、进程间通信,函数调用直达 B-Tree 节点。
  2. 单写者 — 砍掉行级锁、锁管理器、死锁检测,写路径没有 mutex 竞争。
  3. 确定性页面大小 — B-Tree 页面布局精确到字节,cache line 对齐,二分查找零浪费。
  4. 自管 Page Cache — 比操作系统更懂 B-Tree 访问模式,热页面永远在内存。
  5. WAL 模式 — 读写并发,读者看快照,写者追加写,读路径无需获取行级锁。

每一个约束都砍掉了 MySQL/PostgreSQL 为通用性付出的一层开销。五层累积,简单查询的延迟差出 5-10 倍就不奇怪了。

本文从源码级别拆解这三层性能引擎:B-Tree 页面布局WALPage Cache。如果你读过本站的 LSM-Tree 系列,本文将从 B-Tree 视角补完存储引擎的另一半版图。


第一章:单文件的暴政

SQLite 的整个数据库就是一个文件——一个普通的、可以用 cp 拷贝的磁盘文件。

这个看似”简陋”的设计决策,带来了巨大的性能红利。

调用链深度对比

当你执行一条 SELECT * FROM users WHERE id = 42 时:

MySQL(InnoDB)的路径:

MySQL vs SQLite 查询路径对比

即使用 Unix domain socket 跑在本机上,MySQL 的路径也涉及:进程间通信、线程调度、SQL 文本解析、查询计划生成、Buffer Pool 的 mutex 获取/释放。每一层都有成本。

SQLite 的路径:

没有网络层。没有进程间通信。没有连接线程。VDBE(Virtual Database Engine)的字节码是预编译好的(sqlite3_prepare_v2),执行时不需要重新解析 SQL 文本。

具体的差距有多大?SQLite 作者 D. Richard Hipp 在多次演讲中提到过一个数量级估计:同样的简单点查,SQLite 大约需要 ~50 次 C 函数调用就能走完整条路径,而 MySQL 需要 500+ 次——其中大量调用花在连接管理、权限检查、查询优化器的代价估算上,对于简单查询来说这些全是浪费。

这个差距不是代码质量问题。InnoDB 的代码写得很好。问题在于架构分层:客户端/服务器架构为了支持网络访问、并发连接、权限隔离,天然需要这些层。SQLite 选择不支持这些特性,于是不需要为它们付出代价。

函数调用 vs 系统调用

更微妙的差距在于系统调用的数量。

MySQL 的一次查询至少涉及:recvfrom()(收请求)、若干次 futex()(mutex 操作)、可能的 pread64()(读磁盘)、sendto()(发响应)。每次系统调用意味着一次用户态/内核态切换,在启用 Spectre 缓解的现代内核上,这个开销在 ~1μs 量级。

SQLite 在 page cache 命中的情况下,整条查询路径可以不触发额外的系统调用——数据就在进程的堆内存里,通过 sqlite3PagerGet() 直接返回缓存页面的指针。只有 cache miss 时才需要一次 pread()


第二章:B-Tree 页面布局

SQLite 的 B-Tree 实现在 src/btree.c,这是整个代码库中最大、最关键的文件。核心数据结构是 MemPage(表示一个已加载到内存的 B-Tree 页面),核心查找函数是 sqlite3BtreeMovetoUnpacked()——它接受一个 key,沿 B-Tree 逐层二分查找,最终把游标定位到目标 cell。页面布局的设计精确到每一个字节,直接决定了这个函数的性能。

页面结构

SQLite 的数据库文件被划分为固定大小的页面(page),默认 4096 字节(可通过 PRAGMA page_size 调整,范围 512-65536)。每个页面要么是 B-Tree 的一个节点,要么是 overflow 页面、freelist 页面等辅助结构。

一个 B-Tree 叶子页面的布局:

SQLite B-Tree 页面布局

Page Header 包含:

偏移 大小 含义
0 1 页面类型标志(0x0D = 叶子表页,0x05 = 内部表页,0x0A = 叶子索引页,0x02 = 内部索引页)
1 2 第一个 freeblock 的偏移
3 2 页面中 cell 的数量
5 2 cell content area 的起始偏移
7 1 fragmented free bytes 数量

内部页面(interior page)的 header 多 4 字节(偏移 8-11),存储最右子页面的页号。

为什么这个布局快

这个布局有三个精妙之处:

1. Cell Pointer Array 在顶部,Content Area 从底部增长

插入一条新记录时: - 在 Cell Pointer Array 末尾追加一个 2 字节指针(向下增长) - 在 Content Area 顶部写入 cell 数据(向上增长) - 两个区域相向增长,中间的 Unallocated Space 自然收缩

这意味着插入操作不需要移动任何已有数据。对比 InnoDB 的页面结构:插入新记录可能需要移动页面内已有记录来维持物理有序性。SQLite 的 Cell Pointer Array 负责逻辑排序(指针按 key 排序),物理存储可以乱序。

2. 二分查找只访问页面头部

查找一个 key 时,首先对 Cell Pointer Array 做二分查找。这个数组紧凑排列在页面头部,对于一个有 100 个 cell 的页面,整个数组只有 200 字节——一两个 cache line 就能装下。

二分查找的每次比较需要跟随指针读取实际的 cell 数据(key 值),但 Cell Pointer Array 本身的遍历是线性且局部的。

3. Overflow 控制

当一条记录太大,装不进单个页面时,SQLite 只在当前页面存储记录的前 N 字节(取决于页面大小),剩余部分溢出到 overflow page 链。阈值的计算保证每个叶子页面至少能容纳 4 个 cell——这避免了 B-Tree 退化为链表。

B-Tree 与 B+Tree:SQLite 的双树设计

SQLite 同时使用两种树结构:

Table B-Tree(实质是 B+Tree): - Key 是 rowid(64 位整数) - 数据(整行记录)只存储在叶子节点 - 内部节点只存 key + 子页面指针 - 这是存储表数据的默认结构

Index B-Tree(真正的 B-Tree): - Key 是索引列的值 - 叶子节点存储 key + 对应的 rowid - 内部节点也存储 key(含实际值) - 用于 CREATE INDEX 创建的索引

这个设计的性能含义:

LSM-Tree 的设计形成鲜明对比:LSM-Tree 把所有数据视为 KV 对,不区分”表数据”和”索引数据”,统一存储在有序的 SSTable 中。B-Tree 的层级结构让点查只需 O(log N) 次页面访问,但写入需要随机 I/O 修改页面;LSM-Tree 的追加写让写入都是顺序 I/O,但读取可能需要搜索多个层级。


第三章:WAL——读写并发的秘密武器

Rollback Journal:旧方案的痛点

SQLite 的默认日志模式是 rollback journal(journal_mode=DELETE)。工作方式:

  1. 开始写事务前,把要修改的原始页面拷贝到 .db-journal 文件
  2. 修改数据库文件中的页面
  3. 事务提交后,删除 journal 文件

如果中途崩溃,重启时检测到 journal 文件存在,把原始页面从 journal 拷贝回数据库文件——完成回滚。

这个方案有两个严重的性能问题:

问题一:写入路径有两次 fsync - 写 journal 后需要 fsync(确保原始页面已持久化,否则崩溃后无法回滚) - 修改数据库文件后需要 fsync(确保新数据已持久化) - 两次 fsync 在传统硬盘上意味着两次 ~10ms 的寻道

问题二:读写完全互斥 - 写事务持有独占锁:写入期间,所有读操作被阻塞 - 读事务持有共享锁:读取期间,写操作被阻塞 - 在读多写少的场景下,偶尔的写入会导致所有并发读排队

WAL 模式如何解决

PRAGMA journal_mode=WAL; 彻底改变了写入路径:

核心思路:不再修改数据库文件本身,把所有修改追加到一个单独的 WAL 文件。

SQLite WAL 数据流

性能提升来自三处:

  1. 写路径只需一次 fsync(写 WAL),而不是 rollback journal 的两次
  2. 追加写是顺序 I/O,比 rollback journal 修改数据库文件的随机 I/O 快得多
  3. 读写不互斥:读者读的是 WAL 某个时间点之前的快照,写者继续追加,两者互不干扰

WAL 与 LSM-Tree WAL 的对比

如果你读过 LSM-Tree 的 WAL 实现,会发现两者名字相同但角色截然不同:

维度 SQLite WAL LSM-Tree WAL
主要目的 读写并发 + 崩溃恢复 仅崩溃恢复
写入粒度 整个修改后的页面(4KB) 单条 KV record(几十到几百字节)
读路径参与 是——读者需要查 WAL 获取最新数据 否——读者查 MemTable 和 SSTable
并发控制角色 核心——WAL 快照实现 MVCC 无——并发由 MemTable 的并发数据结构处理
Checkpoint 含义 WAL 页面写回主文件 MemTable flush 为 SSTable
空间开销 WAL 大小受 checkpoint 频率控制,通常 ~几 MB WAL 大小受 MemTable 大小控制(通常 4-64MB)

关键洞察:SQLite 的 WAL 身兼二职——既是持久性保证,也是并发控制机制。LSM-Tree 的 WAL 只负责持久性,并发控制交给 MemTable 的 lock-free 跳表。这种职责差异直接来源于架构差异:SQLite 的写操作直接修改 B-Tree 页面,没有内存中的缓冲层来吸收并发。

wal-index:mmap 的精妙用法

WAL 模式引入了一个问题:读者怎么快速知道某个页面在 WAL 文件的什么位置?

答案是 wal-index——数据库文件旁边的 -shm(shared memory)文件。

wal-index 通过 mmap() 映射到每个访问数据库的进程的地址空间。它本质上是一个哈希表(见 src/wal.c 中的 WalHashLoc 结构和 walIndexReadHdr() 函数):给定页面号,O(1) 查找该页面在 WAL 文件中的最新偏移量。

精妙之处:

对比 InnoDB 的并发控制:Buffer Pool 需要 mutex 保护、redo log 需要 log_sys mutex、每个 B-Tree 页面有 latch——这些锁在高并发下成为瓶颈。SQLite 的 WAL + wal-index 用 mmap 和原子操作大幅减少了读路径上的锁操作(写路径仍有 WAL write lock,但同一时刻只有一个写者,所以这个锁没有竞争)。

代价是什么?单写者。SQLite 在 WAL 模式下依然只允许一个写事务。这不是实现缺陷,而是深思熟虑的架构选择——砍掉多写者支持,换来的是读路径几乎零开销的并发。


第四章:Page Cache——比操作系统更聪明

为什么不直接用 OS page cache

操作系统的 page cache 对所有文件一视同仁:LRU 或类 LRU 策略,预读基于顺序访问检测,淘汰基于全局内存压力。

但 B-Tree 的访问模式和”通用文件”完全不同:

操作系统的 page cache 看不到这些语义。它只知道”最近这个文件的第 N 个 4KB 块被读了”。如果系统内存紧张,OS 可能把 B-Tree 根节点——这个最关键的页面——淘汰掉,因为按 LRU 算法它”最近一次访问”可能已经不是最新的了。

SQLite 的 Page Cache(src/pager.c + src/pcache.c)自己管理缓存,理解 B-Tree 的访问层级。核心获取函数 sqlite3PcacheFetch() 根据页面号从哈希表中查找缓存页(PgHdr 结构),命中时直接返回指针,整条路径不涉及任何锁竞争。

cache_size 的真实影响

PRAGMA cache_size 控制 Page Cache 的大小。默认值是 -2000,即约 2MB(负数表示 KB)。

这个参数对性能的影响取决于工作集大小:

场景 默认 2MB 调大到 64MB 分析
10MB 数据库,随机点查 频繁 cache miss 全部缓存命中 性能差数量级
10MB 数据库,顺序扫描 页面不断淘汰重读 全表在内存中 差距更大
1GB 数据库,随机点查 绝大多数 miss 内部节点可缓存 提升显著但非数量级
1GB 数据库,热点集中 热点页面反复淘汰 热点页面常驻 提升取决于热点比例

关键观察:当 cache 命中率超过 95% 时,SQLite 的查询几乎等价于纯内存操作——单线程场景下没有任何 mutex 竞争。InnoDB 即使 Buffer Pool 足够大,也曾经受制于 buf_pool->mutex 的集中竞争;MySQL 5.7 开始引入 Buffer Pool 分片和 hazard pointer 优化大幅缓解了这个问题,MySQL 8.0 进一步移除了部分全局锁。但 InnoDB 的读路径仍然比 SQLite 的单线程直连路径多出 LRU 维护和 page latch 获取的固定开销。

对比 LevelDB 的 LRU Cache:LevelDB 使用 16 分片的 ShardedLRUCache 减少锁竞争,但仍然是 mutex-based。SQLite 在单写者模型下直接避免了整个问题。

mmap 模式:权衡

SQLite 3.7.17+ 支持 mmap I/O:

PRAGMA mmap_size = 268435456;  -- 256MB

启用后,SQLite 用 mmap() 映射数据库文件,读取时直接通过页表访问,跳过用户态的 Page Cache 和 read() 系统调用。

好处:省掉一次从内核到用户态的 memcpy,在大数据库上可以减少内存使用(OS page cache 和 SQLite page cache 不再重复缓存同一份数据)。

代价: - 失去 SQLite 自身的缓存策略控制——淘汰策略退化为 OS 的通用 LRU - mmap 在某些文件系统和操作系统上有 bug(SQLite 文档明确提到了 SQLITE_MAX_MMAP_SIZE 的平台相关问题) - 写操作仍然走 WAL,mmap 只加速读路径

D. Richard Hipp 本人对 mmap 持谨慎态度——他在邮件列表中多次表示,mmap 让数据库代码暴露于信号(SIGBUS)和操作系统行为的不确定性之下,违背了 SQLite “deterministic behavior” 的设计哲学。


第五章:不是万能药

前面讲了 SQLite 为什么快,但它快是有条件的。搞清楚边界才能做出正确的技术选型。

SQLite 赢在哪里

SQLite 输在哪里

1. 并发写入

SQLite WAL 模式允许一个写者 + 多个读者,但只有一个写者。如果你需要多个线程/进程同时写入,SQLite 会串行化所有写事务——吞吐量急剧下降。

MySQL/PostgreSQL 支持行级锁,多个事务可以并发修改不同行。在写密集的 Web 后端场景,这是决定性的优势。

2. 数据量超过内存

当数据库大到 Page Cache 装不下工作集时,SQLite 的每次查询都可能触发磁盘 I/O。此时 B-Tree 的层级决定了最坏情况的 I/O 次数(约 log_B(N),B 是每页 cell 数,N 是总行数)。

InnoDB 在这种场景下有更好的缓存管理(adaptive hash index、change buffer),PostgreSQL 有更强大的查询优化器和并行扫描能力。

3. 需要网络访问

SQLite 不支持网络协议。如果你需要多台服务器访问同一个数据库,SQLite 不是选项(除非你在上层包装一个网络代理,但那就失去了嵌入式优势)。

4. 复杂查询优化

SQLite 的查询优化器相对简单:JOIN 只支持 nested loop(不支持 hash join 和 sort-merge join),统计信息收集有限,不支持并行查询执行。对于 OLAP 类复杂查询(多表 JOIN、大量聚合),PostgreSQL 的优化器领先一个时代。

选型决策树

数据库选型决策树

结尾:约束即自由

SQLite 的性能不是来自某个天才算法或硬件 hack。它来自一系列清醒的”不做”决策:

每一个”不做”都不是偷懒,而是在明确的使用场景下有意识地把性能预算从通用性上收回来,全部投入到核心路径上

这和 LSM-Tree 的设计哲学形成了镜像对比:LSM-Tree 的约束是”所有写入都必须先进内存再批量刷盘”——放弃了随机写,换来了极致的写吞吐。SQLite/B-Tree 的约束是”单文件、单写者”——放弃了并发写和网络访问,换来了极致的单线程读写延迟。

没有”更好的”存储引擎,只有更匹配约束的存储引擎。


参考

  1. SQLite Documentation. How SQLite Is Tested — 理解 SQLite 代码质量的第一篇文档。
  2. SQLite Documentation. Write-Ahead Logging — WAL 机制的官方说明。
  3. SQLite Documentation. SQLite File Format — B-Tree 页面布局的权威参考。
  4. SQLite Source. src/btree.c, src/pager.c, src/wal.c — 本文分析的三个核心源文件。
  5. LevelDB LRU Cache 实现分析 — 本站文章,对比 Page Cache 策略。
  6. LSM-Tree 全景 — 本站文章,B-Tree vs LSM-Tree 设计哲学对比。
  7. WAL + MemTable — 本站文章,LSM-Tree 的 WAL 实现。

By .