默认模式及其局限
默认情况下,SQLite 使用回滚日志(rollback journal)。写入时,SQLite 先把原始页复制到 -journal 文件,再修改主数据库,提交时删除日志。如果进程在写入中途崩溃,就反向回放日志,撤销写了一半的改动。
这种方式简单又安全,但有一个让人头疼的特点:写者和读者争抢同一个文件。写者持有数据库锁时,任何读者都无法开始新事务;有读者在活动时,写者只能等待。在繁忙的应用里,比如一个同时处理几个请求的 Web 服务器,你会比预想中更早看到 SQLITE_BUSY 错误。
WAL 模式改变了这一点。
WAL 实际上做了什么
预写日志把模型反了过来。写者不再原地修改主数据库文件,而是把已提交的页追加到一个以 -wal 结尾的单独文件中。读者继续读取主文件,同时也会查看 WAL,找到所需页的较新版本。
结果是:一个写者和任意数量的读者可以在同一时刻活动。每个读者看到的是自己事务开始时的一致快照,写者则忙着往 WAL 里追加内容,不会碰到读者正在看的数据。
这一条 pragma 就能切换数据库的模式。这个模式是持久的:它保存在文件头里,之后的每个连接都会自动使用 WAL。你不需要在每个连接上执行它,只要在创建数据库时(或在迁移脚本里)执行一次即可。
这条 pragma 会返回新的模式。如果返回 wal,就说明设置成功了。如果返回别的值,很可能是文件系统不支持共享内存(下文会详细说明)。
开启与验证
你随时可以确认当前的模式:
第一条调用开启 WAL 并返回新模式,第二条(不带 =)只是查询当前模式。之后,只要有活动,messages.db 所在目录里就会有三个文件:messages.db、messages.db-wal 和 messages.db-shm。后两个文件会随着连接的打开和关闭出现或消失。
-wal 和 -shm 文件
WAL 会带来两个额外的文件,你应该了解它们的作用:
-wal保存已经提交、但还没合并回主数据库的事务。它随着写入不断变大,在检查点时缩小(或重置)。-shm是一个共享内存文件。它是 WAL 的索引,让所有连接对“哪个页在哪里”达成一致,而不必每次查询都扫描 WAL。
实际影响是:复制 WAL 模式的数据库时,绝不能只复制 .db 文件。最新的数据在 -wal 里,少了它,你的副本要么是过时的,要么已经损坏。要么在没有连接写入时把三个文件一起复制,要么(更好的做法)使用 SQLite 备份 API(下一章会讲到)。
并发:一个写者,多个读者
WAL 并不能让你并发写入。SQLite 仍然让写入逐个进行:任何时刻都只有一个事务持有写锁。改变的是,写不阻塞读,读也不阻塞写。
所以,一个运行在 WAL 上的典型 Web 应用表现如下:
- 以读为主的接口并行运行,互不争抢。
- 写接口会短暂排队,但不会阻塞读取。
- 长时间运行的读者(分析查询、数据导出)不会让写者等待。
如果两个连接同时尝试写入,第二个会得到 SQLITE_BUSY。通常的解决办法是设置一个合理的忙等待超时,让 SQLite 先等一会儿再放弃:
busy_timeout=5000 的意思是“如果锁被占用,最多等 5 秒再报错”。配合 WAL,这足以应对大多数应用实际遇到的争用。BEGIN IMMEDIATE 会在事务开始时就获取写锁,而不是等到第一次写入时,这样当多个连接都打算写入时,可以避免一类锁升级导致的死锁。
检查点:把 WAL 合并回去
WAL 文件不能无限增长。检查点(checkpoint)就是把 WAL 中已提交的页写入主数据库,然后重置 WAL 的过程。
当 WAL 超过约 1000 页(默认的 wal_autocheckpoint)时,SQLite 会自动执行检查点。大多数应用保持默认即可。如果你想调整它,或者手动触发一次:
wal_checkpoint pragma 接受一个模式:
PASSIVE:在不打扰读者和写者的前提下尽量完成检查点。这是默认模式。FULL:等待活动中的写者完成,然后把所有已提交的内容写入检查点。RESTART:与 FULL 相同,另外阻止新的读者使用旧的 WAL。TRUNCATE:与 RESTART 相同,另外把 WAL 文件截断为零字节。
大多数服务器从不需要手动调用它。如果你在开发一个桌面应用,希望关闭时让文件大小保持整洁,那么在关闭最后一个连接之前执行一次 TRUNCATE 检查点是个不错的习惯。
与 WAL 搭配使用的几个 pragma
单独使用 WAL 已经很好。生产环境的应用通常会把 WAL 和另外几个设置一起使用:
简单介绍一下:
synchronous=NORMAL是推荐与 WAL 搭配的设置。它能抵御应用崩溃和操作系统崩溃;只有恰好在错误的时刻断电,才可能丢失最近的几个事务,即便如此数据库也会保持一致。默认的FULL更安全,但明显更慢。busy_timeout上文已经讲过。foreign_keys=ON与 WAL 无关,但值得在每个连接上设置:为了向后兼容,SQLite 默认不检查外键约束。
这些设置都只对当前连接生效(journal_mode 除外,它会保留下来)。在应用代码中打开连接后立刻执行它们。
什么时候不该用 WAL
WAL 是默认推荐的选择,但有几种情况并不适合:
- 网络文件系统。 WAL 依赖访问数据库的各个进程之间的共享内存(
mmap)。NFS、SMB 之类的文件系统无法可靠地支持这一点。如果你的数据库放在网络共享上,就继续使用回滚日志;更好的做法是,不要把 SQLite 放在网络共享上。 - 只读介质。 WAL 需要写入
-wal和-shm文件。放在 CD-ROM 之类介质上的数据库必须使用不写文件的日志模式(或者用mode=ro以只读方式打开)。 - 没有并发读者的单写者批处理任务。 WAL 不会带来坏处,但你也得不到任何好处。默认的回滚日志就够用了。
对于 95% 的应用,包括 Web 后端、桌面应用、移动应用、带本地存储的嵌入式设备,WAL 都是正确的选择。
一套贴近实际的配置
下面是大多数生产环境 SQLite 配置的样子,浓缩成了可以运行的 pragma:
temp_store=MEMORY 把临时表和索引放在内存而不是磁盘上。如果内存有富余,这是一个零成本的小优化。
在应用的数据库初始化代码里,于建立连接时把这些设置好,你就解决了 SQLite 应用在并发负载下保持良好表现所需的大部分工作。
下一章:备份与恢复
既然你的数据库现在多了 -wal 和 -shm 两个伙伴文件,直接复制文件就不再是安全的备份方式了。下一章介绍备份运行中的 SQLite 数据库的正确方法:.backup 命令、在线备份 API,以及在不让应用下线的情况下获取一致快照的办法。
常见问题
SQLite 中的 WAL 模式是什么?
WAL 是 write-ahead logging(预写日志)的缩写。SQLite 不再把改动直接写进主数据库文件、出错时再靠回滚日志撤销,而是把改动追加到单独的 -wal 文件里,定期再合并回主文件。最大的好处在并发上:读者和一个写者可以同时工作,互不阻塞。
SQLite 中如何开启 WAL 模式?
执行一次 PRAGMA journal_mode=WAL; 即可。这个设置是持久的:它保存在数据库文件头里,所以之后的连接都会自动使用 WAL。你不需要在每个连接上设置它。执行成功时,这条 pragma 会返回新的模式(wal)。
WAL 模式支持并发写入吗?
不支持。SQLite 仍然让写入逐个进行,同一时间只有一个写者能持有写锁。WAL 改变的是:读者不再阻塞写者,写者也不再阻塞读者。对大多数应用来说,真正的瓶颈正是这一点。
-wal 和 -shm 文件是什么?
-wal 文件保存已经提交、但还没合并回主数据库的改动。-shm 文件是一个很小的共享内存索引,帮助各个连接快速找到 WAL 里的页。两者都会自动重新创建,但如果你要复制数据库,就必须把它们一起复制,或者使用备份 API。