打开一个 .db 文件,前 100
个字节里藏着页大小、写/读版本、schema cookie、编码方式;第
101 个字节开始,是页面 1 自己的 B-Tree
页头。这不是实现细节的偶然堆砌——它是 SQLite
把整个数据库塞进一个可以直接
cp、scp、塞进 APK
的普通文件这一约束的直接后果:没有独立的元数据服务,所有自描述信息都要压进文件本身的固定偏移量里。
常见误区是把这层格式当成”读一次就忘”的背景知识:page size 以为能随时改、page 1 以为是随便一个表的根页、cell pointer array 以为是定长分区。这些误解会在后续篇章(B-Tree 分裂、Pager 脏页、损坏恢复)里逐个反噬。本文只做三件事:
- 用官方 File Format For SQLite Databases 规范逐字段拆开 100 字节 database header。
- 拆开 B-Tree 页面头的 4 种页类型标志,交代 cell pointer array 与 content area 相向增长的机制。
- 用本机 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:三处边界,一句带过
这三处结构本文只画边界,细节留给后续篇章或不在系列展开范围内:
- Lock-byte page:文件字节偏移
0x40000000(1GiB 处)开始的一整页,在支持它的操作系统上用作跨进程锁的占位区,从不存储实际内容;只有数据库文件超过 1GiB 时才会出现。 - Freelist:
DELETE释放的页不会立刻缩小文件,而是挂进 freelist(trunk 页 + leaf 页的链表),页号记在 database header 偏移 32–39;第 14 篇讨论损坏与空洞时会回到这里。 - Overflow 页:一条记录如果太大装不进单个页,多出的部分会溢出到 overflow 页链,每个 overflow 页开头 4 字节是下一个 overflow 页的页号(0 表示链尾);overflow 阈值的计算保证叶子页至少能容纳 4 个 cell,避免 B-Tree 退化。B-Tree 分裂路径与 cell 布局的完整讨论见第 4 篇。
六、实测: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');
SQLPRAGMA 读回结果(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。
七、常见误解
“page size 可以是任意整数字节数”
必须是 2 的整数次幂,范围 512–65536;65536 因为 2 字节字段装不下,用特殊值1表示。写PRAGMA page_size=5000之类非法值会在下次真正写盘时被规整或报错,不会得到”5000 字节页”。“数据库文件第一页就是我第一个建的表”
Page 1 恒定是sqlite_schema的根页,任何用户表的根页号都记录在 schema 行里,通常从 2 开始但不是规范保证的固定值。“cell pointer array 和 content area 是两块预先划好的定长分区”
两者其实相向动态增长:pointer array 从页头之后往下追加,content area 从页尾往上追加,插入操作不移动已有 cell,只是让中间的空闲区收缩。“改一下
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 编号的单文件;前 100 字节是 database header,记录 magic、page size、change counter、schema cookie 等自描述字段,且这份布局自 SQLite 3.0 起保持稳定。
- B-Tree 页面头用 1 个字节区分 4 种页类型(叶/内部 ×
表/索引),cell pointer array 与 cell content area
相向增长,插入不移动已有数据;page 1 额外承担
sqlite_schema根页的角色,比其他页少 100 字节可用空间。 - 学术锚点是 Bayer & McCreight(1972,问题定义)与官方
file format 规范(工程字节布局);本文用 3.53.2 CLI 的
hexdump 实测核对了 magic、page size、change counter、schema
cookie 与页 1 页类型,全部与规范和
EXPLAIN输出互相印证。
参考资料
规范(A 级)
- SQLite Documentation, File Format For SQLite Databases(sqlite.org/fileformat2.html)。
- SQLite Documentation, PRAGMA Statements —
page_size、user_version、application_id、freelist_count(sqlite.org/pragma.html)。
论文(A 级)
- Bayer, R. & McCreight, E. Organization and Maintenance of Large Ordered Indexes. Acta Informatica, 1972。
实验(A 级,本机实测)
- 本机
sqlite3 3.53.2CLI 执行结果,脚本见reproduce/02-header-explain.sh。
站内
- SQLite B-Tree 页面布局(性能单篇 sqlite-billion-rows 已绘制的字节级图示)。
- 本系列 index、PLAN.md。
上一篇:嵌入式行存全景
下一篇:Pager 与 Page
Cache
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【SQLite 内核】嵌入式行存全景:单文件、单写者、零 IPC
定位单文件嵌入式行存在服务器行存与 LSM 嵌入 KV 之间的生态位;钉住 SQLite 架构约束、站内分工与 17 篇阅读路线,并以 Bayer/McCreight、官方 file format、PVLDB 2022 为学术锚点。
【SQLite 内核】B-Tree 遍历与分裂:表 B-Tree、索引 B-Tree 与 cell 布局
钉住 SQLite table b-tree 与 index b-tree 的字节布局差异、cell pointer array 如何做到逻辑有序物理可乱序、overflow 阈值与 balance() 分裂/合并触发条件;源码以 btree.c 函数签名为准,学术锚点是 Bayer & McCreight 1972。
【SQLite 内核】单文件 · Pager · B-Tree · VDBE · WAL · 锁
补齐嵌入式行存内核层:从单文件格式、Pager/B-Tree、VDBE 到 Rollback Journal/WAL、锁状态机与计划器,并以 PG/InnoDB、DuckDB、RocksDB 对照收束;承接 sqlite-billion-rows 性能叙事。
【SQLite 内核】索引与 covering scan:什么时候真的省掉了第二次查找
钉住 covering index 消除的具体开销:第 4 篇 index b-tree 只存 key+rowid,命中后仍要回表这一次二次查找;用本机 3.53.2 实测 SELECT 列是否全部落在索引内如何改变 EQP 输出,并区分语句级自动索引与 sqlite_autoindex_* 约束索引这两个常被混淆的概念。