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

【SQLite 内核】Pager 与 Page Cache

文章导航

分类入口
databasestorage
标签入口
#sqlite#pager#page-cache#dirty-page#buffer-pool#journal#wal

目录

B-Tree 代码(第 4 篇的主角)从不直接调用 read()write()。它只会问一句”给我页号 N 的内容”,或者”我要改页号 N,请保证这次改动之后仍然可以回滚”。回答这两句话的模块叫 Pager:它是 B-Tree 与操作系统文件之间唯一的契约层,也是第 1 篇提到的”没有跨进程共享缓冲池”这条架构约束真正落地的地方。

常见误区是把 Pager 等同于”一个 LRU 缓存加一个文件描述符”。这低估了它的职责边界:Pager 还要保证——在页面被修改之前,原始内容已经以某种形式(rollback journal 或 WAL)安全落盘,否则进程崩在提交中途,数据库就会处于既不是旧状态、也不是新状态的损坏态。本文只做四件事:

  1. 定位 Pager 在调用栈里的位置,说明它对 B-Tree 暴露的”取页/改页”契约。
  2. 拆开 page cache 命中与未命中两条路径,说明代价差在哪、本文不重复哪些既有实测数字。
  3. 讲清脏页从产生到落盘的生命周期,以及 Pager 与 journal/WAL 的协作边界(细节留给第 7–8 篇)。
  4. 用实测的 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 SQLiteAtomic 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() 命中时直接返回指针不涉及锁竞争。本文不重复那组吞吐数字,只强调它对应的机制边界:

实测:重复查询的第二次命中全部落在缓存

SQLite CLI 的 .stats 元命令会打印 sqlite3_status()/sqlite3_db_status() 暴露的计数器,其中 Page cache hitsPage 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 里,直到以下两个时机之一发生才落盘:

  1. 事务提交COMMIT(或隐式的单语句自动提交)触发提交流程,Pager 负责把所有脏页刷到数据库文件,并根据 journal 模式决定同步顺序与 fsync/fdatasync 调用次数。
  2. 缓存压力:脏页数量或总缓存页数逼近 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 决定:

本文只画这条边界,不展开写时拷贝的具体格式(第 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 ManagerInnoDB 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 没有对应物:它是每个连接(进程内堆内存)私有的,两个进程打开同一个数据库文件,各自维护一份互不相通的缓存。多进程之间靠两种机制让”别的进程改过文件”这件事变得可发现:

一句话收束: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-rowscache_size 对照表(该文标注为既有实测,不在本文重复)。


七、常见误解

  1. “Page cache 未命中也不一定真的读磁盘,OS 已经缓存了。”
    这句话在字面上没错,但会误导对”零系统调用”这句话的理解:SQLite 默认不绕过 OS page cache(没有默认启用 O_DIRECT),所以 SQLite cache miss 时发出的 pread() 很可能命中 OS page cache 而不真正触发磁盘 I/O——但这仍然是一次系统调用,仍有用户态/内核态切换开销,与 SQLite page cache 命中时”完全不发系统调用”是两个不同的性能台阶,不能混为一谈。

  2. “cache_size 设大一点,缓存命中率就一定上去。”
    cache_size 只是建议值,且只对当前连接生效;工作集如果本身就大于建议值对应的内存量,调大才有意义,调小于工作集的场景里再调大也不会有可观测收益——本文没有实测支撑具体调多大合适,不给数字结论。

  3. “两个进程打开同一个数据库文件,会共享同一份 page cache。”
    不会。SQLite 的 page cache 是连接私有的堆内存,多进程之间没有共享缓冲池;能共享的只有 WAL 模式下 -shm 里的 wal-index(一份轻量的”页号到 WAL 偏移”索引),不是页内容本身的缓存。

  4. “脏页一旦标记,就必须等到 COMMIT 才能写盘。”
    不一定。缓存压力大时 Pager 可能提前把脏页写出,只要对应的 journal/WAL 记录已经落盘、回滚路径仍然成立即可;“脏页何时落盘”和”事务何时提交”是两条相关但不等同的时间线。

  5. “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 库)单独实测才能回答的问题,本文不代为下结论。


九、小结

  1. Pager 是 B-Tree 与操作系统文件之间唯一的契约层:对上暴露”取页 sqlite3PagerGet / 改页 sqlite3PagerWrite / 提交”,对下吸收 page cache 命中判定、脏页管理与 journal/WAL 协作,B-Tree 代码不直接碰文件描述符。
  2. Page cache 命中不触发额外系统调用,未命中才 pread;脏页从标记可写到落盘之间必须先保证原始内容已经安全记录(journal 或 WAL),这是 Atomic Commit In SQLite 定义的”全改或不改”承诺的直接体现。
  3. 与 PG/InnoDB 的跨进程共享缓冲池不同,SQLite page cache 是连接私有的,多进程靠 file change counter(rollback 模式)或 wal-index(WAL 模式)检测并失效过期缓存;cache_size 只是建议旋钮,具体调多大没有本系列实测支撑的通用答案。

参考资料

规范(A 级)

  1. SQLite Documentation, Architecture of SQLite(sqlite.org)。
  2. SQLite Documentation, Atomic Commit In SQLite(sqlite.org)。
  3. SQLite Documentation, PRAGMA Statementscache_sizejournal_mode(sqlite.org/pragma.html)。

源码(A 级,函数名引用,未标注行号)

  1. SQLite Source — pager.c 中的 sqlite3PagerGet()sqlite3PagerWrite()pcache.c 中的 sqlite3PcacheFetch()(amalgamation 或拆分树,3.45.x–3.53.x 均可定位到对应符号)。
  2. SQLite C API, sqlite3_status()sqlite3_db_status() — CLI .stats 元命令读取的底层计数器接口。

实验(A 级,本机实测)

  1. 本机 sqlite3 3.53.2 CLI:PRAGMA cache_sizePRAGMA journal_mode 默认值与设置行为;.stats 输出的 Page cache hits/Page cache misses 增量对比。

站内

  1. SQLite 是怎么做到十亿行每秒的 — page cache 命中路径实测、cache_size 对照表。
  2. PostgreSQL Buffer ManagerInnoDB Buffer Pool — 跨进程/跨线程共享缓冲池对照。
  3. 本系列 indexPLAN.md

上一篇单文件格式与页面头
下一篇B-Tree 遍历与分裂

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-07-18 · database / storage

【SQLite 内核】WAL 与 checkpoint:追加日志与读写并发

钉住 SQLite WAL 模式如何反转 rollback journal 的读写关系:原始内容留在主文件、新内容追加到 -wal,checkpoint 才把帧搬回主文件;用本机 3.53.2 实测 journal_mode=WAL 切换与 passive/truncate checkpoint 的真实帧数,读放大与 checkpoint 频率的取舍留作开放问题。


By .