B-Tree 代码(第 4 篇的主角)从不直接调用
read() 或
write()。它只会问一句”给我页号 N
的内容”,或者”我要改页号
N,请保证这次改动之后仍然可以回滚”。回答这两句话的模块叫
Pager:它是 B-Tree
与操作系统文件之间唯一的契约层,也是第 1
篇提到的”没有跨进程共享缓冲池”这条架构约束真正落地的地方。
常见误区是把 Pager 等同于”一个 LRU 缓存加一个文件描述符”。这低估了它的职责边界:Pager 还要保证——在页面被修改之前,原始内容已经以某种形式(rollback journal 或 WAL)安全落盘,否则进程崩在提交中途,数据库就会处于既不是旧状态、也不是新状态的损坏态。本文只做四件事:
- 定位 Pager 在调用栈里的位置,说明它对 B-Tree 暴露的”取页/改页”契约。
- 拆开 page cache 命中与未命中两条路径,说明代价差在哪、本文不重复哪些既有实测数字。
- 讲清脏页从产生到落盘的生命周期,以及 Pager 与 journal/WAL 的协作边界(细节留给第 7–8 篇)。
- 用实测的
PRAGMA cache_size/journal_mode默认值,钉住”Pager 的旋钮是建议值,不是承诺值”这一条。
本文是「SQLite 内核」系列第 3 篇(共 17 篇)。→ 系列目录
篇目 核心内容 第 2 篇 · 单文件格式与页面头 Database header、页类型、cell 布局 第 3 篇 · Pager 与 Page Cache 取页/改页契约、脏页生命周期 第 4 篇 · B-Tree 遍历与分裂 表/索引 B-Tree、cell 布局、分裂
版本锚定:SQLite 3.45.x–3.46.x 官方文档(Architecture of SQLite、Atomic Commit In SQLite);源码符号以
pager.c/pcache.c(amalgamation 或拆分树)中的 Pager 相关函数为准,本文只引用函数名与职责,不引用未读的具体行号。本文不展开 rollback journal 的写时拷贝细节(第 7 篇)与 WAL 帧格式、checkpoint 策略(第 8 篇),也不重复 sqlite-billion-rows 已发布的实测吞吐数字。
一、Pager 是谁:B-Tree 与文件之间的那一层
调用链从 sqlite3_step 推进到需要读一个
B-Tree 页时,走的是 sqlite3PagerGet():B-Tree
传入页号,Pager 返回一个内存中的页对象。它内部先查 page
cache(由 pcache.c 管理的哈希表,key
是页号),命中直接返回指针;未命中才发起一次磁盘读,把结果填进缓存后再返回。B-Tree
代码不知道、也不需要知道这一页是从缓存拿的还是刚从磁盘读的——这正是”契约层”的意义:B-Tree
只面对页号和页内容,磁盘 I/O 的存在与否被 Pager 吸收掉。
flowchart LR
btree["B-Tree code"] -->|"page number"| pager["Pager"]
pager --> cache["Page cache lookup"]
cache -->|"hit"| ret["Return cached page"]
cache -->|"miss"| disk["pread from database file"]
disk --> fill["Fill cache entry"]
fill --> ret
写路径对称但多一步:B-Tree 要修改一页之前,必须先调用
sqlite3PagerWrite()
把这一页标记为”可写”。如果这是当前事务里第一次碰这一页,Pager
会在允许真正修改内存内容之前,先确保这一页的原始内容已经以某种形式记录下来(rollback
journal 模式下拷贝进 -journal 文件,WAL
模式下由后续的 WAL
写路径接管)——这一步就是第三节要讲的”改之前先保证能回滚”。
Pager 本身也不直接调用 POSIX 的
open/read/write/fsync,而是经过
Architecture of SQLite 定义的 VFS(Virtual
File System)
抽象层。这一层的存在是可移植性问题,不是本文的机制重点:同一份
Pager 逻辑在 Linux、Windows、iOS、浏览器 WASM
沙箱上分别接不同的 VFS 实现,Pager 只依赖 VFS
暴露的统一文件操作接口,不关心底层是真实文件系统还是浏览器的虚拟存储。本文后续讨论的”读磁盘”“fsync”,指的都是
Pager 通过 VFS 发出的逻辑操作,具体落到哪个系统调用由 VFS
实现决定。
二、Page
Cache:命中不触碰磁盘,未命中才 pread
站内 sqlite-billion-rows
已经用实测数据说明过这条路径的性能含义:cache
命中时整条查询可以不触发额外的系统调用,只有 cache miss
才需要一次 pread();文章同时展示了
src/pager.c + src/pcache.c
如何用哈希表把页号映射到 PgHdr
结构、sqlite3PcacheFetch()
命中时直接返回指针不涉及锁竞争。本文不重复那组吞吐数字,只强调它对应的机制边界:
- Page cache 的生命周期绑定在连接/进程上,不是像 PG 那样挂在一段所有 backend 进程共享的共享内存里(详见第五节)。
- 命中判定只看页号,不理解 B-Tree 语义上”这一页是根页还是普通叶子页”;根页更热只是因为查询天然会更频繁经过它,不是 Pager 主动区分优先级去保护。
- 缓存驱逐发生在缓存页数超过
cache_size(第六节)配置的建议值时,具体淘汰哪一页由pcache.c内部策略决定,本文不展开淘汰算法的实现细节,避免与未读源码行号绑死。
实测:重复查询的第二次命中全部落在缓存
SQLite CLI 的 .stats 元命令会打印
sqlite3_status()/sqlite3_db_status()
暴露的计数器,其中 Page cache hits 与
Page cache misses
直接对应本节的命中/未命中判定,且每次打印后计数器归零,方便看单次查询的增量。本机实测(sqlite3 3.53.2,对
第
2 篇 建的 sk-header.db,表 t
只有 1 行,涉及页 1 的 schema 记录与页 2 的表根页共 2
页):
$ sqlite3 sk-header.db
sqlite> SELECT name FROM t WHERE id=1;
a
sqlite> .stats
...
Page cache hits: 2
Page cache misses: 2
...
sqlite> SELECT name FROM t WHERE id=1;
a
sqlite> .stats
...
Page cache hits: 2
Page cache misses: 0
...
第一次查询发生在刚打开的连接上,2 次命中 2
次未命中——未命中对应页 1(schema)与页
2(表根页)首次从磁盘读入;命中对应 SQLite
内部对同一页的重复内部访问(例如游标定位过程中多次触碰同一页)。第二次对完全相同的
SELECT
再查一遍,未命中归零:两页都已经在本连接的
page cache 里,命中数原样保持
2,不再产生任何新的磁盘读取——这正是本节开头那句话的直接证据,不是从性能单篇转述的数字。
三、脏页生命周期:从标记可写到落盘
一页被 sqlite3PagerWrite()
标记为可写之后,就成为脏页(dirty
page)——内存内容与磁盘内容不一致。脏页不会立刻写回磁盘,Pager
把它继续留在 page cache
里,直到以下两个时机之一发生才落盘:
- 事务提交:
COMMIT(或隐式的单语句自动提交)触发提交流程,Pager 负责把所有脏页刷到数据库文件,并根据 journal 模式决定同步顺序与fsync/fdatasync调用次数。 - 缓存压力:脏页数量或总缓存页数逼近
cache_size建议值时,Pager 可能需要提前把某些脏页写出以腾出空间,即便事务还没提交(这时必须已经有 journal/WAL 记录保底,否则无法安全写出未提交的修改)。
flowchart TD
get["sqlite3PagerGet: fetch page"] --> write["sqlite3PagerWrite: mark writable"]
write --> firstwrite{"first write to this page in txn?"}
firstwrite -->|"yes"| save["Save original content<br/>to journal or WAL"]
firstwrite -->|"no"| dirty["Page already dirty"]
save --> dirty
dirty --> commit["Transaction commit"]
commit --> sync["Sync journal/WAL, then write pages to db file"]
这条生命周期的核心约束是“改之前先记原样”——不管走 rollback journal 还是 WAL,Pager 都要保证:如果进程在提交完成前崩溃,重启后能找到足够信息,把数据库恢复到修改前的一致状态。这正是官方 Atomic Commit In SQLite 文档定义的目标:单个事务里对若干页的修改,要么全部生效,要么全部不生效,不存在”改了一半”的可见状态。
四、Journal / WAL 协作边界:Pager 接的是同一份契约,两种实现
Pager 对上(B-Tree)暴露的是”取页 / 改页 /
提交”这一套统一接口;对下,它可以接两种不同的持久化实现,由
journal_mode 决定:
- Rollback
journal(
DELETE/TRUNCATE/PERSIST等变体):修改前把原始页面拷贝进-journal文件;提交时先fsyncjournal,再把脏页写回数据库文件本身,最后删除或截断 journal。 - WAL:修改后的页面追加写入
-wal文件,数据库文件本身在 checkpoint 之前保持不变;崩溃恢复靠重放-wal里已提交的帧。
本文只画这条边界,不展开写时拷贝的具体格式(第 7 篇)或 WAL 帧结构与 checkpoint 触发条件(第 8 篇)。需要记住的只是:Pager 是这两种日志模式共同的上层调用者,日志模式的切换不改变 B-Tree 对 Pager 的调用方式,改变的只是 Pager 内部提交时走的落盘路径。
本机默认 journal_mode
实测(sqlite3 3.53.2,未显式设置过
journal_mode 的库):
$ sqlite3 sk-header.db "PRAGMA journal_mode;"
delete
即默认走 rollback journal 的 DELETE
变体,不是 WAL——WAL 需要显式
PRAGMA journal_mode=WAL; 开启(第 8
篇实测)。
五、与 PG Buffer Pool / InnoDB Buffer Pool 对照一句
站内 PostgreSQL
Buffer Manager 与 InnoDB
Buffer Pool
都描述了一份跨执行单元共享的缓冲池:PG 的
shared_buffers 分配在共享内存里,所有 backend
进程都能看到同一份缓存;InnoDB 的 buffer pool
则是单进程内多个线程共享的内存区,用
buf_pool_mutex 之类的锁协调。
flowchart LR
subgraph pg["PostgreSQL"]
b1["Backend 1"] --> sb["shared_buffers<br/>(shared memory)"]
b2["Backend 2"] --> sb
end
subgraph sq["SQLite"]
c1["Connection A (process 1)"] --> pc1["Page cache<br/>(private heap)"]
c2["Connection B (process 2)"] --> pc2["Page cache<br/>(private heap)"]
end
SQLite 的 page cache 没有对应物:它是每个连接(进程内堆内存)私有的,两个进程打开同一个数据库文件,各自维护一份互不相通的缓存。多进程之间靠两种机制让”别的进程改过文件”这件事变得可发现:
- Rollback journal 模式:靠 database header 里的 file change counter(第 2 篇)。每次打开事务前,Pager 会比较内存里记的 change counter 与文件当前的 change counter,不一致就说明文件被别的连接改过,本地缓存必须整体作废重新读取。
- WAL 模式:靠
-shm文件里的 wal-index(第 8 篇展开),通过mmap在多进程间共享一份”哪个页最新版本在 WAL 哪个位置”的索引,不需要靠 change counter 整体失效。
一句话收束:PG/InnoDB 用跨进程共享内存换缓存复用;SQLite 用零共享换零跨进程同步开销,代价是每个连接都要自己扛一份缓存,并靠 change counter 或 wal-index 做失效检测,而不是靠共享内存直接看到最新数据。
六、cache_size
是建议旋钮,不是容量保证
PRAGMA cache_size 控制 page cache
的建议页数;正数表示页数,负数表示以 KiB
为单位的建议内存占用(除以页大小换算成建议页数)。本机实测(sqlite3 3.53.2):
$ sqlite3 :memory: "PRAGMA cache_size;"
-2000
$ sqlite3 :memory: "PRAGMA cache_size=64; PRAGMA cache_size;"
64
默认值 -2000 表示”建议大约用 2000 KiB
缓存”,不是”精确锁定 2000 KiB”。官方文档把它定义为对 Pager
的建议(advisory),不是硬性配额:Pager
可能在缓存压力下短暂超出这个建议值,也可能因为工作集小而用不到这么多。本文不给”应该设多大”的配方——那需要针对具体工作集跑内存/命中率对比实验,本系列没有做这组实测,按写作底线不写成结论;已发布的、口径明确的性能数字见
sqlite-billion-rows
的 cache_size
对照表(该文标注为既有实测,不在本文重复)。
七、常见误解
“Page cache 未命中也不一定真的读磁盘,OS 已经缓存了。”
这句话在字面上没错,但会误导对”零系统调用”这句话的理解:SQLite 默认不绕过 OS page cache(没有默认启用O_DIRECT),所以 SQLite cache miss 时发出的pread()很可能命中 OS page cache 而不真正触发磁盘 I/O——但这仍然是一次系统调用,仍有用户态/内核态切换开销,与 SQLite page cache 命中时”完全不发系统调用”是两个不同的性能台阶,不能混为一谈。“cache_size 设大一点,缓存命中率就一定上去。”
cache_size只是建议值,且只对当前连接生效;工作集如果本身就大于建议值对应的内存量,调大才有意义,调小于工作集的场景里再调大也不会有可观测收益——本文没有实测支撑具体调多大合适,不给数字结论。“两个进程打开同一个数据库文件,会共享同一份 page cache。”
不会。SQLite 的 page cache 是连接私有的堆内存,多进程之间没有共享缓冲池;能共享的只有 WAL 模式下-shm里的 wal-index(一份轻量的”页号到 WAL 偏移”索引),不是页内容本身的缓存。“脏页一旦标记,就必须等到 COMMIT 才能写盘。”
不一定。缓存压力大时 Pager 可能提前把脏页写出,只要对应的 journal/WAL 记录已经落盘、回滚路径仍然成立即可;“脏页何时落盘”和”事务何时提交”是两条相关但不等同的时间线。“Pager 只是缓存层,锁在 Pager 之上另一层管。”
不准确。锁状态机(第 9 篇)与 Pager 紧密耦合:能不能把某一页标记为脏、能不能提交,都要先问 Pager 当前持有的文件锁级别够不够(例如写事务前必须先升到 RESERVED)。Pager 不是”缓存 + 甩给上层判断锁”,它自己就是锁状态的调用点之一。
八、工程间隙与开放问题
工程间隙:官方文档描述的是 Pager 在
POSIX/Windows 文件语义上应有的行为(VFS
抽象层),但生产环境常见的两处落差文档不会主动提醒——一是
SQLite page cache 与 OS page
cache双重缓存同一份数据(除非应用自己选用支持
O_DIRECT 的
VFS),内存里可能同时存在两份物理等价但生命周期不同步的页内容,这与
PostgreSQL
Buffer Manager 里提到的 PG/OS
双缓存问题是同一类工程代价,只是双方选择披露的位置不同;二是嵌入式部署里
Pager 依赖的 fsync
语义受宿主文件系统与存储介质影响(例如某些移动闪存控制器上的
fsync 实现),这类差异不体现在 file format 或
architecture 文档里,只能在具体平台上验证。
开放问题:SQLite 长期选择”零跨进程共享缓冲池”这一设计点,换来的是零 IPC 与实现简单;第一篇已经点出”是否应引入细粒度锁”这一开放问题,Pager 层的对应问题是——在多进程频繁读写同一文件的部署下,各自私有 page cache 造成的重复内存占用与重复磁盘读,什么时候会成为比”引入共享缓冲池的复杂度”更大的代价? 官方文档与本系列都没有给出量化阈值;这是需要针对具体部署(例如同一台机器上多个进程访问同一移动端 SQLite 库)单独实测才能回答的问题,本文不代为下结论。
九、小结
- Pager 是 B-Tree
与操作系统文件之间唯一的契约层:对上暴露”取页
sqlite3PagerGet/ 改页sqlite3PagerWrite/ 提交”,对下吸收 page cache 命中判定、脏页管理与 journal/WAL 协作,B-Tree 代码不直接碰文件描述符。 - Page cache 命中不触发额外系统调用,未命中才
pread;脏页从标记可写到落盘之间必须先保证原始内容已经安全记录(journal 或 WAL),这是 Atomic Commit In SQLite 定义的”全改或不改”承诺的直接体现。 - 与 PG/InnoDB 的跨进程共享缓冲池不同,SQLite page cache
是连接私有的,多进程靠 file change counter(rollback
模式)或 wal-index(WAL
模式)检测并失效过期缓存;
cache_size只是建议旋钮,具体调多大没有本系列实测支撑的通用答案。
参考资料
规范(A 级)
- SQLite Documentation, Architecture of SQLite(sqlite.org)。
- SQLite Documentation, Atomic Commit In SQLite(sqlite.org)。
- SQLite Documentation, PRAGMA Statements —
cache_size、journal_mode(sqlite.org/pragma.html)。
源码(A 级,函数名引用,未标注行号)
- SQLite Source —
pager.c中的sqlite3PagerGet()、sqlite3PagerWrite();pcache.c中的sqlite3PcacheFetch()(amalgamation 或拆分树,3.45.x–3.53.x 均可定位到对应符号)。 - SQLite C API,
sqlite3_status()、sqlite3_db_status()— CLI.stats元命令读取的底层计数器接口。
实验(A 级,本机实测)
- 本机
sqlite3 3.53.2CLI:PRAGMA cache_size、PRAGMA journal_mode默认值与设置行为;.stats输出的Page cache hits/Page cache misses增量对比。
站内
- SQLite
是怎么做到十亿行每秒的 — page cache
命中路径实测、
cache_size对照表。 - PostgreSQL Buffer Manager、InnoDB Buffer Pool — 跨进程/跨线程共享缓冲池对照。
- 本系列 index、PLAN.md。
上一篇:单文件格式与页面头
下一篇:B-Tree
遍历与分裂
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【SQLite 内核】单文件 · Pager · B-Tree · VDBE · WAL · 锁
补齐嵌入式行存内核层:从单文件格式、Pager/B-Tree、VDBE 到 Rollback Journal/WAL、锁状态机与计划器,并以 PG/InnoDB、DuckDB、RocksDB 对照收束;承接 sqlite-billion-rows 性能叙事。
【SQLite 内核】嵌入式行存全景:单文件、单写者、零 IPC
定位单文件嵌入式行存在服务器行存与 LSM 嵌入 KV 之间的生态位;钉住 SQLite 架构约束、站内分工与 17 篇阅读路线,并以 Bayer/McCreight、官方 file format、PVLDB 2022 为学术锚点。
【SQLite 内核】与 PG / InnoDB 机制对照:进程、WAL、锁、缓冲池
用进程模型、日志、锁与缓冲池四轴对照 SQLite 与 PostgreSQL / InnoDB:说明嵌入式单文件如何删掉服务器层,以及同名 WAL、隔离、缓存概念为何不能平移运维经验;不写跨库吞吐排名。
【SQLite 内核】WAL 与 checkpoint:追加日志与读写并发
钉住 SQLite WAL 模式如何反转 rollback journal 的读写关系:原始内容留在主文件、新内容追加到 -wal,checkpoint 才把帧搬回主文件;用本机 3.53.2 实测 journal_mode=WAL 切换与 passive/truncate checkpoint 的真实帧数,读放大与 checkpoint 频率的取舍留作开放问题。