一个数据库引擎把数据、索引与元数据全部放进一个文件,意味着文件本身就构成一套可移植的存储格式。理解这种布局,有助于解释读写放大、文件体积增长与损坏恢复这几类行为。以下说明基于文件字节结构,具体字段偏移以官方格式文档为准。
页:文件的基本单位
文件被切分为等长的页,页是读写与缓存的基本单位。默认长度为 4096 字节,取值需为 512 到 65536 之间的 2 的幂。页从 1 开始编号,第 1 页的开头即文件头,文件头固定 100 字节,紧接其后的是第 1 页的 B 树页头。
| 偏移 | 字段 | 含义 |
|---|---|---|
| 0 | magic | 固定字符串,标识这是数据库文件 |
| 16 | page size | 每页字节数,取值 1 表示 65536 |
| 18 / 19 | write / read version | 读写版本号,取值 2 表示使用 WAL |
| 28 | page count | 数据库总页数 |
| 32 | freelist trunk | 空闲页链表头所在页号 |
| 36 | freelist count | 空闲页总数 |
| 40 | schema cookie | 模式版本,模式变更时递增 |
| 52 | largest root page | 供空间回收流程使用 |
这些字段中,schema cookie 与客户端缓存关系较为直接:建表、加列或建索引都会改变它,连接据此判断已缓存的语句结构是否需要重新编译。freelist 两个字段描述的是一条空闲页链表,删除对象后释放的整页会挂到这条链上供后续分配复用。
# 读取头部中的页大小、总页数与空闲页数
sqlite3 app.db "PRAGMA page_size; PRAGMA page_count; PRAGMA freelist_count;"
# 以字节视图查看文件头,前 16 字节为格式标识字符串
xxd -l 100 app.db
页的几种角色
页的角色由该页 B 树页头部的 1 字节类型标识决定:
- 表叶子页与表内部页
- 索引叶子页与索引内部页
- 溢出页
- 空闲页,位于 freelist 链表上
B 树页头里记录了本页空闲块链表的首地址、单元格数量、单元格内容区的起始位置以及碎片字节数。内部页比叶子页多保存一个 4 字节的右侧子节点指针。表页与索引页的差别体现在单元格载荷上:表叶子页的单元格携带 rowid,索引叶子页的单元格则是键值与对应的 rowid。
第 1 页同时是 B 树根页,因此它的可用空间要从 100 字节文件头之后算起,这一点在计算页内容量时需要注意。
单元格与空闲块
单元格按页类型有不同的布局。以表叶子页为例,单元格依次为载荷长度(变长整数)、rowid(变长整数)与载荷本身。当载荷长度超过本页可容纳的上限时,超出的部分写入溢出页,原位置只保留一段指向溢出页链的指针,溢出页自身也以 4 字节指针串成链。
空闲块同样位于页内,头部记录下一空闲块的偏移与自身长度。单元格指针数组从页首向后增长,单元格内容区从页尾向前增长,两者相向而行,中间剩余的间隙即可分配空间。碎片字节数用于统计那些过小、无法再被复用的间隙。
sqlite_master 与元数据
第 1 页是 sqlite_master(新版实现中也称 sqlite_schema)的根页,它本身就是一张表,常见列为 type、name、tbl_name、rootpage 与 sql。每个表与索引在其中占一行,rootpage 指向该对象 B 树的根页。
查询一张表时,解析器先读这张模式表取出根页号,再进入对应的 B 树。也就是说,模式信息与其他表共用同一套页缓存与文件锁机制,并没有特殊的数据结构;相关的 B 树页组织方式可参见 B+ 树索引结构。
事务日志
改动数据库前先记录原状态,是实现原子性的通用做法。多数实现提供两种方式:
- 回滚日志:修改页之前把原页内容写入带 journal 后缀的日志文件,提交时删除该文件。若进程崩溃,可用它把数据库文件恢复成事务开始前的状态。日志的写入顺序与文件系统的持久化语义相关,可参见 文件系统抽象层与虚拟列表。
- WAL:改动追加写入带 wal 后缀的日志文件,内容由 24 字节帧头与整页数据组成的帧依次排列,另有共享内存文件保存帧索引。提交即写入一帧并更新帧头,读操作按各自可见的快照读取。
两种方式对读写并发的影响不同:回滚日志下写操作会阻塞读,而 WAL 允许读写并行,代价是引入了额外的日志文件与检查点流程。
页大小的取舍
页大小影响树的高度与每次读取的字节数,两者方向相反。页较大时,单个页能容纳的单元格更多,树的分支数增加、层数减少,一次查找需要读的页数随之减少;同时每页读入的字节数增加,对小行数据的空间利用率不利。页较小时,层数增加,但页内浪费减少。
页大小在数据库创建后不再变动(除非通过导出重建),因此通常在创建时按行的平均长度确定。它也会影响单条记录可用的载荷上限与溢出页出现的频率,进而影响一次查找涉及的页数。这一点与内存页缓存的命中率相互牵连:缓存能容纳的页数有限,页越大,缓存覆盖的记录数越多,但可缓存的页数越少。
小结
单文件数据库把页作为存储与缓存单位,用文件头描述全局状态,用 B 树页、溢出页与空闲页承担不同类型的内容,把模式信息也存成一张普通表。事务的原子性由日志机制维护,回滚日志与 WAL 在读写并发上各有取舍,而页大小在创建时就确定了树的形状与单次读取的成本。