数据库基础体系 · 第 31/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
SQLite 架构与文件格式:嵌入式数据库、Pager、B-tree 和 VFS
SQLite 常被描述为“一个数据库文件”,但这个说法只描述了部署形态的一部分。SQLite 实际上是一组运行在应用进程内的数据库引擎代码,以及一套定义数据库文件如何组织、如何读写和如何保证事务原子性的分层架构。
理解 SQLite,至少需要同时回答四个问题:
- SQL 语句如何在进程内执行?
- 表和索引如何组织成 B-tree?
- B-tree 修改如何映射到固定大小的数据库页?
- 数据库页如何通过文件、内存文件或其他存储后端持久化?
这四个问题分别对应 SQLite 的执行层、B-tree 层、Pager 层和 VFS 层。文件格式则把其中一部分内部数据结构固定下来,使不同版本、不同语言绑定和不同操作系统上的 SQLite 可以交换数据库文件。
一、SQLite 的嵌入式模型
1.1 SQLite 没有独立数据库服务器
使用 PostgreSQL 或 MySQL 时,应用通常通过网络协议连接到一个独立服务器:
应用进程 ── TCP/Unix Socket ── 数据库服务器进程 ── 数据文件
SQLite 的典型结构是:
应用进程
└── SQLite 库
├── SQL 编译器
├── VDBE 虚拟机
├── B-tree
├── Pager
└── VFS ── 数据库文件
SQLite 库直接链接进应用进程。执行查询时:
- SQL 文本由应用传给 SQLite;
- SQLite 在当前进程内解析和编译;
- VDBE 在当前线程或调用上下文中执行字节码;
- B-tree 访问表和索引;
- Pager 管理数据库页;
- VFS 执行最终的文件打开、读、写、加锁和同步。
因此,“嵌入式”主要表示数据库引擎与应用在同一进程内运行,并不表示 SQLite 只能用于单用户程序,也不表示它没有事务、锁和并发控制。
1.2 一个连接、一个进程、一个数据库文件不是同一回事
需要区分三个概念:
- 数据库连接:由
sqlite3_open()等 API 创建的 SQLite 会话对象。 - 应用进程:加载 SQLite 库的操作系统进程。
- 数据库文件:保存主数据库内容的文件,通常以
.db或.sqlite结尾。
一个进程可以创建多个连接;多个进程也可以打开同一个数据库文件。SQLite 支持多个读取者并存,并在合适的事务模式下允许一个写入者修改数据库。
但 SQLite 的并发模型仍然不同于服务器型数据库:
- 锁通常作用于整个数据库文件,而不是单个行;
- 同一时刻通常只有一个写事务可以提交;
- 写事务持续时间越长,越容易阻塞其他写入;
- 数据库文件所在文件系统必须正确实现 SQLite 依赖的读写、锁和同步语义。
SQLite 的“嵌入式”优势是部署简单、调用路径短、数据可以直接作为文件管理;代价是连接池、权限隔离、远程连接、进程级资源管理等职责更多地由应用负责。
二、SQLite 的整体架构
SQLite 官方架构通常可以抽象为以下层次:
SQL 接口
↓
SQL 编译器
├── Parser
├── Code Generator
└── VDBE 字节码
↓
存储引擎
└── B-tree
↓
Pager
├── 页面缓存
├── 事务与回滚
├── 数据库锁
└── 日志管理
↓
OS Interface
└── VFS
↓
操作系统文件、内存、网络或自定义存储
这不是简单的“从上到下调用一次就结束”。一次 SELECT 或 UPDATE 往往会在这些层之间反复往返。
2.1 SQL 编译器和 VDBE
SQL 编译器负责把 SQL 文本转换为 VDBE 字节码。VDBE 是 SQLite 的虚拟数据库引擎,可以理解为一个面向数据库操作的虚拟机。
例如:
SELECT name FROM user WHERE id = 10;
编译后的执行计划需要表达类似以下逻辑:
- 打开
user表或索引; - 定位键值
10; - 从记录中取出
name字段; - 把结果返回给调用者;
- 关闭游标。
VDBE 不直接处理磁盘文件。它通过 B-tree 游标访问表和索引,B-tree 再通过 Pager 获取页面。
这一区分很重要:SQL 执行计划说的是“访问哪个表、哪个索引、以什么顺序访问”,而 Pager 关心的是“页面是否在缓存中、是否需要从文件读取、是否已经被修改、事务提交时怎样保证持久性”。
2.2 B-tree 层
B-tree 层把关系数据库中的表和索引表示为树结构。
SQLite 中主要有两类 B-tree:
- 表 B-tree:保存表记录,键通常是
rowid; - 索引 B-tree:保存索引键和对应的记录定位信息。
普通 rowid 表的逻辑结构可以表示为:
表 B-tree
├── 内部页
│ ├── 子树指针
│ └── 分隔键
└── 叶子页
├── rowid
└── 记录内容
索引 B-tree 的叶子记录则主要保存索引键及其关联的 rowid,或者在 WITHOUT ROWID 表中保存主键和整行数据的组织形式。
B-tree 层不负责操作系统文件,也不负责决定一个页面是否已经安全写入磁盘。它通过 Pager 请求“读取第 N 页”“写入第 N 页”“创建保存点”等操作。
2.3 Pager 层
Pager 是 SQLite 存储层中的关键边界。它把 B-tree 看到的“数据库页”转换为具有事务语义的页面缓存。
Pager 主要负责:
- 按页读取和缓存数据库内容;
- 跟踪页面是否被修改;
- 管理读事务和写事务;
- 处理回滚日志或 WAL 相关的页面可见性;
- 在提交过程中保证原子性;
- 在异常中止后恢复到一致状态;
- 请求和释放文件锁;
- 调用 VFS 完成底层 I/O。
B-tree 关心“第 7 页是一棵树的哪部分”;Pager 关心“第 7 页的旧版本在哪里、新版本是否已经落盘、事务回滚时应恢复哪个版本”。
2.4 VFS 层
VFS 是 Virtual File System 的缩写,即虚拟文件系统接口。它是 SQLite 与操作系统环境之间的抽象层。
VFS 不只是包装 read() 和 write()。它还涉及:
- 打开和关闭文件;
- 读取、写入和截断文件;
- 获取文件大小;
- 文件同步;
- 文件锁定;
- 删除文件;
- 获取当前时间;
- 生成随机数;
- 动态加载扩展;
- 获取系统相关信息。
SQLite 可以使用系统自带的 VFS,也可以注册自定义 VFS。例如,某些嵌入式设备可能希望:
- 把数据库保存到特殊块设备;
- 把文件映射到对象存储适配层;
- 使用定制的锁实现;
- 将数据库放在内存中;
- 为测试构造可控的故障注入环境。
但是,自定义 VFS 必须正确实现 SQLite 依赖的语义。仅仅把 xRead() 和 xWrite() 接上,并不能构成安全的 SQLite 存储后端;锁、同步、短读写、文件大小变化和故障恢复同样重要。
三、一次查询和一次更新如何穿过这些层
3.1 查询路径
以下查询:
SELECT name FROM user WHERE id = 10;
可能经过如下步骤:
- SQL 接口接收 SQL 字符串。
- 解析器生成语法树。
- 代码生成器决定使用表扫描或索引查找。
- VDBE开始执行字节码。
- VDBE 打开一个 B-tree 游标。
- B-tree从根页面开始比较键值。
- B-tree 请求 Pager 提供相应页面。
- Pager先查页面缓存;缓存未命中时,通过 VFS 读取数据库文件的对应偏移。
- B-tree 根据页面中的单元格指针继续向子页面移动。
- 找到记录后,SQLite 解码记录格式,提取
name字段。 - VDBE 把结果返回给应用。
如果数据库页大小为 4096 字节,读取第 7 页时,主数据库文件中的基础偏移大致为:
这里页号从 1 开始,第一页从文件偏移 0 开始。实际是否直接读取这个位置,还取决于事务模式、页面缓存和 WAL 状态。
3.2 更新路径
执行:
UPDATE user SET name = 'Bob' WHERE id = 10;
大致会经历:
- 编译 SQL,决定如何定位
id = 10; - 读取包含目标记录的 B-tree 页面;
- 在 Pager 中建立写事务;
- 修改内存中的页面副本;
- 如果索引键或表结构受影响,修改对应的索引 B-tree 页面;
- 记录必要的旧页面内容,或者把新页面写入 WAL;
- 提交事务;
- 通过 VFS 执行写入和同步;
- 释放锁或更新 WAL 状态。
这里有一个常见误解:UPDATE 并不等于“找到一行后在文件中原地替换几个字符”。记录长度可能改变,B-tree 页面可能没有足够空间,因而需要移动单元格、分裂页面、修改父页面,甚至更新多个索引页面。
四、数据库文件的基本单位:页面
SQLite 数据库文件由固定大小的页面组成。常见页面大小包括 1024、2048、4096、8192 等字节。
页大小必须是 512 到 65536 之间的二的幂。数据库文件头中有一个两字节字段保存页大小:
- 值为 512 到 32768 时,表示实际页大小;
- 值为 1 时,表示 65536 字节;
- 其他值不是有效的标准页大小编码。
页大小影响:
- B-tree 一个页面能容纳多少条记录;
- 树的高度;
- 大记录需要多少溢出页;
- I/O 粒度;
- WAL 和回滚日志的页面单位;
- 读写放大和空间利用率。
页大小不是连接随意解释的参数,而是数据库文件格式的一部分。可以通过以下命令查看:
sqlite3 example.db 'PRAGMA page_size;'
输出可能是:
4096
PRAGMA page_size = 8192; 只会影响后续创建的数据库,或者在特定条件下通过 VACUUM 重建数据库后生效。数据库已经包含表和数据时,不能简单地把某个页大小字段改掉;那会使后续页面边界全部失效。
五、SQLite 数据库文件头
主数据库文件第一页的前 100 字节是数据库文件头。它不是普通 B-tree 页面头,而是包含数据库级元数据。
文件开头 16 字节是 ASCII:
SQLite format 3\000
可以用命令查看:
od -An -tx1 -N100 example.db
或者:
xxd -l 100 example.db
数据库文件头中值得理解的字段包括:
| 偏移 | 长度 | 含义 |
|---|---|---|
| 0 | 16 | 文件格式字符串 |
| 16 | 2 | 页大小编码 |
| 18 | 1 | 文件格式写版本 |
| 19 | 1 | 文件格式读版本 |
| 20 | 1 | 每页保留的末尾字节数 |
| 21 | 1 | 最大嵌入负载比例 |
| 22 | 1 | 最小嵌入负载比例 |
| 23 | 1 | 叶子页最小嵌入负载比例 |
| 24 | 4 | 文件改变计数器 |
| 28 | 4 | 数据库页数 |
| 32 | 4 | 第一个空闲页链表页 |
| 36 | 4 | 空闲页数量 |
| 40 | 4 | schema cookie |
| 44 | 4 | schema 格式号 |
| 48 | 4 | 默认缓存页数 |
| 52 | 4 | 最大根页号 |
| 56 | 4 | 文本编码 |
| 60 | 4 | 用户版本号 |
| 64 | 4 | 增量 vacuum 模式相关值 |
| 68 | 4 | 应用 ID |
| 92 | 4 | 版本有效性号 |
| 96 | 4 | SQLite 版本号 |
多字节整数采用大端序存储,这一点与许多普通 x86 程序直接使用的小端整数不同。
5.1 文件改变计数器和版本有效性号
数据库头中的文件改变计数器用于判断数据库内容是否发生改变。缓存和连接可以利用它检测页面内容是否可能已经过期。
在 WAL 模式下,数据库头的更新和可见性判断还要结合 WAL 的状态。不能仅凭“主数据库文件头没有变化”就断定数据库没有变化,因为最新事务可能仍位于 WAL 文件中。
5.2 Schema cookie 和 schema 格式
SQLite 会在数据库结构改变时更新 schema 相关字段。连接如果发现 schema cookie 与编译语句时看到的值不一致,可能需要重新解析语句,常见表现是:
database schema has changed
这不是普通数据行变化的同义词,而是数据库模式发生了变化,例如表、索引或触发器定义被修改。
六、数据库页的类型和 B-tree 页面布局
数据库的第一个页面从文件偏移 0 开始,因此它同时包含 100 字节数据库文件头和一个 B-tree 页面头。第一页的 B-tree 页面头从偏移 100 开始;其他页面的 B-tree 页面头从该页起始位置开始。
B-tree 页面分为四类:
- 表 B-tree 内部页;
- 表 B-tree 叶子页;
- 索引 B-tree 内部页;
- 索引 B-tree 叶子页。
页面前部通常包含:
B-tree 页面头
单元格指针数组
未分配区域
单元格内容区域
可能的碎片空间
6.1 B-tree 页面头
表 B-tree 叶子页的页面头长度为 8 字节,表 B-tree 内部页和索引内部页长度为 12 字节。索引叶子页的页面头长度为 8 字节。
页面头中的典型字段:
| 字段 | 作用 |
|---|---|
| 页面类型 | 区分四种 B-tree 页面 |
| 第一个空闲块偏移 | 指向页内空闲块链表 |
| 单元格数量 | 当前页含有多少个 cell |
| 单元格内容起始偏移 | cell 内容区从哪里开始 |
| 碎片字节数 | 页内少量不可用空间 |
| 最右子页指针 | 仅内部页存在 |
页面类型字节通常为:
0x02:索引内部页;0x05:表内部页;0x0a:索引叶子页;0x0d:表叶子页。
6.2 单元格指针数组
页面头之后是两个字节一个的单元格指针数组。每个指针指向本页中某个 cell 的起始位置。
指针数组按键的逻辑顺序排列,而 cell 的实际内容通常从页面尾部向前分配。这样做可以让指针数组从前向后增长、cell 内容从后向前增长,中间形成空闲空间。
因此,不能假设:
第一个 cell 紧跟在页面头后面
正确做法是:
- 读取页面头;
- 获取 cell 数量;
- 从指针数组取出每个 cell 的偏移;
- 跳转到对应 cell;
- 根据页面类型解析 cell。
6.3 表 B-tree 的顺序
rowid 表的表 B-tree 按 rowid 排序。内部页保存子页指针和分隔 rowid,叶子页保存实际记录。
例如,逻辑上有以下 rowid:
3, 8, 12, 20, 25
B-tree 不要求这些记录在文件中连续存放。它只要求通过页面中的指针和键,能够按顺序找到它们。
当页面空间不足时,B-tree 会执行页面分裂:
- 分配新页面;
- 将原页面中的部分 cell 移到新页面;
- 修改父页面中的子页指针和分隔键;
- 如果根页分裂,可能创建新的根结构;
- 这些修改必须作为同一个事务的一部分提交。
这说明一次逻辑上的单行插入,可能导致多个物理页面改变。
七、记录格式:SQLite 如何保存一行
SQLite 表记录采用自描述格式。一个记录通常由两部分组成:
记录头部
记录体
记录头部包含:
- 记录头部总长度;
- 每个字段对应的 serial type。
记录体紧随其后,字段值按照 serial type 描述的长度排列。
7.1 Serial type
常见 serial type 如下:
| serial type | 含义 |
|---|---|
| 0 | NULL |
| 1 | 1 字节有符号整数 |
| 2 | 2 字节有符号整数 |
| 3 | 3 字节有符号整数 |
| 4 | 4 字节有符号整数 |
| 5 | 6 字节有符号整数 |
| 6 | 8 字节有符号整数 |
| 7 | 8 字节 IEEE 754 浮点数 |
| 8 | 整数 0 |
| 9 | 整数 1 |
| 10、11 | 保留 |
| 大于等于 12 的偶数 | BLOB |
| 大于等于 13 的奇数 | TEXT |
对于大于等于 12 的 serial type:
- BLOB 长度:
- TEXT 长度:
这里的 是字节长度,不是字符数。UTF-8 文本中,一个字符可能占多个字节。
7.2 Varint
SQLite 大量使用变长整数,也称 varint。小数值使用较少字节,大数值使用较多字节。
这使得:
- 小 rowid 占用空间较少;
- 小记录头部较紧凑;
- 页面可以容纳更多小记录;
- 解析时需要逐字节判断 varint 是否结束。
7.3 一个简化记录示例
假设某行逻辑值为:
id = 7
name = "Bob"
其中 id 是整数,name 是 3 字节 UTF-8 文本。
可能的记录内容包括:
记录头:
头部长度
id 的 serial type
name 的 serial type
记录体:
id 的整数编码
B
o
b
实际编码还受 SQLite 对整数宽度、列亲和性以及表结构的影响。特别要注意:SQL 中声明为 INTEGER 的列,并不意味着所有值都总是固定使用 8 字节保存。SQLite 会根据实际值选择适当的 serial type。
八、表 B-tree 叶子 cell 与溢出页
表 B-tree 叶子页中的一个 cell 通常包含:
payload 长度 varint
rowid varint
记录 payload
记录 payload 可能无法全部放入当前页面。此时 cell 只保存一部分本地 payload,并在末尾保存一个四字节溢出页页号。溢出页再通过链表保存剩余内容。
结构可以表示为:
叶子页中的 cell
├── 本地 payload
└── 下一溢出页页号
↓
溢出页
├── 下一溢出页页号
└── payload
这解释了两个常见现象:
- 大字段可能导致一次查询读取多个页面;
- 更新大字段可能修改本地 cell、多个溢出页以及相关 B-tree 页面。
SQLite 不会把每个大字段独立存成操作系统文件。BLOB 和 TEXT 仍然属于数据库页中的记录内容,只是在超出页面本地容量时使用溢出页。
本地 payload 的精确大小由页大小、页面类型和文件格式规则共同决定。实现文件解析器时不能使用“超过半页就溢出”这样的非正式规则替代 SQLite 文件格式中定义的计算方法。
九、sqlite_schema:数据库结构本身也存储在 B-tree 中
SQLite 的表、索引、视图和触发器定义保存在一个特殊表中:
SELECT type, name, tbl_name, rootpage, sql
FROM sqlite_schema;
可能得到:
table|user|user|2|CREATE TABLE user(id INTEGER PRIMARY KEY, name TEXT)
index|user_name|user|3|CREATE INDEX user_name ON user(name)
这里:
rootpage是对应 B-tree 的根页号;sql是用于重建对象定义的 SQL 文本;- 表和索引本身也最终通过 B-tree、Pager 和 VFS 保存。
因此,执行:
CREATE INDEX user_name ON user(name);
不仅会创建索引数据页,还会修改 sqlite_schema 中的结构记录,并更新 schema cookie。
9.1 INTEGER PRIMARY KEY 的特殊性
在普通 rowid 表中,如果一列声明为:
id INTEGER PRIMARY KEY
它会成为 rowid 的别名。表 B-tree 直接使用该整数作为键,不需要再在记录体中重复保存一个独立的同值列。
这与以下声明不同:
id INT PRIMARY KEY
后者并不具有同样的 rowid 别名语义。它通常只是一个具有类型名 INT 的普通列,并不会自动成为 rowid 的直接替代。
9.2 WITHOUT ROWID 表
WITHOUT ROWID 表使用主键 B-tree 作为主要存储结构,不再额外使用隐含 rowid。例如:
CREATE TABLE device (
tenant_id INTEGER NOT NULL,
device_id TEXT NOT NULL,
payload BLOB,
PRIMARY KEY (tenant_id, device_id)
) WITHOUT ROWID;
这种表的物理组织和普通 rowid 表不同:
- 主键是 B-tree 的键;
- 主键列组合直接决定记录顺序;
- 不能使用普通 rowid 语义访问;
- 主键过宽时,内部节点和叶子页的空间成本也可能增加。
“所有 SQLite 表都按 rowid 存储”是错误的。WITHOUT ROWID 是文件和 B-tree 组织上的重要例外。
十、Pager:页面缓存与事务原子性的核心
10.1 Pager 管理的是页,不是行
Pager 的基本对象是数据库页:
(page number) → (page content in memory)
当 B-tree 请求页面时,Pager 可能执行:
- 在页面缓存中查找;
- 若命中,直接返回缓存页;
- 若未命中,通过 VFS 读取;
- 若页面将被修改,建立可写副本并记录其旧状态;
- 在事务结束时决定写入主库、写入 WAL 或丢弃修改。
因此,Pager 层并不理解“用户表的 name 列”。它只知道“第 N 页在事务前是什么内容、事务中变成了什么内容”。
10.2 回滚日志模式下的提交
在传统 rollback journal 模式下,修改数据库页时,SQLite 需要先保护旧页内容。简化流程如下:
数据库文件:旧页面 A
回滚日志:空
内存缓存:旧页面 A
开始写事务并修改:
数据库文件:旧页面 A
回滚日志:页面 A 的旧副本
内存缓存:新页面 A'
提交时,SQLite 需要确保:
- 回滚日志已经写入并同步;
- 新页面写入数据库文件;
- 数据库文件同步;
- 通过删除、清空或标记日志等方式完成提交协议;
- 释放写锁。
如果进程在中间崩溃,下一次打开数据库时,SQLite 可以利用日志中的旧页面把数据库恢复到事务开始前的一致状态。
这个过程的核心不是“每条 SQL 都立即落盘”,而是把多个页面修改组织为一个原子事务。
10.3 WAL 模式下的区别
WAL,即 Write-Ahead Logging,把新页面追加写入 WAL 文件,而不是首先覆盖主数据库文件:
主数据库:旧版本页面
WAL 文件:新版本页面
读事务根据自己的快照决定读取主数据库中的旧页还是 WAL 中的新页。Checkpoint 再把 WAL 中的页面合并回主数据库。
因此,WAL 模式下:
- 主数据库文件不是全部最新内容;
database.db-wal是数据库状态的一部分;- 可能还有
database.db-shm共享内存文件; - 仅复制主数据库文件可能得到不完整快照;
- 读写并发通常比 rollback journal 模式更适合,但仍然只有一个写者。
WAL 的锁状态、checkpoint、忙等待和复制边界需要单独分析;在架构层面,最重要的是认识到:Pager 的页面版本不一定来自主数据库文件,也可能来自日志文件。
十一、事务状态、锁和故障路径
11.1 rollback journal 下的典型锁状态
在传统回滚日志模式中,SQLite 使用操作系统文件锁协调连接。常见状态包括:
- UNLOCKED:没有持有数据库锁;
- SHARED:可以读取数据库,多个连接可以同时持有;
- RESERVED:当前连接准备写入,但允许已有读者继续读取;
- PENDING:准备升级到排他写入,阻止新的读者进入;
- EXCLUSIVE:获得排他访问,执行最终写入。
一个典型写事务流程是:
UNLOCKED
↓ BEGIN 或首次读取
SHARED
↓ 首次修改
RESERVED
↓ 等待读者退出
PENDING
↓ 获取排他锁
EXCLUSIVE
↓ 提交或回滚
UNLOCKED / SHARED
实际状态转换会受到事务类型、共享缓存、WAL 模式和具体操作影响。不能把上述状态图理解为每一条 SQL 都严格经过所有状态。
11.2 DEFERRED、IMMEDIATE 和 EXCLUSIVE
以下三种事务开始方式含义不同:
BEGIN DEFERRED;
BEGIN IMMEDIATE;
BEGIN EXCLUSIVE;
DEFERRED:开始时不立即获得写锁,第一次读时建立读事务,第一次写时才尝试升级;IMMEDIATE:立即尝试建立写事务;EXCLUSIVE:在 rollback journal 模式下立即要求更强的排他访问;在 WAL 模式下与IMMEDIATE的差异较小。
例如:
BEGIN IMMEDIATE;
UPDATE user SET name = 'Bob' WHERE id = 10;
COMMIT;
BEGIN IMMEDIATE 能较早暴露“当前无法获得写事务”的情况,而不是等到后面的 UPDATE 才失败。
11.3 崩溃发生在哪里
以 rollback journal 为例,若崩溃发生在不同阶段,恢复结果不同:
| 崩溃阶段 | 可能状态 | 下次打开时的处理 |
|---|---|---|
| 日志尚未完整写入 | 主库仍是旧内容 | 日志可能被判定为无效 |
| 日志完整、主库未完成写入 | 主库可能部分更新 | 使用日志回滚 |
| 主库写完但同步或提交标记未完成 | 提交是否生效取决于协议和持久化保证 | SQLite 判断并恢复一致状态 |
| 提交完成、锁已释放 | 新事务可见 | 正常打开 |
这里的“同步”不是 C 语言中的 fflush()。SQLite 需要通过 VFS 的同步接口请求操作系统和存储设备按相应语义持久化数据。存储设备、文件系统或虚拟化层若违反持久化保证,任何数据库都可能遭遇超出引擎假设的损坏。
十二、VFS 的职责与边界
12.1 VFS 不是 SQL 驱动
VFS 位于 SQLite 最底层,它不知道:
- SQL 表名;
- 行和列;
- 索引选择;
- B-tree 键的含义。
它主要处理抽象文件对象和系统能力。典型的文件方法包括:
xRead:从指定偏移读取;xWrite:向指定偏移写入;xTruncate:改变文件大小;xSync:请求同步;xLock/xUnlock:获得或释放锁;xFileSize:获取文件大小;xClose:关闭文件。
VFS 还提供全局方法,例如生成随机数和获取时间。
12.2 为什么锁实现特别重要
SQLite 可能在多个进程中打开同一个文件。Pager 的并发控制依赖 VFS 把 SQLite 的锁请求正确映射到操作系统或底层存储。
如果自定义 VFS:
- 锁不具备跨进程一致性;
xLock报告已加锁但实际没有隔离;xSync提前返回;xRead返回错误长度却被错误处理;- 文件删除、重命名和临时文件语义不一致;
那么 SQLite 的上层事务算法即使完全正确,也无法得到可靠结果。
12.3 :memory: 和临时数据库
sqlite3_open(":memory:", &db);
会创建仅存在于当前连接生命周期内的内存数据库。它没有普通主数据库文件,因此不能通过另一个独立连接直接打开同一个 :memory: 数据库。
如果需要多个连接访问共享内存数据库,可以使用 URI 形式和共享缓存相关能力,但这涉及连接生命周期、编译选项和线程模型,不应把 :memory: 简化理解为“所有连接共享的一块内存”。
临时数据库文件也可能使用特定 VFS 和临时文件路径,其持久化行为与普通磁盘数据库不同。
12.4 自定义 VFS 的工程边界
自定义 VFS 适合以下情况:
- 操作系统没有标准文件 API;
- 需要加密或封装底层存储;
- 需要测试 I/O 故障;
- 需要把 SQLite 接入嵌入式设备存储;
- 需要定制文件锁和同步策略。
但加密 VFS 必须考虑:
- 页级读写和随机访问;
- 日志文件、WAL 文件和共享内存文件;
- 文件大小与密文扩展;
- nonce、认证标签和崩溃一致性;
- 密钥生命周期;
- 备份和恢复工具的兼容性。
仅对数据库文件做静态加密,不能自动覆盖 WAL、journal、临时文件和内存中的明文数据。
十三、用 SQLite CLI 观察架构和文件格式
下面的示例使用 SQLite 官方命令行工具。假设环境中安装了 sqlite3。
13.1 创建数据库并观察元数据
rm -f example.db example.db-wal example.db-shm example.db-journal
sqlite3 example.db <<'SQL'
PRAGMA page_size = 4096;
PRAGMA journal_mode = delete;
CREATE TABLE user (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
age INTEGER
);
CREATE INDEX user_name ON user(name);
INSERT INTO user(name, age) VALUES
('Alice', 30),
('Bob', 25),
('陈明', 28);
.headers on
.mode box
SELECT type, name, tbl_name, rootpage, sql
FROM sqlite_schema
ORDER BY type, name;
PRAGMA page_size;
PRAGMA page_count;
PRAGMA freelist_count;
SQL
可能看到类似结果:
type name tbl_name rootpage sql
----- --------- -------- -------- ----------------------------------------
index user_name user 3 CREATE INDEX user_name ON user(name)
table user user 2 CREATE TABLE user (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
age INTEGER
)
page_size page_count freelist_count
---------- ---------- ---------------
4096 3 0
页数和根页号可能因 SQLite 版本、插入顺序、页面利用率等因素不同,不应把示例中的具体页号当成规范保证。这里能确认的是:
sqlite_schema本身是数据库中的结构对象;- 表和索引各自有 B-tree 根页;
page_size是文件级属性;page_count是当前数据库文件包含的页数。
13.2 查看表结构和查询计划
sqlite3 example.db <<'SQL'
.schema user
EXPLAIN QUERY PLAN
SELECT name FROM user WHERE name = 'Bob';
SQL
如果优化器选择了索引,可能输出:
QUERY PLAN
`--SEARCH user USING INDEX user_name (name=?)
这只说明 SQL 层的访问计划。它并不表示查询只读取一个文件块。实际执行还可能读取:
- 索引 B-tree 的根页和内部页;
- 索引叶子页;
- 表 B-tree 中对应 rowid 的页面;
- 记录溢出页;
- WAL 中的新页面。
查询计划和物理 I/O 不是同一层次的概念。
13.3 查看页头的实际字节
xxd -g 1 -l 120 example.db
开头应包含:
53 51 4c 69 74 65 20 66 6f 72 6d 61 74 20 33 00
对应 ASCII:
SQLite format 3\0
对于 4096 字节页,第一页的 B-tree 页面头从偏移 100 开始。若第一页是普通表的叶子页,偏移 100 处的页面类型通常为 0d;但新数据库的第一页可能是 sqlite_schema 的 B-tree,具体类型应以实际文件为准。
不要直接编辑这些字节来“修复”数据库。文件头字段相互关联,B-tree、空闲页链表、schema cookie 和日志状态也必须一致。
十四、B-tree 页面分裂的完整逻辑示例
假设一个表 B-tree 叶子页只能再容纳一个 cell,当前结构为:
根页 2
├── 叶子页 5:rowid 1, 2, 3
└── 叶子页 6:rowid 8, 9
现在插入 rowid = 10:
- B-tree 从根页 2 比较键值;
- 根据分隔键选择叶子页 6;
- Pager 读取页 6;
- 页 6 没有足够空间容纳新 cell;
- B-tree 分配新页 7;
- 将页 6 中部分 cell 移到页 7;
- 把 rowid 10 放入页 7 或页 6,具体取决于分裂策略;
- 修改根页 2,增加指向新子页的分隔信息;
- Pager 标记页 2、6、7 为脏页;
- 事务提交时,所有这些页面必须作为一个原子修改集持久化。
如果在第 8 步之后、提交完成之前进程崩溃,数据库不能只保留根页修改而丢失子页修改,否则根页会指向不完整结构。Pager 的日志和提交协议正是为了避免这种“部分树结构可见”。
这个例子还说明:
- B-tree 页分裂可能修改父页;
- 一个逻辑操作可能修改多个页面;
- 页面分配会影响空闲页链表;
- 事务原子性必须覆盖整组页面,而不是单行。
十五、空闲页、碎片与 VACUUM
删除记录后,空间不一定立刻让操作系统看到数据库文件变小。
SQLite 通常会把释放出的页面放入空闲页链表,供后续插入重用。数据库文件可能保持原有大小:
删除数据
↓
页面内部出现空闲空间
↓
某些页面可能进入 freelist
↓
后续写入重用
可以查看:
PRAGMA freelist_count;
这表示当前空闲页数量,但不能简单等同于“可以立即从文件尾部截断的空间”。
如果需要重建数据库并回收空间,可以执行:
VACUUM;
VACUUM 会构造一个新的数据库布局,再替换原数据库。它可能:
- 改变页面排列;
- 消除大量内部碎片;
- 改变页数;
- 改变 rowid 表中未显式固定的 rowid;
- 需要临时空间;
- 需要较长时间和较高 I/O;
- 在其他连接持有冲突事务时失败或等待。
如果只是希望在开启自动回收模式后逐步回收页面,还涉及 auto_vacuum 和增量 vacuum。相关模式必须在数据库创建或重建阶段正确设置,不能把它们理解成无条件优于普通 freelist 的“压缩开关”。
十六、数据库文件、journal 和 WAL 不是一份简单文件
16.1 rollback journal 模式
在 rollback journal 模式下,事务期间可能存在:
example.db
example.db-journal
journal 保存恢复事务所需的信息。一个正在进行的写事务并不意味着主数据库文件已经包含全部新数据。
如果应用在事务中崩溃,重新打开数据库时 SQLite 会检查 journal 是否是有效的热日志,并在必要时执行回滚。
16.2 WAL 模式
执行:
PRAGMA journal_mode = WAL;
成功后,可能看到:
example.db
example.db-wal
example.db-shm
其中:
example.db保存 checkpoint 后的主库页;example.db-wal保存尚未合并回主库的提交内容;example.db-shm通常用于多个连接共享 WAL 索引信息。
下面的程序边界是正确的:
sqlite3 example.db 'PRAGMA wal_checkpoint(FULL);'
但即使 checkpoint 完成,也不能在有其他连接继续访问数据库时,随意把主库文件当作“永远独立于 WAL 的完整快照”来复制。
安全备份通常应使用:
- SQLite Online Backup API;
VACUUM INTO;- 在正确锁定和日志状态下进行文件级复制;
- 或先确保连接和相关日志文件处于可控状态,再复制完整文件集合。
直接复制正在写入的 WAL 数据库的主文件,可能得到一个时间点不一致的数据库。
十七、文件格式兼容性与读取边界
SQLite 文件格式的设计目标之一是长期兼容。不同版本的 SQLite 通常可以读取同一数据库文件,文件头中也包含读写版本字段,用于表示某些格式能力。
但“文件格式兼容”不等于“所有工具都支持所有能力”:
- 某些旧版本可能不理解较新的特性;
WITHOUT ROWID、WAL 等能力需要相应版本支持;- 编译选项可能禁用某些功能;
- 数据库文件可能被配置为只读;
- 自定义 VFS 或加密格式可能不再是标准 SQLite 文件。
SQLite 的数据库文件格式并不是一个适合业务程序自行拼接和修改的公开对象模型。需要迁移、清理或修复时,应优先通过 SQLite 引擎执行 SQL、备份 API 或导出导入,而不是直接修改页面。
十八、常见误解与失败表现
18.1 “SQLite 只有一个文件,所以复制这个文件总是安全”
不正确。
在 rollback journal 模式下,要考虑 journal;在 WAL 模式下,要考虑 WAL 和共享内存状态。正在进行事务时复制主文件,可能得到旧版本、部分状态或缺少日志内容的快照。
更稳妥的方式是使用 Online Backup API 或 VACUUM INTO,并明确备份时的事务和并发边界。
18.2 “事务提交就是调用一次 write()”
不正确。
事务提交涉及:
- 页面修改集合;
- 日志或 WAL;
- 文件锁;
- 同步操作;
- 提交标记或日志状态;
- 崩溃恢复协议。
底层文件系统是否真正提供 SQLite 所需的同步和锁语义,是 VFS 和部署环境共同决定的。
18.3 “索引命中就只读一个页面”
不正确。
索引查找至少可能访问索引根页、内部页和叶子页,然后还要根据 rowid 访问表 B-tree。记录较大时,还可能访问溢出页。页面缓存命中时,物理读次数会减少,但逻辑页面依赖仍然存在。
18.4 “把数据库文件截断到看起来的有效长度就能修复损坏”
风险很高。
数据库页数、空闲页链表、B-tree 指针、溢出页链和日志状态必须一致。文件末尾看起来存在空页,不代表可以直接删除;文件大小字段和页引用也必须匹配。
诊断时可以使用:
sqlite3 example.db 'PRAGMA integrity_check;'
或:
sqlite3 example.db 'PRAGMA quick_check;'
integrity_check 会进行更完整的结构检查,可能耗时较长;quick_check 较快但检查范围不同。检查通过不等于业务数据一定正确,检查失败也不应立即通过手工改文件处理。
18.5 “WAL 会让 SQLite 变成多写者数据库”
不正确。
WAL 改善的是读写并发和读事务不阻塞写事务的典型场景,但 SQLite 仍然需要协调写事务,通常一次只有一个写者。长事务、未完成 checkpoint、写者竞争和 SQLITE_BUSY 仍然可能出现。
18.6 “VFS 只是换一个文件路径”
不正确。
VFS 还承担锁、同步、临时文件、随机数和系统时间等能力。错误的 VFS 可能使数据库在正常测试中工作,却在断电、多进程或异常退出时损坏。
十九、从架构角度排查问题
面对 SQLite 错误,应先判断它属于哪一层。
19.1 SQL 或编译层
典型问题:
no such table
no such column
database schema has changed
检查:
SELECT type, name, tbl_name, rootpage, sql
FROM sqlite_schema;
并检查连接是否打开了预期路径。SQLite 中相对路径取决于进程当前工作目录,应用可能误打开另一个同名数据库。
19.2 锁与事务层
典型问题:
database is locked
database is busy
重点检查:
- 是否存在长时间未提交的事务;
- 是否在事务中执行了网络请求或用户交互;
- 多个连接是否同时竞争写锁;
- 是否设置了合理的 busy timeout 或忙处理策略;
- WAL checkpoint 是否被长读事务阻塞。
忙等待只适合短暂竞争。它不能解决永远不提交的事务,也不应掩盖连接泄漏和事务边界错误。
19.3 Pager、journal 或 WAL 层
重点检查:
- 是否异常终止过;
- journal 或 WAL 文件是否被外部删除;
- 是否在复制时遗漏了相关文件;
- 是否使用了不可靠的网络文件系统;
- 存储设备是否真正支持同步语义;
- 自定义 VFS 是否正确实现锁和
xSync。
19.4 B-tree 和文件格式层
可以执行:
PRAGMA integrity_check;
PRAGMA foreign_key_check;
两者检查对象不同:
integrity_check关注数据库文件结构和一致性;foreign_key_check关注外键约束关系。
如果文件结构损坏,通常的恢复路径是:
- 立即保留原始文件和相关 journal/WAL;
- 对副本执行诊断;
- 尝试从可靠备份恢复;
- 必要时导出仍可读取的数据到新数据库;
- 对无法读取的页面和记录保留明确损失范围。
“导出后重建”有时能够绕过部分损坏,但不是保证完整恢复的方法。
二十、架构如何影响工程设计
20.1 事务边界必须短而明确
因为 SQLite 的写事务通常具有较粗的文件级竞争粒度,以下代码结构风险较高:
BEGIN
写数据库
调用远程服务
等待用户输入
继续写数据库
COMMIT
远程调用和用户等待期间,写事务一直持有资源,其他写者会被阻塞。
更合理的结构通常是:
准备外部数据
BEGIN
执行必要的本地修改
COMMIT
这不是因为 SQLite 没有事务能力,而是因为 Pager 的锁和页面提交范围决定了长写事务的代价。
20.2 连接生命周期要明确
应用应明确:
- 哪个线程使用哪个连接;
- 连接是否允许跨线程;
- 事务由谁开始和结束;
- 错误路径是否总能回滚;
- 游标、预编译语句和连接何时关闭;
- 关闭连接前是否仍有未完成语句。
SQLite 的线程安全配置、连接模式和语言绑定规则可能不同。不能仅凭“库默认线程安全”就推断任意连接对象可以被多个线程同时无约束使用。
20.3 部署边界要包含文件系统
SQLite 数据库的安全性依赖底层文件系统提供:
- 一致的文件读写;
- 可用的锁;
- 合理的同步语义;
- 稳定的重命名和删除行为;
- 正确的多进程可见性。
把 SQLite 文件放在本地磁盘、容器卷、网络文件系统、同步盘或用户目录,可能产生不同结果。尤其不能因为普通测试成功,就假设所有网络文件系统都具备 SQLite 所需的锁和同步语义。
20.4 备份边界必须覆盖数据库状态
备份设计需要先确定:
- 使用 rollback journal 还是 WAL;
- 备份是否允许并发写入;
- 备份要不要包含未 checkpoint 的 WAL 内容;
- 是否使用 Online Backup API;
- 恢复时 SQLite 版本和编译选项是否一致;
- 是否需要验证恢复后的
integrity_check和业务数据。
备份不是把一个路径复制到另一个路径这么简单,而是复制某个一致性时间点上的数据库状态。
二十一、把几个层次放在一起理解
执行一次带索引的更新:
BEGIN IMMEDIATE;
UPDATE user
SET name = 'Bob'
WHERE id = 10;
COMMIT;
可以按以下顺序理解:
BEGIN IMMEDIATE让 Pager 尝试建立写事务;- SQL 编译器生成定位记录和更新字段的 VDBE 字节码;
- VDBE 通过表 B-tree 找到 rowid 为 10 的记录;
- Pager 从缓存或 VFS 取得相关页面;
- 记录解码后生成新的 record payload;
- 如果新记录长度变化,B-tree 可能移动 cell 或分裂页面;
- 若
name相关索引受影响,索引 B-tree 也要更新; - Pager 记录所有被修改页面;
- rollback journal 模式写入旧页面,WAL 模式写入新页面;
- VFS 执行必要的写入、加锁和同步;
- 事务提交后,新的 B-tree 页面集合成为可见状态。
其中任意一层都不能被另一层完全替代:
- SQL 计划不能替代 B-tree 页面结构;
- B-tree 不能替代事务日志;
- Pager 不能替代 VFS 的持久化保证;
- VFS 也不知道 SQL 语义。
SQLite 的可靠性正是来自这些层之间清晰的职责边界,以及它们对页面、锁和事务状态的协同。
结语
SQLite 的核心不是“把 SQL 存进一个文件”,而是一条完整的数据路径:
SQL
→ VDBE
→ B-tree
→ Pager
→ VFS
→ 数据库文件或日志文件
- 嵌入式数据库说明 SQLite 与应用同进程运行,没有独立数据库服务器;
- B-tree负责把表和索引组织成可搜索、可分裂、可复用的页面树;
- Pager负责页面缓存、事务、日志、锁和崩溃恢复;
- VFS负责把这些抽象映射到操作系统或自定义存储;
- 文件格式则规定了页面、B-tree、记录、溢出页、空闲页和数据库头如何编码。
掌握这些关系后,database is locked、WAL 文件、页面分裂、VACUUM、备份边界和文件损坏就不再是互相孤立的现象,而可以归入同一套模型:SQL 操作最终改变的是一组数据库页面,而 SQLite 必须通过 Pager 和 VFS,把这组页面以正确的锁、日志和同步顺序变成一个可恢复的持久状态。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:Oracle RMAN、Data Guard 与 RAC:备份恢复和高可用边界
- 下一篇:SQLite 事务与 WAL:锁状态、并发读写、Checkpoint 和忙等待
- 延伸:SQLite 工程实践:嵌入应用、备份、迁移、损坏恢复与安全
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论