“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
的速度来自一组精心选择的架构约束:
- 单文件 — 砍掉网络协议栈、连接管理、进程间通信,函数调用直达 B-Tree 节点。
- 单写者 — 砍掉行级锁、锁管理器、死锁检测,写路径没有 mutex 竞争。
- 确定性页面大小 — B-Tree 页面布局精确到字节,cache line 对齐,二分查找零浪费。
- 自管 Page Cache — 比操作系统更懂 B-Tree 访问模式,热页面永远在内存。
- WAL 模式 — 读写并发,读者看快照,写者追加写,读路径无需获取行级锁。
每一个约束都砍掉了 MySQL/PostgreSQL 为通用性付出的一层开销。五层累积,简单查询的延迟差出 5-10 倍就不奇怪了。
本文从源码级别拆解这三层性能引擎:B-Tree 页面布局、WAL、Page Cache。如果你读过本站的 LSM-Tree 系列,本文将从 B-Tree 视角补完存储引擎的另一半版图。
第一章:单文件的暴政
SQLite 的整个数据库就是一个文件——一个普通的、可以用
cp 拷贝的磁盘文件。
这个看似”简陋”的设计决策,带来了巨大的性能红利。
调用链深度对比
当你执行一条
SELECT * FROM users WHERE id = 42 时:
MySQL(InnoDB)的路径:
即使用 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 叶子页面的布局:
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 创建的索引
这个设计的性能含义:
- 主键查找(
WHERE rowid = ?)走 Table B-Tree,一次树搜索直接拿到数据 - 索引查找(
WHERE name = ?)走 Index B-Tree 拿到 rowid,再走 Table B-Tree 拿数据——两次树搜索 WITHOUT ROWID表是例外:数据直接存在 Index B-Tree 的叶子节点里,避免二次查找
与 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)。工作方式:
- 开始写事务前,把要修改的原始页面拷贝到
.db-journal文件 - 修改数据库文件中的页面
- 事务提交后,删除 journal 文件
如果中途崩溃,重启时检测到 journal 文件存在,把原始页面从 journal 拷贝回数据库文件——完成回滚。
这个方案有两个严重的性能问题:
问题一:写入路径有两次 fsync - 写 journal 后需要 fsync(确保原始页面已持久化,否则崩溃后无法回滚) - 修改数据库文件后需要 fsync(确保新数据已持久化) - 两次 fsync 在传统硬盘上意味着两次 ~10ms 的寻道
问题二:读写完全互斥 - 写事务持有独占锁:写入期间,所有读操作被阻塞 - 读事务持有共享锁:读取期间,写操作被阻塞 - 在读多写少的场景下,偶尔的写入会导致所有并发读排队
WAL 模式如何解决
PRAGMA journal_mode=WAL;
彻底改变了写入路径:
核心思路:不再修改数据库文件本身,把所有修改追加到一个单独的 WAL 文件。
性能提升来自三处:
- 写路径只需一次 fsync(写 WAL),而不是 rollback journal 的两次
- 追加写是顺序 I/O,比 rollback journal 修改数据库文件的随机 I/O 快得多
- 读写不互斥:读者读的是 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 文件中的最新偏移量。
精妙之处:
- 无需 IPC:多个进程通过 mmap 共享同一块内存,不需要消息传递
- 无需锁管理器:读者使用的是 WAL 的某个快照点(end mark),不需要获取行级锁
- 崩溃安全:wal-index 可以从 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 的访问模式和”通用文件”完全不同:
- B-Tree 根节点和高层内部节点几乎每次查询都要访问——它们应该永远在缓存中
- 叶子节点的访问取决于查询模式——随机点查时每次访问不同叶子,range scan 时顺序访问相邻叶子
- Overflow 页面只在读取大记录时访问——优先级最低
操作系统的 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 赢在哪里
- 单机嵌入式场景:移动 app、桌面应用、IoT 设备、CLI 工具——不需要网络,不需要并发写入
- 读密集 + 中小数据量:数据能装进内存或 SSD 随机读延迟可接受的场景
- 低运维成本:一个文件就是整个数据库,部署/备份/迁移只需
cp
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 的约束是”单文件、单写者”——放弃了并发写和网络访问,换来了极致的单线程读写延迟。
没有”更好的”存储引擎,只有更匹配约束的存储引擎。
参考
- SQLite Documentation. How SQLite Is Tested — 理解 SQLite 代码质量的第一篇文档。
- SQLite Documentation. Write-Ahead Logging — WAL 机制的官方说明。
- SQLite Documentation. SQLite File Format — B-Tree 页面布局的权威参考。
- SQLite Source.
src/btree.c,src/pager.c,src/wal.c— 本文分析的三个核心源文件。 - LevelDB LRU Cache 实现分析 — 本站文章,对比 Page Cache 策略。
- LSM-Tree 全景 — 本站文章,B-Tree vs LSM-Tree 设计哲学对比。
- WAL + MemTable — 本站文章,LSM-Tree 的 WAL 实现。