强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

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 字节),页面结构如下:

偏移大小说明
01页面类型(0x02=内部索引,0x05=内部表,0x0a=叶子索引,0x0d=叶子表)
1-22第一个空闲块偏移
3-42单元格数量
5-62单元格内容区偏移
71碎片空闲字节数
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 JournalWAL
读写并发读阻塞写,写阻塞读读写可并发
文件数量数据库 + 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 类型平台说明
unixLinux/macOSPOSIX 文件操作
unix-dotfilesUnix使用点文件作为锁
win32WindowsWin32 API 文件操作
win32-longpathWindows支持长路径
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;  -- 内存映射大小

⚠️ 注意事项

  1. WAL 文件不要手动删除——.wal.shm 文件是 WAL 模式必需的,手动删除会导致数据丢失
  2. 页面大小只能在建表之前修改——已有数据时修改页面大小需要 VACUUM
  3. 网络文件系统上不要使用 WAL 模式——NFS/SMB 上使用 WAL 可能导致数据库损坏
  4. 大事务可能导致 WAL 文件膨胀——长时间不 checkpoint 会使 WAL 文件增长
  5. VACUUM 会重写整个数据库文件——大数据库执行 VACUUM 时需要足够的磁盘空间

💡 技巧

  1. 理解 EXPLAIN 输出有助于优化查询——可以看到 SQLite 是否使用了索引
  2. WAL 模式是大多数场景的首选——读写并发性能更好
  3. 定期 CHECKPOINT 可以控制 WAL 文件大小
  4. 页面大小的选择:4096 适合大多数场景,8192 适合大记录

📌 业务场景

场景一:选择日志模式

你的应用以读操作为主,偶尔写入。WAL 模式允许读写并发,是最佳选择。

场景二:性能调优

发现查询较慢,通过 EXPLAIN 发现全表扫描。理解 B-Tree 结构后,为相关列创建索引。

场景三:空间管理

数据库文件占用空间远超数据量。通过 PRAGMA freelist_count 检查空闲页面,使用 VACUUM 回收空间。


🔗 承接阅读


📖 下一章04 - SQL 基础 —— DDL、DML、DQL、类型亲和性