SQLite Journal Mode介绍
SQLite 以其轻量级, 易用性著称, 无需专业的数据库管理技能即可上手. 只需将SQLite库添加到项目, 便能立即开始执行SQL语句.
这种极简主义的上手体验, 可能会让人误以会以为为 SQLite 是完全无需配置的.
虽然 SQLite 的确无需繁琐的配置, 但仍有一些关键配置选项值得关注.
本文将深入探讨下: 每位 SQLite 开发者都必须理解的重要配置选项 -- Journal Mode (日志模式).
Journal Mode 的重要性
在深入细节之前, 先需要明确一个预期: 即将讨论的 Journal Mode 是一个相当复杂的主题, 涉及大量的技术细节.
但本文的目标是为读者提供一个关于其工作原理的宏观视角和一个实用的心智模型.
Journal Mode 可以通过以下 PRAGMA 语句进行设置:
PRAGMA journal_mode = ... ;
Journal Mode 的主要选项
SQLite 中主要有两种 Journal Mode 可供选择:
- ROLLBACK (回滚日志模式): 这是 SQLite 的默认模式.
- WAL (Write-Ahead Logging, 预写式日志模式): 也被称为 WAL 模式.
虽然 Rollback Journal 模式还有一些技术变体, 但对于绝大多数开发者而言, 只需关注这两种核心模式即可.
如何选择 Journal Mode? 要回答这个问题, 我们需要深入了解这两种模式的工作原理.
Rollback Journal Mode (回滚日志模式)
Rollback Journal Mode 是 SQLite 的默认模式. 为了便于理解, 我们不妨将运行 SQLite 应用的计算机想象成一个 Web 服务器 (但请记住, SQLite 的应用场景远不止 Web 服务器, 它同样适用于移动应用, 桌面应用, 物联网设备等等).
系统内部结构 (简化模型):
我们聚焦于计算机内部的磁盘驱动器, 它存储着主要的数据库文件以及一些 Journal 文件 (稍后会详细介绍). 同时, 我们关注内存中的操作系统缓冲区.
数据读写流程:
- 读取数据: 当建立数据库连接并执行查询时, 磁盘文件中的数据 首先被复制到操作系统缓冲区, 然后查询所需的数据从缓冲区复制到连接的用户空间内存中供应用使用.
- 写入数据: 当连接需要写入数据时, 修改会先在连接的内存中进行. 当更改需要保存时, 更新后的页面会被 复制回操作系统缓冲区, 最终 由操作系统负责将缓冲区的数据持久化到磁盘.
[开始 - 读取请求] [获取共享锁] [数据读取] [结束 - 读取完成]
+---------------------+ +--------------------+ +-------------------------+ +---------------------+
| 1. Read Request |--->| 2. Get Shared Lock |-->| 3. Data Copy from Disk |---->| 4. Read Complete |
| (inited by app) | | (OS Buffers) | | to OS Buffers & User Mem| | returen data to app |
+---------------------+ +--------------------+ +-------------------------+ +---------------------+
Rollback Mode - Write Operation (Journaling Stage)
[开始 - 写请求] [获取预留锁] [创建 Rollback Journal] [数据库页面修改(内存)] [fsync (Journal File)]
+---------------------+ +----------------------+ +--------------------+ +--------------------+ +------------------------+
| 1. Write Request |--->| 2. Get Reserved Lock |--->| 3. Create Rollback |----->| 4. Modify DB Pages |---->| 5. fsync (Journal File)|
| (inited by app) | | (OS Buffers) | | Journal File | | (In Memory) | | (Journal saved to disk |
+---------------------+ +----------------------+ +--------------------+ +--------------------+ +------------------------+
Rollback Mode - Write Operation (Commit Stage)
[开始 - 提交准备] [获取排他锁] [数据写入] [fsync (Database File)] [删除 Rollback Journal] [释放排他锁] [结束 - 写入完成]
+---------------------+ +-------------------------+ +------------------+ +-------------------------+ +---------------------+ +---------------------+ +---------------------+
| 1. Prepare to Commit|--->| 2. Get Exclusive Lock |--->| 3. Write Data to |---->| 4. fsync (Database File)|---->| 5. Delete Rollback >---->| 6. Release Exclusive|----->| 7. Write Complete |
| (log finished) | | (Pending Lock Stage) | | OS Buffers | | (saved to disk | | Journal File | | Lock | | (数据持久化完成) |
+---------------------+ +-------------------------+ +------------------+ +-------------------------+ +---------------------+ +---------------------+ +---------------------+
图例说明:
* 专注于写操作的后半部分,从获取排他锁到事务提交完成,强调排他锁的作用和数据库文件落盘及 Journal 文件删除。
图例说明:
* 专注于写操作的前半部分,从获取预留锁到 Journal 文件落盘,强调 Rollback Journal 的创建和 fsync 的重要性。
Rollback Mode - Crash Recovery
[System Startup]
[Database Connection]
|
V
+---------------------+
| Detect Journal File |-> No (流程结束 - No Recovery Needed)
| Exists? |
+---------+---------+-+
|
| Yes
V
+----------------------+
| Check Hot Journal |
| (Needs Rollback?) |-> No (流程结束 - No Rollback)
+---------+---------+--+
|
| Yes
v
+----------------------+
| Get Exclusive Lock |
| (Database File) |
+----------------------+
|
V
+----------------------+
| Replay Journal |
| (Rollback Txn) |
+----------------------+
|
V
+----------------------+
| Delete Hot Journal |
| File |
+----------------------+
|
V
+----------------------+
| Release Lock |
+----------------------+
|
V
[Recovery Done]
图例说明:
- 简化了读操作流程,突出共享锁的获取和数据从磁盘到内存的流动。
数据持久性问题:
仅仅将数据写入缓冲区就足够了吗? 如果数据库只是简单地将更改写入缓冲区, 并依赖操作系统最终刷新到磁盘, 那么对于一个需要 ACID (原子性, 一致性, 隔离性, 持久性) 特性的数据库来说, 这是 不可接受的. 因为如果系统崩溃或断电, 缓冲区中尚未刷入磁盘的数据将会丢失, 持久性 (Durability) 无法得到保证.
为了确保数据持久性, 对缓冲区的写入操作必须在关键时刻通过 fsync 系统调用强制刷新到磁盘. 稍后, 将看到 fsync 在不同 Journal Mode 中的作用.
Rollback Journal Mode 的读操作流程:
获取共享锁 (Shared Lock): 当连接需要查询数据时, 首先会在数据库文件上获取一个共享锁. 需要注意的是, 这个锁实际上是存储在 易失的操作系统缓冲区 中的文件锁.
- 共享锁的作用: 表明当前至少有一个读操作正在进行. 共享锁允许多个并发读操作同时进行, 但 会阻止并发的写操作.
重要提示: 在 Rollback Mode 下, 可以拥有多个并发的读取者, 但 不能同时存在读取者和写入者. 这可能在某些高并发场景下成为瓶颈. 不过, WAL Mode 提供了一种解决此限制的方法, 我们稍后会详细讨论.
数据读取: SQLite 从数据库文件 复制数据到操作系统缓冲区, 然后将读取的数据 复制到进程或连接的内存 中, 供应用程序使用. 通过将数据缓存到操作系统缓冲区, 后续对相同数据的读取操作可能会更快, 因为可以避免缓慢的磁盘 I/O.
Rollback Journal Mode 的写操作流程:
获取预留锁 (Reserved Lock): 写入数据的第一步是 在数据库文件上获取一个预留锁, 同样, 锁信息存储在操作系统缓冲区中.
- 预留锁与共享锁的共存: 预留锁可以与共享锁同时存在. 这意味着预留锁不会阻止正在进行的读操作.
- 预留锁的信号作用: 预留锁向 SQLite 发出信号, 表明 即将发生写操作, SQLite 需要做好准备.
- 预留锁的唯一性: 在任何时刻, 只能存在一个预留锁. 如果另一个进程也想执行写操作, 则需要等待当前写进程完成.
创建 Rollback Journal 文件: 创建 Rollback Journal 文件, 这个文件将在后续的事务处理中发挥关键作用.
Rollback Journal 的内容: Rollback Journal 的名称已经暗示了它的用途 -- 回滚. SQLite 是一个 ACID 兼容的数据库, 其中 A (Atomicity, 原子性) 代表着事务的原子性.
- 原子性 (Atomicity): 在一个事务中包含的多个 SQL 语句, 要么 全部成功执行 (Commit), 要么 全部失败回滚 (Rollback). 不存在事务执行一半的情况.
为了实现原子性, SQLite 必须具备 撤销 (Undo) 部分应用事务的能力, 即在事务执行失败时能够回滚到事务开始之前的状态. Rollback Journal 文件正是实现 "Nothing (失败回滚)" 这一部分的关键.
可以将 Rollback Journal 文件视为数据库在进行任何更改之前的一个部分副本或快照.
- Rollback Journal 的数据构成: 当即将对数据库进行写操作时, 首先会将 需要更新的数据库页面从磁盘读取出来, 然后将未修改的页面和一些元数据保存到 Rollback Journal 文件中.
- 总结: Rollback Journal 文件中存储的是 在发生错误时执行回滚操作 (撤销更改) 所需的数据.
修改数据库页面 (内存中): 在数据库连接的内存中 修改需要更新的数据库页面. 至此, 修改后的数据仍然只存在于内存中, 尚未持久化到磁盘.
fsync(第一次): 为了支持原子性, SQLite 会 调用fsync系统调用, 强制将 Rollback Journal 缓冲区中的数据物理写入磁盘.fsync操作的开销是比较大的, 请留意每种 Journal Mode 中fsync的调用次数, 这将直接影响性能.获取排他锁 (Exclusive Lock): 现在 Rollback Journal 已经持久化到磁盘, 且内存中的数据库页面也已更新, 接下来需要将这些更改提交到实际的数据库文件. 为了执行提交操作, SQLite 需要 获取数据库的排他锁. 如前所述, 在 Rollback Mode 下, 写操作必须是排他的, 不能与读操作并发执行.
- 排他锁的获取过程: 预留锁会先升级为 Pending Lock (等待锁), 然后在合适的时机进一步升级为 排他锁 (Exclusive Lock).
- Pending Lock 的作用: Pending Lock 的引入是为了 防止写入进程被读取进程饿死 (Starvation). 在持有预留锁期间, 写入进程可以进行一些准备工作, 而读取连接仍然可以自由地建立和断开. 当预留锁升级为 Pending Lock 后, 已有的读取连接仍然可以完成当前的读取操作, 但新的读取连接将被阻止建立. Pending Lock 的作用就像一个" 排水阀" , 逐渐耗尽现有的读取连接, 最终确保所有读取连接都断开, Pending Lock 才能升级为排他锁, 让写入进程获得独占访问权.
写入数据并
fsync(第二次): 写入进程获得排他锁后, 就可以将修改后的页面 写入操作系统缓冲区, 然后 SQLite 再次调用fsync, 强制将缓冲区中的数据库页面数据物理写入磁盘.删除 Rollback Journal 文件: 最后, 删除 Rollback Journal 文件. 此时, 写入事务才算真正提交 (Commit).
释放排他锁: 释放排他锁. 数据库恢复到可以接受新的读取或写入操作的状态.
异常处理 (崩溃恢复):
以上描述的是一切顺利的 "Happy Path" (正常流程). 现在我们来考虑异常情况 -- 系统崩溃或断电. 假设在写入操作过程中, 例如在数据写入磁盘的过程中, 系统突然崩溃. 此时, 可能事务的一部分操作已经完成, 但另一部分尚未完成, 数据库处于不一致的状态.
崩溃后的恢复流程:
检测 Rollback Journal 文件: 当系统重启后, 首次建立数据库连接时, SQLite 会检查是否存在 Rollback Journal 文件. 正常情况下, 上一次数据库连接在断开之前会删除 Rollback Journal 文件, 因此, 如果在启动时发现 Rollback Journal 文件, 则表明上次数据库操作可能异常终止.
检查 Hot Journal: SQLite 会进一步 检查该 Journal 文件是否为 "Hot Journal" (热日志). Hot Journal 指的是需要被 "播放" (Play Back, 即重放日志) 或 "回滚" (Rolled Back) 的 Journal 文件, 以便将数据库恢复到一致状态.
Hot Journal 的判断条件: 一个 Journal 文件被判定为 Hot Journal 需要满足以下所有条件:
- Rollback Journal 文件存在.
- Journal 文件非空.
- 主数据库文件上没有预留锁.
- Journal 文件的头部格式正确 (Well-formed).
- Journal 文件不引用 Super Journal (Super Journal 用于跨多个数据库的事务).
恢复操作 (回滚): 如果 Journal 文件被判定为 Hot Journal, SQLite 将执行以下恢复操作:
- 获取排他锁: 在数据库上获取排他锁.
- 重放 Journal (Rollback): 读取 Journal 文件内容, 将 Journal 文件中记录的数据重新应用到数据库文件, 实际上执行的是 回滚 (Rollback) 操作, 撤销未完成的事务, 将数据库恢复到事务开始之前的状态.
- 删除 Hot Journal 文件: 删除 Hot Journal 文件.
- 释放排他锁: 释放排他锁.
Rollback Journal Mode 工作原理总结:
以上就是 Rollback Journal Mode 的工作原理.
WAL (Write-Ahead Logging) Mode (预写式日志模式)
接下来, 我们来看看另一种 Journal Mode -- Write-Ahead Logging (WAL) Mode (预写式日志模式).
WAL Mode 的核心思想:
与 Rollback Journal Mode 的"先备份, 再修改"策略相反, WAL Mode 采用的是 "先写日志 (WAL 文件), 再修改数据库文件" 的策略. 在 WAL Mode 下, 所有的修改操作首先被追加 (Append) 到 WAL 文件中, 数据库文件本身在初始阶段保持不变. 在稍后的某个时刻, WAL 文件中的更改会被 "检查点 (Checkpoint)" 操作 写入数据库文件.
WAL Mode 的写操作流程:
- 复制页面到内存: 将需要修改的数据库页面 复制到连接的内存中.
- 修改页面: 在内存中 修改页面数据.
- 追加数据到 WAL 文件: 将修改后的数据 追加到 WAL 文件末尾.
fsync(第一次): 调用fsync, 强制将 WAL 缓冲区中的数据物理写入磁盘. 在 WAL Mode 下, 每次写入操作只需进行一次fsync, 这与 Rollback Mode 相比, 减少了fsync的次数, 提升了写入性能. 到目前为止, 数据修改仅存在于 WAL 文件中, 数据库文件本身尚未被修改.
WAL Mode - Write Operation
[Write Request]
(Application)
|
V
+-------------------------+
| 1. Modify DB Pages |
| (In Memory) |
+-------------------------+
|
V
+-------------------------+
| 2. Append Data to WAL |
| File |
+-------------------------+
|
V
+-------------------------+
| 3. fsync (WAL File) |
| (WAL File Persisted) |
+-------------------------+
|
V
[Write Complete]
(Data in WAL File)
WAL Mode - Read Operation
[Read Request]
(Application)
|
V
+-------------------------+
| 1. Reader Marks In-Mark |
| (Last Commit in WAL) |
+-------------------------+
|
V
+-------------------------+
| 2. Check WAL File |
| (Before In-Mark?) |
+---------+---------------+
| |
| Yes | No
V V
+-------------------------+ +-------------------------+
| 3a. Read Page from WAL | |3b. Read Page from DB File|
+-------------------------+ +-------------------------+
| |
\---------OR------------/
V
[Read Complete]
(Data Retrieved)
WAL Mode - Checkpoint Process
[Checkpoint Trigger]
(WAL Size Threshold Reached)
|
V
+-------------------------+
| 1. Sync WAL Data to |
| Database File |
+-------------------------+
|
V
+-------------------------+
| 2. fsync (Database File)|
| (DB File Persisted) |
+-------------------------+
|
V
+-------------------------+
| 3. Truncate/Remove WAL |
| File |
| (WAL File Cleaned) |
+-------------------------+
|
V
[Checkpoint Complete]
(WAL File Cleaned)
WAL 文件增长与 Checkpoint 操作:
后续的写入操作会重复步骤 1-4, 不断将修改追加到 WAL 文件末尾. WAL 文件会持续增长, 直到达到某个阈值, 此时会触发一个名为 "Checkpoint (检查点)" 的特殊操作. 默认情况下, 当 WAL 文件累积写入 1000 页或更多时, 会触发 Checkpoint 操作.
- Checkpoint 触发示例: 假设 WAL 文件当前有 999 页数据, 此时又有一个写操作, 将数据追加到 WAL 文件后, WAL 文件大小达到 1000 页, 触发 Checkpoint 操作.
- Checkpoint 操作的执行者: 触发 Checkpoint 操作的连接 (事务) 会负责执行 Checkpoint 操作.
Checkpoint 操作:
Checkpoint 操作的核心任务是将 WAL 文件中的数据 "搬运" 到数据库文件 中, 从而将 WAL 文件中的修改同步到数据库文件.
WAL Mode 的并发性优势 (读写并发):
WAL Mode 最吸引人的特性之一是它 允许多个读取者和一个写入者并发执行. 写入者无需像 Rollback Mode 那样获取数据库的排他锁. 那么, SQLite 如何保证写入者在修改 WAL 文件的同时, 不会干扰到正在读取数据的读取者呢?
WAL Mode 的读操作流程 (读写并发的关键):
读取者的 "In-Mark": 当一个读取连接启动时, 它会 标记 WAL 文件中最后一个有效的 Commit 记录, 称为 "In-Mark". 这个 In-Mark 标志着该读取连接的 "数据快照" 的时间点.
读取数据: 当读取连接需要读取某个数据页时, 它会 首先检查 WAL 文件, 查看所需页面是否出现在 In-Mark 之前的 WAL 文件段中.
- 如果在 WAL 文件中找到: 如果找到了, 则使用 WAL 文件中的页面数据, 因为 WAL 文件中的数据更新, 更 "新".
- 如果未在 WAL 文件中找到: 如果在 WAL 文件中找不到, 则从数据库文件中读取页面数据.
并发写入不干扰读取: 在读取连接读取数据的同时, 写入连接可以并发地将新的页面数据追加到 WAL 文件末尾. 但读取连接永远不会考虑 In-Mark 之后写入 WAL 文件的任何页面. 同时, 写入者也不会 Checkpoint 任何 In-Mark 之后的页面. 通过这种机制, WAL Mode 实现了读写操作的并发执行, 互不干扰. 这显著提升了数据库的 最大读取吞吐量 (Read Throughput). 但需要注意的是, WAL Mode 仍然只允许一个写入者 (Single Writer) 并发执行.
WAL Mode 的异常处理 (崩溃恢复):
WAL Mode 如何处理异常情况? 例如, 当写入连接正在向 WAL 文件写入数据时, 系统发生崩溃.
崩溃后的恢复流程:
检测 WAL 文件: 系统重启后, SQLite 启动时会 检测到 WAL 文件的存在, 这表明上次数据库操作可能异常终止.
检查 Commit 记录: SQLite 会检查 WAL 文件, 判断 WAL 文件中是否存在 "Commit 记录". Commit 记录标志着一个事务的完整提交.
- 如果 WAL 文件中缺少 Commit 记录: 表明上次写入操作 只完成了一部分数据写入, 但事务并未完整提交. SQLite 会直接忽略 WAL 文件中这部分不完整的数据, 视该事务为已回滚.
Checkpoint 过程崩溃的处理:
另一种情况是, 崩溃发生在 Checkpoint 操作正在进行时, 即 WAL 文件中的数据正在被写入数据库文件时. 此时, 数据库文件中的页面可能处于一种 "Suspect State" (不确定状态).
Checkpoint 崩溃后的恢复:
在这种情况下, 不需要执行额外的恢复操作来修复数据库文件. 即使数据库文件中的某些页面处于不确定状态, 也不会影响数据的完整性. 因为 WAL 文件中仍然保留着这些页面的有效版本, 并且 WAL 文件中的数据具有更高的优先级.
在稍后的某个时刻, Checkpoint 操作会 再次自动启动, 重新尝试将 WAL 文件中的页面数据 "搬运" 到数据库文件. Checkpoint 操作是幂等的 (Idempotent), 多次执行结果一致, 因此重复执行 Checkpoint 操作不会导致数据错误. Checkpoint 操作最终会 修复数据库文件中可能处于 "Suspect State" 的页面, 并将 WAL 文件中的内容移除.
WAL Mode 的优缺点总结:
优点:
- 速度更快 (Faster): WAL Mode 通常比 Rollback Mode 更快, 特别是在 写入密集型应用 中.
- 更高的并发性 (Better Concurrency): WAL Mode 支持 读写并发, 允许多个读取者和一个写入者同时操作数据库, 显著提升了 读取吞吐量.
- 减少磁盘写入量 (Less Disk Writes): WAL Mode 的写入操作主要是 顺序追加 WAL 文件, 磁盘写入量更少.
- 更少的
fsync调用 (Less Fsyncs): WAL Mode 每次写入事务只需调用一次fsync, 而 Rollback Mode 可能需要多次fsync. - 更高效的
fsync(Efficient Fsyncs): Checkpoint 操作可以将 多次事务的写入操作批量提交到数据库文件, 摊销fsync的开销.
缺点:
- 不适用于网络文件系统 (No Network File Systems): WAL Mode 不适用于基于网络文件系统的数据库文件, 例如 NFS (Network File System).
- 不支持跨多数据库文件的原子提交 (No Atomic Commit across Multiple Databases): WAL Mode 不支持跨多个独立数据库文件的原子事务提交.
- 读密集型场景下可能略慢 (Slightly Slower in Read-Heavy Applications): 对于 绝大多数操作都是读取, 只有极少量写入的应用, WAL Mode 的性能提升可能不明显, 甚至可能略微慢于 Rollback Mode.
如何选择 Journal Mode?
选择哪种 Journal Mode 取决于具体应用场景和需求, 需要权衡各自的优缺点. 但对于大多数应用场景, 强烈建议考虑使用 WAL Mode. WAL Mode 在 性能, 并发性, 可靠性 方面通常都更具优势.
总结
深入理解 SQLite 的 Journal Mode 对于优化数据库性能和确保数据可靠性至关重要. 希望本文能够帮助更好地掌握这两种 Journal Mode 的工作原理, 并根据实际情况做出明智的选择.