SQLite 的 WAL 模式:为什么开了它读写就不再打架
上周排查一个线上问题:一个单机服务用 SQLite 存配置和任务队列,平时好好的,一到日志分析任务跑起来,API 接口就集体卡顿几百毫秒。查到最后不是 CPU 也不是连接池,是 SQLite 默认日志模式下的读写互斥。改成 WAL 之后,同样负载下接口的 P99 从 600ms 掉到了 20ms 以内。
这篇把 WAL 的原理、适用场景和踩过的坑讲清楚。
一、默认的 rollback journal 到底慢在哪
SQLite 默认使用 rollback journal(回滚日志)模式。一次写事务的流程大致是:
- 把要修改的页的**原始内容**复制到 `xxx.db-journal` 文件
- 把修改直接写回主数据库文件
- 提交成功后删除 journal
问题出在第 2 步:写操作是**直接覆盖主库文件**的。所以在写入过程中,任何读操作都必须等它写完,否则可能读到写了一半的页。SQLite 用文件锁来实现这个互斥——写的时候持排他锁,读的时候持共享锁。读写不能并发,这就是卡顿的根源。
放到那个场景里就很好理解了:日志任务在批量写 10 万行,持写锁的时间被拉得很长,这期间所有接口的读请求全在排队。
二、WAL 改了什么
WAL(Write-Ahead Logging)的核心思路是把"原地覆盖"换成"追加写":
- 所有修改先追加到 `xxx.db-wal` 文件,主库文件**暂时不动**
- 读操作依然读主库,但会先在 WAL 里检查有没有该页的更新版本,有就用新的
这样一来,写只在追加 WAL,读在读主库,两者操作的是不同的文件区域,**天然不冲突**。多个读可以同时进行,读写也可以同时进行。
真正的"落地"发生在 checkpoint:当 WAL 文件增长到一定阈值(默认 1000 页),SQLite 会把 WAL 里的修改合并回主库。这一步需要短暂阻塞,但它是批量的,摊薄到单次事务上开销很小。
三、怎么开
只有一行 PRAGMA,而且只需要执行一次——WAL 是个**持久化设置**,写进数据库头,之后每次打开都自动生效:
PRAGMA journal_mode = WAL;
验证一下:
PRAGMA journal_mode; -- 返回 wal 就成功了
如果你用 Python:
import sqlite3
conn = sqlite3.connect("app.db")
print(conn.execute("PRAGMA journal_mode=WAL").fetchone()) # ('wal',)
```
顺手还建议调两个参数:
PRAGMA synchronous = NORMAL; -- WAL 下的推荐值,掉电最多丢最后一个事务 PRAGMA wal_autocheckpoint = 1000; -- 默认值,一般不用改
`synchronous = NORMAL` 是官方在 WAL 模式下的推荐配置。在默认的 `FULL` 下,每次提交都要 fsync 两次;降到 `NORMAL` 后只在 checkpoint 时 fsync。代价是极端掉电场景可能丢失最近一个事务,但对大多数业务完全可接受——毕竟你本来也不该拿 SQLite 存"绝对不能丢"的核心账务数据。
四、代价和坑
WAL 不是银弹,有几个必须知道的限制:
**第一,产生了额外的文件。** 除 `app.db` 外,旁边会多出 `app.db-wal` 和 `app.db-shm`(共享内存索引)。备份的时候如果把 `app.db` 单独拷走,会漏掉 WAL 里还没 checkpoint 的数据。正确做法是用 `sqlite3 app.db ".backup backup.db"` 或者 `VACUUM INTO 'backup.db'`。
**第二,WAL 模式下不能跨网络文件系统。** NFS、SMB 这类共享挂载上,`shm` 文件的 mmap 行为不可靠,SQLite 会直接拒绝启用 WAL(退回 rollback journal),或者行为诡异。数据库文件必须放在本地盘上。
**第三,WAL 只能单写者。** 并发读解决了,但写入仍然是串行的——同一时刻只有一个连接能写。如果你的场景是"多进程同时大量写",SQLite 本身就不是合适的选择,该上 PostgreSQL 了。
**第四,只读副本的坑。** 如果数据库在只读介质上打开,WAL 相关的恢复流程可能失败。这种情况要显式指定 `PRAGMA journal_mode=DELETE` 或者用 `file:app.db?mode=ro&immutable=1`。
五、什么时候值得开
满足这几条就该开:
- 读多写少,或读写混合但写不是高频
- 数据库文件在本地盘
- 有多个连接/进程同时访问
- 读请求的延迟敏感(接口、实时查询)
如果只有一个进程、串行地读写、压根没有并发,那开了也没坏处,只是收益不明显。
反过来,如果写入非常密集、单机吞吐顶到 fsync 上限(一般几百到几千 TPS),WAL 也救不了你,因为瓶颈已经从锁变成了磁盘刷盘。这时候考虑批量事务、`BEGIN IMMEDIATE` 合并写,或者换数据库。
六、小结
WAL 的本质是把"原地修改"换成"追加日志 + 读时合并",用一份额外文件换掉了读写锁的互斥。它是一个只需要一行 PRAGMA、零迁移成本、收益却通常很显著的优化——如果你手上还有没开 WAL 的 SQLite,值得今天就打开看看。
*适用版本:SQLite 3.7.0+(2010 年引入 WAL)。文中参数以 3.40+ 实测为准。*
- 点赞
- 收藏
- 关注作者
评论(0)