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

【SQLite 内核】单文件格式与页面头

文章导航

分类入口
databasestorage
标签入口
#sqlite#file-format#database-header#page-header#btree#cell-pointer-array

源码下载

本文相关源码已整理,共 1 个文件。

打开下载目录 →

目录

打开一个 .db 文件,前 100 个字节里藏着页大小、写/读版本、schema cookie、编码方式;第 101 个字节开始,是页面 1 自己的 B-Tree 页头。这不是实现细节的偶然堆砌——它是 SQLite 把整个数据库塞进一个可以直接 cpscp、塞进 APK 的普通文件这一约束的直接后果:没有独立的元数据服务,所有自描述信息都要压进文件本身的固定偏移量里。

常见误区是把这层格式当成”读一次就忘”的背景知识:page size 以为能随时改、page 1 以为是随便一个表的根页、cell pointer array 以为是定长分区。这些误解会在后续篇章(B-Tree 分裂、Pager 脏页、损坏恢复)里逐个反噬。本文只做三件事:

  1. 用官方 File Format For SQLite Databases 规范逐字段拆开 100 字节 database header。
  2. 拆开 B-Tree 页面头的 4 种页类型标志,交代 cell pointer array 与 content area 相向增长的机制。
  3. 用本机 SQLite 3.53.2 CLI 实测 hexdump,核对 magic、page size、页 1 首字节这三个可验证的字段。

本文是「SQLite 内核」系列第 2 篇(共 17 篇)。→ 系列目录

篇目 核心内容
第 1 篇 · 嵌入式行存全景 生态位、架构约束、系列路线
第 2 篇 · 单文件格式与页面头 Database header、页类型、cell 布局
第 3 篇 · Pager 与 Page Cache 读改写契约与脏页

版本锚定:官方 File Format For SQLite Databases(sqlite.org/fileformat2.html);该格式自 SQLite 3.0(2004 年)起未发生不兼容变更,本系列版本锚点 3.45.x–3.46.x 与本文实测所用的本机 SQLite 3.53.2 CLI 在本文讨论的字段上一致。本文不覆盖 WAL 帧格式(第 8 篇)与损坏修复(第 14 篇)。


一、一个文件,从页 1 数到最后一页

主数据库永远是一个文件(外加运行期可能存在的 -journal-wal/-shm 附属文件,第 7–8 篇展开)。文件被切成大小相同的页(page),页大小必须是 2 的整数次幂,范围 512 到 65536 字节;页从 1 开始编号,不存在页 0。

页大小写在 database header 偏移 16 处的 2 字节里,正常情况下直接是数值(例如 0x1000 = 4096)。但 2 字节最多表示到 65535,装不下 65536,所以规范留了一个特例:当这 2 字节的值恰好是 1 时,代表页大小是 65536,不是”1 字节”。这是 file format 里少数需要死记的编码陷阱,也是后文误解 1 的来源。

flowchart LR
  F["Database file"] --> P1["Page 1<br/>100-byte header + sqlite_schema root"]
  F --> PN["Page 2 .. N<br/>table / index B-Tree, freelist, overflow"]
  P1 -->|"same page_size bytes"| PN

页大小一旦写入数据库文件就基本固定:官方 page_size PRAGMA 文档规定,只有在数据库为空(还没写入任何行)时设置才会生效;已有数据的库要改页大小,必须配合 VACUUM 重建整个文件。这不是运维习惯问题,而是文件格式本身没有”混合页大小”的概念——每一页在磁盘上的物理偏移量都是 (页号 − 1) × page_size,页大小只能是文件级的单一常量。


二、Database Header:前 100 字节逐字段拆

File Format For SQLite Databases 把文件最开头的 100 字节定义为固定布局的 header,只出现在页 1 里,其余任何页都不含这段结构。字段表(官方规范,A 级):

偏移 大小 字段 含义
0 16 header string 固定字符串 "SQLite format 3\0"
16 2 page size 页大小;1 代表 65536
18 1 write version 文件格式写版本(1=legacy, 2=WAL)
19 1 read version 文件格式读版本
20 1 reserved space 每页末尾预留字节数
21 1 max payload fraction 固定为 64
22 1 min payload fraction 固定为 32
23 1 leaf payload fraction 固定为 32
24 4 file change counter 每次写事务提交后递增
28 4 size in pages 数据库文件的页数(header 内记录值)
32 4 freelist trunk page 第一个 freelist trunk 页号,0 表示无
36 4 freelist page count freelist 总页数
40 4 schema cookie 每次 schema(DDL)变更后递增
44 4 schema format 支持值为 1、2、3、4
48 4 default cache size 建议缓存页数,通常为 0(未设置)
52 4 largest root btree 仅 auto/incremental vacuum 模式使用
56 4 text encoding 1=UTF-8,2=UTF-16le,3=UTF-16be
60 4 user version 供应用自定义,PRAGMA user_version
64 4 incremental vacuum 非零表示开启 incremental-vacuum 模式
68 4 application ID PRAGMA application_id
72 20 reserved 必须为 0,留给未来扩展
92 4 version-valid-for 写入该值时的 SQLite 版本号
96 4 SQLite version number 最近一次写入该文件的版本号

file change counter 和 schema cookie 是两个独立计数器,容易混为一谈:change counter 记录”写事务提交了多少次”,任何一次 INSERT/UPDATE/COMMIT 都会让它加 1;schema cookie 只在 CREATE TABLE/DROP INDEX 等改表结构的 DDL 之后才加 1。下一节的实测会同时验证这两个字段。


三、B-Tree 页面头:4 种类型与相向增长

除了 database header,每一页(包括页 1,紧跟在 100 字节之后)如果是 B-Tree 页面,还带一段自己的页头。页头第一个字节是页类型标志,官方规范只定义 4 个合法值:

flowchart TD
  Flag["Byte 0 of B-Tree page header"] -->|"0x0d (13)"| LT["Leaf table B-Tree"]
  Flag -->|"0x05 (5)"| IT["Interior table B-Tree"]
  Flag -->|"0x0a (10)"| LI["Leaf index B-Tree"]
  Flag -->|"0x02 (2)"| II["Interior index B-Tree"]
  IT -->|"+4 bytes"| RC["Right-most child pointer"]
  II -->|"+4 bytes"| RC

页头其余字段(叶子页共 8 字节,内部页因为多出右子指针共 12 字节):

偏移(页头内) 大小 字段
0 1 页类型标志
1 2 第一个 freeblock 的偏移,0 表示无空闲块
3 2 本页 cell 数量
5 2 cell content area 起始偏移
7 1 fragmented free bytes 数量
8 4 右子页页号(仅 interior 页有此字段)

页头之后紧跟cell pointer array:每个 cell 对应数组里的一个 2 字节偏移量,按 key 排序,指向该 cell 在页内的真实存储位置。这个数组从页头之后向页尾方向增长;而 cell 的实际内容从页尾往页头方向增长,两者中间是尚未分配的空闲区:

flowchart TB
  subgraph page["One page (page_size bytes)"]
    direction TB
    hdr["B-Tree page header (8 or 12 bytes)"]
    cpa["Cell pointer array<br/>appends downward on insert"]
    gap["Unallocated space<br/>shrinks on insert"]
    cca["Cell content area<br/>appends upward on insert"]
  end

这个设计的直接后果:插入一条新 cell 不需要移动页内任何已有数据——在 pointer array 尾部追加一个指针,在 content area 顶部写入新内容,逻辑顺序完全由 pointer array 的排列维护,物理存储可以保持乱序。完整字节级图示见 SQLite B-Tree 页面布局(性能单篇已绘制,本文不重复画同一张图)。

Page 1 因为前面挤了 100 字节 database header,它的 B-Tree 页头从文件偏移 100开始,而不是像其他页一样从页起始处开始——page 1 可用的 cell content area 因此比同大小的其他页少 100 字节。


四、Page 1 的特殊性:header 与 sqlite_schema 共享同一页

Page 1 同时承担两个角色:它是 database header 的宿主,也是 sqlite_schema(历史名 sqlite_master)表自己的 B-Tree 根页。这不是约定,而是硬编码的规则:任何 SQLite 数据库文件,page 1 恒定是 schema 表的根,不存在”page 1 是普通用户表”的库。

用户表和索引的根页号则记录在 sqlite_schema 的行里,第一个建的表通常落在 page 2,但这只是”最先申请到的空页恰好是 2”,不是规范保证的固定值——多建几个表、删几个表之后,根页号会随空间分配变化。


五、Lock-byte page、freelist、overflow:三处边界,一句带过

这三处结构本文只画边界,细节留给后续篇章或不在系列展开范围内:


六、实测:hexdump 核对 header 与页 1 页头

环境:Linux/WSL2,sqlite3 3.53.2 2026-06-03。步骤见 reproduce/02-header-explain.sh:新建一个 4096 字节页大小的数据库,建一张表并插入一行,然后 hexdump 前 100 字节与 page 1 的 B-Tree 页头。

sqlite3 "$DB" <<'SQL'
PRAGMA page_size=4096;
CREATE TABLE t(id INTEGER PRIMARY KEY, name TEXT);
INSERT INTO t VALUES(1,'a');
SQL

PRAGMA 读回结果(page_size / encoding / freelist_count):

4096
UTF-8
0

Database header 前 100 字节(以下输出经删减,去掉了 locale 警告):

00000000: 5351 4c69 7465 2066 6f72 6d61 7420 3300  SQLite format 3.
00000010: 1000 0101 0040 2020 0000 0002 0000 0002  .....@  ........
00000020: 0000 0000 0000 0000 0000 0001 0000 0004  ................
00000030: 0000 0000 0000 0000 0000 0001 0000 0000  ................
00000040: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000050: 0000 0000 0000 0000 0000 0000 0000 0002  ................
00000060: 002e 95ca                                ....

Page 1 的 B-Tree 页头(文件偏移 100 开始):

00000064: 0d00 0000 010f bf00 0fbf 0000            ............

逐项核对,全部与第二、三节的字段表吻合:

字段 十六进制 解读
offset 0 5351 4c69 7465 2066 6f72 6d61 7420 3300 SQLite format 3\0,magic 正确
offset 16 10 00 0x1000 = 4096,与 PRAGMA page_size 读回值一致
offset 18–19 01 01 write/read version 均为 1(legacy,未启用 WAL)
offset 24–27 0000 0002 file change counter = 2,对应 CREATE TABLE + INSERT 两次提交
offset 28–31 0000 0002 文件共 2 页:page 1(schema)+ page 2(表 t 的根页)
offset 40–43 0000 0001 schema cookie = 1,对应恰好 1 次 DDL(CREATE TABLE
offset 56–59 0000 0001 编码 = 1(UTF-8),与 PRAGMA encoding 一致
offset 96–99 00 2e 95 ca = 3053002 版本号 3053002 = 3×1000000+53×1000+2,即 3.53.2
page 1 header byte 0 0d 页类型 = leaf table B-Tree(sqlite_schema 恰好只需要 1 个叶子页)
page 1 header byte 3–4 00 01 1 个 cell,对应 sqlite_schema 里那 1 行 CREATE TABLE 记录
page 1 header byte 5–6 0f bf = 4031 cell content area 从偏移 4031 开始,离页尾还有 65 字节存这条 schema 记录

EXPLAIN SELECT name FROM t WHERE id=1 的第一条指令是 OpenRead 0 2 0 2 … root=2——直接证实表 t 的根页是 2,不是 1,呼应第四节”page 1 恒为 schema 根,不是用户表根”的结论。

change counter=2、schema cookie=1 这组数字不是巧合:本文只执行了一次 CREATE TABLE(1 次 DDL)和一次 INSERT(1 次写事务提交),加上 CREATE TABLE 本身也算一次写事务提交,两次提交对应 change counter=2;CREATE TABLE 是仅有的一次 schema 改动,对应 schema cookie=1。


七、常见误解

  1. “page size 可以是任意整数字节数”
    必须是 2 的整数次幂,范围 512–65536;65536 因为 2 字节字段装不下,用特殊值 1 表示。写 PRAGMA page_size=5000 之类非法值会在下次真正写盘时被规整或报错,不会得到”5000 字节页”。

  2. “数据库文件第一页就是我第一个建的表”
    Page 1 恒定是 sqlite_schema 的根页,任何用户表的根页号都记录在 schema 行里,通常从 2 开始但不是规范保证的固定值。

  3. “cell pointer array 和 content area 是两块预先划好的定长分区”
    两者其实相向动态增长:pointer array 从页头之后往下追加,content area 从页尾往上追加,插入操作不移动已有 cell,只是让中间的空闲区收缩。

  4. “改一下 PRAGMA page_size 就能就地生效”
    只有空库(尚无行数据)设置才立即生效;已有数据的库要改页大小必须配合 VACUUM 重建整个文件,因为文件里每一页的物理偏移量都由单一的 page_size 常量决定,不存在混合页大小。


八、学术谱系与工程间隙

页式 B-Tree 的问题定义来自 Bayer, R. & McCreight, E. Organization and Maintenance of Large Ordered Indexes(Acta Informatica, 1972)——论文关注的是外存上有序索引的读写复杂度下界,没有规定任何具体的字节布局。SQLite 的 file format 文档把这个抽象数据结构落成了一份可以逐字节核对的工程规范:cell pointer array 相向增长、payload fraction 固定为 64/32/32、overflow 阈值保证叶子至少 4 个 cell,这些都是论文里不存在、纯粹为磁盘页对齐与就地插入服务的工程决策。

工程间隙集中在一点:论文假设的是”有序索引”这一抽象问题,不关心文件格式必须自描述、可被独立工具(sqlite3 CLI、file 命令、取证工具)识别版本与编码这一约束。100 字节 header 里 write/read version、schema format、application ID 这几个字段,都是为了让文件在没有外部元数据的情况下仍能被正确识别和迁移——这是嵌入式单文件场景特有的负担,服务器行存(PG/InnoDB 把元数据放在系统表和独立的控制文件里)不需要在数据文件头部背这么多自描述信息。


九、小结

  1. 数据库是一个页大小固定、从页 1 编号的单文件;前 100 字节是 database header,记录 magic、page size、change counter、schema cookie 等自描述字段,且这份布局自 SQLite 3.0 起保持稳定。
  2. B-Tree 页面头用 1 个字节区分 4 种页类型(叶/内部 × 表/索引),cell pointer array 与 cell content area 相向增长,插入不移动已有数据;page 1 额外承担 sqlite_schema 根页的角色,比其他页少 100 字节可用空间。
  3. 学术锚点是 Bayer & McCreight(1972,问题定义)与官方 file format 规范(工程字节布局);本文用 3.53.2 CLI 的 hexdump 实测核对了 magic、page size、change counter、schema cookie 与页 1 页类型,全部与规范和 EXPLAIN 输出互相印证。

参考资料

规范(A 级)

  1. SQLite Documentation, File Format For SQLite Databases(sqlite.org/fileformat2.html)。
  2. SQLite Documentation, PRAGMA Statementspage_sizeuser_versionapplication_idfreelist_count(sqlite.org/pragma.html)。

论文(A 级)

  1. Bayer, R. & McCreight, E. Organization and Maintenance of Large Ordered Indexes. Acta Informatica, 1972。

实验(A 级,本机实测)

  1. 本机 sqlite3 3.53.2 CLI 执行结果,脚本见 reproduce/02-header-explain.sh

站内

  1. SQLite B-Tree 页面布局(性能单篇 sqlite-billion-rows 已绘制的字节级图示)。
  2. 本系列 indexPLAN.md

上一篇嵌入式行存全景
下一篇Pager 与 Page Cache

同主题继续阅读

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

2026-07-18 · database / storage

【SQLite 内核】索引与 covering scan:什么时候真的省掉了第二次查找

钉住 covering index 消除的具体开销:第 4 篇 index b-tree 只存 key+rowid,命中后仍要回表这一次二次查找;用本机 3.53.2 实测 SELECT 列是否全部落在索引内如何改变 EQP 输出,并区分语句级自动索引与 sqlite_autoindex_* 约束索引这两个常被混淆的概念。


By .