SQLite 完全指南 / 03 - 架构原理
03 - 架构原理:虚拟机、B-Tree、WAL 与页面缓存
3.1 SQLite 架构总览
SQLite 的架构分为三个主要子系统,每个子系统由多个模块组成:
┌──────────────────────────────────────────────┐
│ 接口层 (Interface) │
│ sqlite3_open / sqlite3_exec │
├──────────────────────────────────────────────┤
│ 编译器 (Compiler) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 分词器 │ │ 语法分析 │ │ 代码生成 │ │
│ │(Tokenizer)│ │ (Parser) │ │(Code Gen)│ │
│ └──────────┘ └──────────┘ └──────────┘ │
├──────────────────────────────────────────────┤
│ 虚拟机 (Virtual Machine) │
│ VDBE (Virtual Database Engine) │
├──────────────────────────────────────────────┤
│ 存储引擎 (Storage Engine) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ B-Tree │ │ 页缓存 │ │ 日志模块 │ │
│ │(B-Tree) │ │(Pager) │ │(Journal) │ │
│ └──────────┘ └──────────┘ └──────────┘ │
├──────────────────────────────────────────────┤
│ 操作系统接口 (OS Interface) │
│ VFS (Virtual File System) │
├──────────────────────────────────────────────┤
│ 磁盘存储 │
│ 数据库文件 (.db/.sqlite) │
└──────────────────────────────────────────────┘
3.2 编译器前端
3.2.1 分词器(Tokenizer)
分词器将原始 SQL 字符串拆分为一个个 Token:
SELECT name FROM users WHERE id = 1;
-- Token 序列:
-- SELECT → 关键字
-- name → 标识符
-- FROM → 关键字
-- users → 标识符
-- WHERE → 关键字
-- id → 标识符
-- = → 运算符
-- 1 → 整数字面量
-- ; → 语句终止
3.2.2 语法分析器(Parser)
SQLite 使用 Lemon 解析器生成器(非 yacc/bison),将 Token 序列转化为 AST(抽象语法树):
-- 以下查询:
SELECT name, age FROM users WHERE age > 18 ORDER BY name;
-- 生成的 AST 概念结构:
-- Select
-- ├── columns: [name, age]
-- ├── from: users
-- ├── where: age > 18
-- └── orderBy: name ASC
3.2.3 代码生成器(Code Generator)
代码生成器将 AST 转换为虚拟机字节码(bytecode):
-- 查看 SQL 的字节码
EXPLAIN SELECT name FROM users WHERE id = 1;
addr opcode p1 p2 p3 p4 p5 comment
---- ------------- ---- ---- ---- ------------- -- -------
0 Init 0 12 0 0 Start at 12
1 OpenRead 0 2 0 2 columns 0 root=2
2 Integer 1 1 0 0 r[1]=1
3 SeekRowid 0 8 1 0 if (r[1]) goto 8
4 Column 0 0 2 0 r[2]=users.name
5 ResultRow 2 1 0 0 output=r[2]
6 Next 0 4 0 0
7 Halt 0 0 0 0
8 Rowid 0 1 0 0 r[1]=rowid
9 Goto 0 3 0 0
...
3.3 虚拟机(VDBE)
SQLite 虚拟机(Virtual Database Engine,VDBE)是 SQLite 的核心执行引擎。所有 SQL 语句最终都被编译成 VDBE 字节码并执行。
3.3.1 VDBE 的特点
| 特性 | 说明 |
|---|---|
| 寄存器模型 | 使用虚拟寄存器存储中间结果 |
| 栈式执行 | 指令顺序执行,支持跳转 |
| 操作码数量 | 约 150+ 条指令 |
| 类型 | 与硬件无关的虚拟机 |
3.3.2 常见操作码
| 操作码 | 说明 | 用途 |
|---|---|---|
Init | 初始化 | 程序入口 |
OpenRead | 打开表/索引用于读取 | SELECT |
OpenWrite | 打开表用于写入 | INSERT/UPDATE/DELETE |
Column | 读取列值 | SELECT |
Rowid | 获取当前行的 rowid | — |
SeekRowid | 通过 rowid 定位 | WHERE id = ? |
SeekGE | 索引范围查找 | WHERE idx_col >= ? |
Integer | 加载整数到寄存器 | — |
String8 | 加载字符串到寄存器 | — |
ResultRow | 输出一行结果 | SELECT |
Insert | 插入一行 | INSERT |
Delete | 删除一行 | DELETE |
Halt | 停止执行 | — |
Transaction | 开始事务 | BEGIN |
AutoCommit | 提交/回滚 | COMMIT/ROLLBACK |
Function | 调用内置/自定义函数 | — |
Jump | 无条件跳转 | — |
Compare | 比较两个寄存器 | — |
If | 条件跳转 | — |
3.3.3 实际示例
-- 简单的 INSERT 语句
EXPLAIN INSERT INTO users (name, age) VALUES ('张三', 25);
addr opcode p1 p2 p3 p4 p5 comment
---- ------------- ---- ---- ---- ------------- -- -------
0 Init 0 7 0 0
1 OpenWrite 0 2 0 3 0
2 NewRowid 0 1 0 0 r[1]=new rowid
3 String8 0 2 0 张三 0 r[2]='张三'
4 Integer 25 3 0 0 r[3]=25
5 Insert 0 4 1 0 table.insert(r[1],r[2..3])
6 Halt 0 0 0 0
3.4 B-Tree 存储结构
SQLite 使用 B-Tree(准确说是 B+Tree)来组织表和索引的数据。
3.4.1 表 B-Tree(Table B-Tree)
表数据存储在 B+Tree 中,key 为 rowid,value 为行数据:
┌────────────────────┐
│ Internal Node │
│ keys: [10, 20, 30]│
└──┬────┬────┬───┬──┘
│ │ │ │
┌──────┘ │ │ └──────┐
▼ ▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ Leaf │ │ Leaf │ │ Leaf │ │ Leaf │
│ rows │ │ rows │ │ rows │ │ rows │
│ 1-9 │ │ 10-19 │ │ 20-29 │ │ 30+ │
└────────┘ └────────┘ └────────┘ └────────┘
-- 查看表的 B-Tree 结构信息
PRAGMA page_size; -- 默认 4096 字节
PRAGMA table_info(users);
-- 查看某张表占用的页面数
PRAGMA page_count;
PRAGMA freelist_count; -- 空闲页面数
3.4.2 索引 B-Tree(Index B-Tree)
索引也是 B+Tree,key 为索引列值 + rowid,value 为空(或包含额外的覆盖列):
索引 B-Tree (email 索引)
┌────────────────────────────┐
│ Internal Node │
│ keys: [a@..., m@..., t@...]│
└─────┬──────┬───────┬──────┘
│ │ │
┌──────┘ │ └──────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Leaf │ │ Leaf │ │ Leaf │
│ a-e@... │ │ m-s@... │ │ t-z@... │
│ → rowid │ │ → rowid │ │ → rowid │
└──────────┘ └──────────┘ └──────────┘
3.4.3 页面结构
每个 B-Tree 节点占一个页面(默认 4096 字节),页面结构如下:
| 偏移 | 大小 | 说明 |
|---|---|---|
| 0 | 1 | 页面类型(0x02=内部索引,0x05=内部表,0x0a=叶子索引,0x0d=叶子表) |
| 1-2 | 2 | 第一个空闲块偏移 |
| 3-4 | 2 | 单元格数量 |
| 5-6 | 2 | 单元格内容区偏移 |
| 7 | 1 | 碎片空闲字节数 |
| 8+ | 4*n | 单元格指针数组 |
-- 查看数据库的十六进制内容(调试用)
-- 使用 sqlite3 CLI:
-- sqlite3 mydb.db ".dump" 查看结构
-- 查看页面数量和大小
sqlite3 mydb.db "PRAGMA page_count; PRAGMA page_size;"
3.5 页面缓存(Pager)
页面缓存是 B-Tree 与磁盘之间的中间层,负责:
| 功能 | 说明 |
|---|---|
| 缓存 | 常用页面保持在内存中 |
| 事务 | 实现原子提交(rollback journal 或 WAL) |
| 锁管理 | 管理并发访问的文件锁 |
| 崩溃恢复 | 从 journal/WAL 中恢复 |
3.5.1 缓存管理
-- 设置页面缓存大小(单位:页)
PRAGMA cache_size = -2000; -- 使用约 2MB 缓存(负值=KB)
-- 查看当前缓存大小
PRAGMA cache_size;
-- 查看缓存统计(需在源码中启用)
-- sqlite3_analyzer 工具可以分析
3.5.2 页面缓存与内存
应用程序 SQL
│
▼
┌─────────┐
│ VDBE │ 虚拟机执行字节码
└────┬────┘
│ 请求页面
▼
┌─────────┐
│ Pager │ 页面缓存
│ │
│ ┌─────┐ │
│ │缓存页│ │ LRU 淘汰策略
│ │ page1│ │
│ │ page2│ │
│ │ ... │ │
│ └─────┘ │
└────┬────┘
│ 持久化
▼
┌─────────┐
│ 日志文件 │ rollback journal 或 WAL
└────┬────┘
│
▼
┌─────────┐
│ 数据库文件│
└─────────┘
3.6 日志机制
SQLite 有两种日志机制来保证 ACID 特性:
3.6.1 Rollback Journal(默认模式)
传统的回滚日志模式:
写操作流程:
1. 将要修改的页面原始内容写入 -journal 文件
2. 将新内容写入数据库文件
3. 删除 -journal 文件(事务提交)
崩溃恢复:
- 如果存在 -journal 文件 → 说明事务未完成 → 回滚
BEGIN
│
▼
保存原始页面到 -journal
│
▼
修改数据库文件中的页面
│
▼
COMMIT → 删除 -journal
3.6.2 WAL(Write-Ahead Logging)模式
现代的预写日志模式:
写操作流程:
1. 将修改写入 WAL 文件(-wal)
2. 在 WAL 中设置 commit 标记
3. 可选:checkpoint 将 WAL 数据合并回数据库文件
读操作流程:
- 同时读取数据库文件和 WAL 文件来获取最新数据
-- 启用 WAL 模式
PRAGMA journal_mode = WAL;
-- 查看 WAL 模式状态
PRAGMA journal_mode;
-- WAL 自动 checkpoint 大小(默认 1000 页)
PRAGMA wal_autocheckpoint;
-- 手动执行 checkpoint
PRAGMA wal_checkpoint(PASSIVE); -- 非阻塞
PRAGMA wal_checkpoint(TRUNCATE); -- 阻塞,截断 WAL
PRAGMA wal_checkpoint(FULL); -- 阻塞,确保所有数据合并
3.6.3 WAL vs Rollback Journal 对比
| 特性 | Rollback Journal | WAL |
|---|---|---|
| 读写并发 | 读阻塞写,写阻塞读 | 读写可并发 |
| 文件数量 | 数据库 + 1 个 journal | 数据库 + .wal + .shm |
| 适用场景 | 简单场景、NFS | 高读并发 |
| 崩溃恢复 | 从 journal 回滚 | 从 WAL 重放 |
| 空间使用 | journal 在提交后删除 | WAL 可能增长 |
| 默认模式 | ✅ 默认 | 需手动开启 |
| 网络文件系统 | ⚠️ 勉强可用 | ❌ 不支持 |
3.7 文件锁机制
SQLite 使用操作系统级别的文件锁来管理并发:
3.7.1 锁的状态
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ UNLOCK│──▶│ SHARED│──▶│RESERVED│──▶│PENDING│──▶│EXCLUSIVE│
│ 无锁 │ │ 共享锁│ │ 保留锁 │ │ 待定锁 │ │ 排他锁 │
└──────┘ └──────┘ └──────┘ └──────┘ └──────┘
▲ │
└──────────────────────────────────────────────────────┘
释放锁
| 锁状态 | 说明 | 允许的操作 |
|---|---|---|
| UNLOCK | 无锁 | 无 |
| SHARED | 共享锁 | 读操作 |
| RESERVED | 保留锁 | 准备写入(持有锁但未写入) |
| PENDING | 待定锁 | 等待其他共享锁释放 |
| EXCLUSIVE | 排他锁 | 写操作(其他连接不能读) |
3.7.2 WAL 模式下的锁
WAL 模式下的锁机制更加细粒度:
| 锁状态 | 说明 |
|---|---|
| UNLOCK | 无锁 |
| SHARED | 读 WAL(可多个读者) |
| WRITE | 写 WAL(一次只能一个写者) |
| CHECKPOINT | 执行 checkpoint |
-- 查看当前锁状态(调试用)
PRAGMA lock_status; -- 需要特定编译选项
-- 设置锁等待超时(毫秒)
PRAGMA busy_timeout = 5000; -- 等待 5 秒
3.8 VFS(虚拟文件系统)
VFS 是 SQLite 与操作系统之间的抽象层:
| VFS 类型 | 平台 | 说明 |
|---|---|---|
unix | Linux/macOS | POSIX 文件操作 |
unix-dotfiles | Unix | 使用点文件作为锁 |
win32 | Windows | Win32 API 文件操作 |
win32-longpath | Windows | 支持长路径 |
memdb | 全平台 | 内存数据库(共享内存) |
-- 查看可用的 VFS
-- 在 CLI 中使用:PRAGMA vfs_list;(需要调试编译选项)
-- 使用指定 VFS 打开数据库
-- C API: sqlite3_open_v2("mydb.db", &db, flags, "unix");
3.9 数据库文件格式
一个 SQLite 数据库文件的布局:
┌──────────────────────────────────────────┐
│ Page 1: 数据库头(100 字节)+ 表结构 │
│ ┌────────────────────────────────────┐ │
│ │ Header String: "SQLite format 3\000"│ │
│ │ Page Size: 4096 │ │
│ │ File Format: 1/2 │ │
│ │ Reserved Space: 0 │ │
│ │ Max Payload: 64 │ │
│ │ ... (共 100 字节) │ │
│ └────────────────────────────────────┘ │
├──────────────────────────────────────────┤
│ Page 2+: B-Tree 数据页面 │
├──────────────────────────────────────────┤
│ ... 更多页面 │
├──────────────────────────────────────────┤
│ Freelist: 空闲页面链表 │
└──────────────────────────────────────────┘
-- 查看数据库头信息
sqlite3 mydb.db "SELECT * FROM sqlite_master;"
-- 查看页面大小
PRAGMA page_size; -- 默认 4096
-- 修改页面大小(需在建表之前)
PRAGMA page_size = 8192;
PRAGMA journal_mode = WAL;
VACUUM; -- 应用新的页面大小
3.10 内存管理
SQLite 的内存使用主要在以下区域:
| 区域 | 说明 | 配置 |
|---|---|---|
| 页面缓存 | 缓存常用的数据库页面 | PRAGMA cache_size |
| WAL 索引 | WAL 文件的索引 | 自动管理 |
| SQL 编译 | SQL 语句编译时的内存 | — |
| 排序/哈希 | ORDER BY、GROUP BY 等 | PRAGMA sorter_mem |
| BLOB I/O | 大对象读写缓冲 | — |
-- 设置内存使用限制(C API)
-- sqlite3_soft_heap_limit64(limit)
-- sqlite3_hard_heap_limit64(limit)
-- 查看当前内存使用
PRAGMA mmap_size; -- 内存映射大小
⚠️ 注意事项
- WAL 文件不要手动删除——
.wal和.shm文件是 WAL 模式必需的,手动删除会导致数据丢失 - 页面大小只能在建表之前修改——已有数据时修改页面大小需要
VACUUM - 网络文件系统上不要使用 WAL 模式——NFS/SMB 上使用 WAL 可能导致数据库损坏
- 大事务可能导致 WAL 文件膨胀——长时间不 checkpoint 会使 WAL 文件增长
- VACUUM 会重写整个数据库文件——大数据库执行 VACUUM 时需要足够的磁盘空间
💡 技巧
- 理解 EXPLAIN 输出有助于优化查询——可以看到 SQLite 是否使用了索引
- WAL 模式是大多数场景的首选——读写并发性能更好
- 定期 CHECKPOINT 可以控制 WAL 文件大小
- 页面大小的选择:4096 适合大多数场景,8192 适合大记录
📌 业务场景
场景一:选择日志模式
你的应用以读操作为主,偶尔写入。WAL 模式允许读写并发,是最佳选择。
场景二:性能调优
发现查询较慢,通过 EXPLAIN 发现全表扫描。理解 B-Tree 结构后,为相关列创建索引。
场景三:空间管理
数据库文件占用空间远超数据量。通过 PRAGMA freelist_count 检查空闲页面,使用 VACUUM 回收空间。
🔗 承接阅读
📖 下一章:04 - SQL 基础 —— DDL、DML、DQL、类型亲和性