SQLite 内部架构:一个文件如何保护事务
单个文件如何做到 ACID。页面与 B+tree 的布局、从回滚日志到 WAL 的演进、控制并发访问的五个锁状态,以及四条把性能拉满的 PRAGMA 设置,开 WAL、降 synchronous、加 cache_size、开 mmap。 设置(
中文
复制
引言
一份实用指南,讲解 SQLite 如何在单个文件中实现 B 树、WAL 和锁,从而实现安全的事务。
在现代软件开发中,数据库不可或缺。其中,“SQLite”可以说是世界上使用最广泛的数据库引擎之一,从智能手机应用、嵌入式系统到 Web 浏览器,甚至小规模 Web 服务器,都能见到它的身影。SQLite 最显著的特点,正如其名,是“Lite”,而最重要的是它将全部数据存储在一个文件中的架构。与 MySQL 或 PostgreSQL 等客户端-服务器数据库不同,SQLite 作为一个库,直接在应用程序的进程内运行。然而,尽管其单文件结构简单,SQLite 却支持具有完整 ACID(原子性、一致性、隔离性、持久性)属性的事务。即使被多个进程同时访问,数据也不会损坏。在本文中,我们将深入探讨 SQLite 的内部架构(B 树、WAL、锁机制),并从实践角度解释这一神奇机制是如何实现的。
1. 单文件的魔力:页与 B 树架构
从操作系统的角度看,SQLite 数据文件不过是一个简单的二进制文件。然而,在内部,SQLite 通过将文件划分为固定大小的块(通常为 4KB)来管理它,这些块被称为“页”。
页结构
整个文件通过从 1 开始的页号进行索引。第 1 页是一个特殊页,包含数据库头信息(版本、页大小、编码等)以及一个特殊表(sqlite_schema)的根节点,该表存储数据库模式信息。每个页具有以下角色之一:
- B 树页:存储表数据和索引数据
- 空闲列表页:已被删除、现为空闲空间的页
- 指针映射页:用于跟踪页移动的页(在启用特定功能时)
使用 B 树进行数据管理
为了高效地搜索、插入和删除数据,SQLite 采用了 B 树数据结构。具体来说,它对表数据使用“B+树”(仅在叶节点存储数据),对索引数据使用“B 树”(内部节点也存储键)。
graph TD
Root["Root Page (Page N)"] --> Internal1["Internal Page (Page X)"]
Root --> Internal2["Internal Page (Page Y)"]
Internal1 --> Leaf1["Leaf Page (Data 1-10)"]
Internal1 --> Leaf2["Leaf Page (Data 11-20)"]
Internal2 --> Leaf3["Leaf Page (Data 21-30)"]
Internal2 --> Leaf4["Leaf Page (Data 31-40)"]
得益于这种层次结构,即使有数百万条记录,也只需几次磁盘 I/O(页读取)就能到达所需数据。这种复杂的树结构被映射在单个文件内。
2. 事务保护机制:从回滚日志到 WAL
数据库最重要的任务之一是“崩溃恢复能力”。即使写入过程中发生断电或操作系统冻结,也必须保证数据不会陷入不一致状态。历史上,SQLite 采用的是名为“回滚日志(Rollback Journal)”的技术,而如今,在性能和并发性上更胜一筹的“WAL(Write-Ahead Logging,预写式日志)”模式已成为主流。
旧方法:回滚日志
回滚日志的做法是,在改写数据之前,先把待修改页面的“修改前状态”复制到一个单独的文件(日志文件)中。 如果事务失败或发生崩溃,下次启动时就利用这个日志文件“回滚”(倒回)更改,恢复一致性。这种方法最大的缺点在于:“写入操作进行期间,其他进程连读都读不了(整个数据库被锁住)。”
新方法:WAL(预写式日志)
WAL 模式在 SQLite 3.7.0 及之后的版本中引入,大幅改善了上述并发问题。在 WAL 模式下,被修改的页面不会直接写回原数据库文件,而是追加到另一个文件(.wal 文件)的末尾。
sequenceDiagram
participant App as Application
participant DB as db file
participant WAL as .wal file
App->>WAL: Start Transaction
App->>WAL: Append changed pages (Write)
Note over WAL: Data exists
only in WAL
App->>WAL: Commit
App->>DB: Read
Note over App, DB: Latest data is fetched
by referencing both WAL and DB
WAL 的优势:
- 并发性提升:写入操作以追加方式写入
.wal文件,因此不会阻塞读取原数据库文件的“读操作”。这意味着一个写入和多个读取可以同时进行。 - 性能提升:不再随机改写磁盘上的位置,而是顺序追加,因此磁盘 I/O 性能更高。
WAL 文件中累积的更改,会在达到一定大小或显式执行命令时写回原数据库文件。这一过程称为“Checkpoint(检查点)”。
3. 控制并发访问:锁机制
当多个进程(或线程)同时访问 SQLite 这个单一文件时,为了防止数据冲突,锁机制不可或缺。
SQLite 的锁状态
一个 SQLite 数据库连接会处于以下五种锁状态之一:
- UNLOCKED:连接未访问数据库。
- SHARED:用于读取数据的锁。多个连接可以同时获取 SHARED 锁(即可以并发读)。
- RESERVED:声明将来要写入数据的锁。整个数据库只能有一个连接获取它。即使处于该状态,其他连接仍可继续获取 SHARED 锁。
- PENDING:写入准备已完成,正在等待当前活跃的 SHARED 锁释放。此时新的 SHARED 锁获取会被阻塞。
- EXCLUSIVE:真正执行写入的锁。处于该状态时,其他连接既不能读也不能写。
锁升级
在开启事务读写数据时,SQLite 会自动分阶段提升这些锁状态(即锁升级)。
- 执行
SELECT会获取 SHARED 锁。 - 尝试执行
INSERT或UPDATE会先获取 RESERVED 锁。 - 到真正提交事务、将改动写入文件时,会经由 PENDING 尝试获取 EXCLUSIVE 锁。
如果另一个进程长时间持有 SHARED 锁,写入进程就无法获取 EXCLUSIVE 锁,从而报出 SQLITE_BUSY(database is locked)错误。
Busy Timeout 设置
在应用开发中,处理这个 SQLITE_BUSY 错误最简单也最有效的办法是设置 超时(busy_timeout)。
1 | PRAGMA busy_timeout = 5000; -- Wait for 5000 milliseconds (5 seconds) |
|---|
设置之后,获取不到锁时不会立刻返回错误,而是在指定时间内反复重试。只要超时设置得当,中小规模的并发访问基本可以避免这类错误。
4. 性能最大化的最佳实践
了解了 SQLite 的内部结构之后,下面是一些能让应用性能和安全性最大化的实用设置(PRAGMA)。
1. 启用 WAL 模式
如前所述,只要存在并发访问,这一步就必不可少。
1 | PRAGMA journal_mode = WAL; |
|---|
2. 优化同步模式
配合 WAL 模式,把同步模式降到 NORMAL,写入性能会大幅提升,而数据损坏的风险依然极低。
1 | PRAGMA synchronous = NORMAL; |
|---|
3. 增大内存缓存
让 SQLite 在内存中缓存更多页,就能减少磁盘 I/O。(默认值为 2000 页)
| 1 2 | -- 将 cache size 设为负值即以 KB 为单位。下面是 64MB。 PRAGMA cache_size = -64000; |
|---|---|
4. 用 mmap 加速读取
启用内存映射 I/O(mmap)后,可以借助操作系统的虚拟内存机制直接访问文件,从而加快读取速度。
1 | PRAGMA mmap_size = 30000000000; |
|---|
结语
SQLite 外表极其简单,只是“一个文件”,内里却藏着由 B 树构成的精巧数据结构、基于 WAL 的高级事务管理,以及一套精密的锁机制。认为它“轻量,所以干不了正经活”是极大的误解。只要正确理解它的内部架构并施加合适的设置(比如启用 WAL 模式、设置超时),SQLite 就能展现出惊人的性能与稳定性。下次为项目挑选数据库时,这个“世界上使用最广泛的数据库”或许才是最合理的选择。
来源: kenji.blog← 返回首页