SQLite 并发写入性能瓶颈,可能只是 WAL 没开
上个月帮一个项目做性能诊断,场景不算复杂:一个内部管理系统,用 SQLite 做本地缓存和元数据存储。SQLite 的优势在这里是实打实的——没有独立数据库进程、部署零配置、查询延迟低。但问题出在并发写入上。
业务本身是单用户场景,某个定时任务会批量更新配置数据,同时前台页面偶尔会发起查询请求。理论上 SQLite 完全扛得住。但实际观察到的现象是:批量写操作执行时,读请求会明显变慢,有时候卡几百毫秒,严重时甚至超时。
最初的直觉是"查询没走索引"或者"事务太大",排查后发现 SQL 本身没问题。真正把问题定位清楚的,是从 SQLite 的并发模型开始的——默认情况下,WAL 没开,整个写入过程中读者会被阻塞。
为什么 SQLite 并发表现不如预期

很多人对 SQLite 有一个印象:这是轻量级数据库,适合嵌入式场景,多用户并发不是它的强项。这个印象对了一半。
SQLite 确实是单文件数据库,但不代表它不支持并发。核心矛盾在于它的锁策略设计——默认使用 rollback journal(DELETE 模式),这个模式下,写事务启动后会立即锁定整个数据库文件。也就是说,只要有一个写事务在执行,所有读请求都会被阻塞,直到写事务提交或回滚。
反过来也一样:如果有一个读事务正在进行,写事务也会被阻塞,等待读事务完成。
-- 查看当前数据库的 WAL 状态
PRAGMA journal_mode;
-- 返回值如果是 DELETE,说明用的是传统 journal 模式
这个问题在"读写交替频繁"的场景下会被放大。比如刚才那个项目,批量写操作的事务跨度比较长,期间有查询请求进来,这些查询只能排队等。表面上看是"查询慢",实际上是"等待写锁释放"。
传统的 rollback journal 有一个更深层的设计原因:它需要保证 ACID 中的 A(Atomicity)。写操作先记录日志,再修改数据页,崩溃后可以通过日志恢复。这种模式的代价就是写期间锁住数据库。
WAL 的出现,正是为了在保持 ACID 的前提下,打破这种互斥。
WAL 的工作原理与取舍
WAL 的全称是 Write-Ahead Logging,直译是"预写日志"。但这个名称容易让人误解——它和普通关系型数据库的 redo log 不是一回事。SQLite 的 WAL 是一个追加式的日志文件,写入操作会先追加到 WAL 文件中,然后后台线程负责将数据刷回到主数据库文件,这个过程叫 checkpoint。
理解了 WAL 的物理结构,才能理解它为什么能改善并发。
传统模式下,数据库文件同时承载读和写,读写之间必然产生锁竞争。WAL 模式下,写入操作发生在独立的 WAL 文件里,读操作则直接从主数据库文件读取。这两条路径天然隔离:
- 写操作追加 WAL 文件,不会阻塞读操作
- 读操作读取主数据库文件,不会被写操作阻塞
- 后台 checkpoint 线程负责把 WAL 中的数据合并回主数据库文件
但这里有一个关键细节需要注意:WAL 模式下,仍然只允许一个写者。多个读者可以同时存在,但多个写者必须串行。这一点在文档里写得比较隐晦,很多人误以为 WAL 支持多写者并发,实际上它只是解决了"读写互斥"的问题,没有解决"写写互斥"的问题。
-- 开启 WAL 模式
PRAGMA journal_mode = WAL;
-- 开启后会自动生成三个文件
-- database.db (主数据库文件)
-- database.db-wal (WAL 日志文件)
-- database.db-shm (共享内存文件,用于进程间协调)
第三个 .shm 文件是 WAL 模式的配套产物,用于管理 WAL 文件的 checkpoint 和reader/writer 协调,不要删除它,否则会导致数据不一致。
WAL 的设计取舍也很明确:用额外的磁盘 IO 和文件数量,换取读写并发的提升。checkpoint 期间会发生一次数据合并,这期间会有短暂的 I/O 压力。如果 checkpoint 策略设置不当,反而可能引入新的性能问题。
Checkpoint 策略:最容易踩坑的地方
WAL 模式打开之后,checkpoint 的行为直接影响整体性能。SQLite 提供了五种 checkpoint 模式:
-- 被动 checkpoint:只在 WAL 文件超过阈值时才触发,不主动推进
PRAGMA wal_checkpoint(PASSIVE);
-- 完整 checkpoint:阻塞等待所有 reader 完成后,将 WAL 数据全部刷回数据库
PRAGMA wal_checkpoint(FULL);
-- 截断 checkpoint:类似于 FULL,但还会清空 WAL 文件
PRAGMA wal_checkpoint(TRUNCATE);
-- 重启 checkpoint:数据库关闭时自动执行
PRAGMA wal_checkpoint(RESTART);
-- 自动 checkpoint:由后台线程控制
PRAGMA automatic_checkpoint_interval = 1000;
默认情况下,SQLite 使用 PASSIVE 模式——只有 WAL 文件超过一定阈值才会触发 checkpoint,而且不会阻塞 reader。这意味着 WAL 文件会持续增长,占用额外磁盘空间。对于写入量大的系统,这个文件可能膨胀到和主数据库相当甚至更大的体积。
automatic_checkpoint_interval 是 SQLite 3.41.0(2022 年)引入的功能,可以设置后台线程自动 checkpoint 的时间间隔(秒)。在此之前,开发者只能通过应用层的定期调用或连接关闭时来触发 checkpoint,操作比较粗糙。
-- 设置自动 checkpoint 间隔为 1000 秒(约 16 分钟)
-- 建议在开启 WAL 后立即配置,避免 WAL 文件无限增长
PRAGMA automatic_checkpoint_interval = 1000;
这个参数的设置需要权衡:间隔太短,checkpoint 频繁,会影响写入吞吐;间隔太长,WAL 文件膨胀,恢复时间变长。通常建议设置为写入峰值时段的 1/4 到 1/2。
什么时候该开 WAL,什么时候不该开
基于前面的分析,可以给出一个比较清晰的判断标准。
应该开启 WAL 的场景:
读写混访是核心条件。如果你的系统存在"写操作期间有读请求"的情况,WAL 的收益是确定的。典型例子包括:Web 服务的本地缓存、移动端应用的本地数据库、有定时批量更新但同时提供查询接口的场景。
// Java 中开启 WAL 的常见方式
String url = "jdbc:sqlite:mydb.db";
try (Connection conn = DriverManager.getConnection(url)) {
// 建表或首次连接时开启
try (Statement stmt = conn.createStatement()) {
stmt.execute("PRAGMA journal_mode=WAL");
stmt.execute("PRAGMA synchronous=NORMAL");
stmt.execute("PRAGMA automatic_checkpoint_interval=1000");
}
}
不应该开启 WAL 的场景:
纯写入场景没有意义。如果一个系统只有批量导入、ETL 之类的纯写操作,传统 journal 模式的写入速度反而更快,因为少了一层 WAL 文件的维护开销,checkpoint 的压力也更小。
嵌入型和短生命周期场景也不适合。比如一次性批处理脚本、临时计算工具,数据库打开后很快就关闭,WAL 带来的并发优势无法体现,反而多出了三个文件的管理负担。
还有资源受限的嵌入式设备,.shm 文件的进程间同步机制在某些平台(如部分 ARM 嵌入式环境)上存在兼容性风险,这种情况下保守选择传统模式更稳妥。
常见问题排查
开了 WAL 但效果不明显,或者出现新问题,是实践中经常遇到的情况。
问题一:WAL 文件持续增长,磁盘空间告警
这是最常见的"开了 WAL 忘了配置"的症状。默认 PASSIVE 模式下,checkpoint 只在 WAL 超过阈值时被动触发,不会主动清理。解决方案是显式设置 automatic_checkpoint_interval 或者在应用层定期调用 checkpoint:
-- 定期在业务空闲时段手动 checkpoint
PRAGMA wal_checkpoint(TRUNCATE);
问题二:切换 WAL 模式后查询变慢
有一种情况是数据库已经积累了大量数据,突然从 DELETE 模式切换到 WAL 模式,第一次 checkpoint 会把整个数据库内容刷入 WAL 文件,这个过程会产生大量的同步写 IO,短期内显著拖慢查询。
解决方法是分批迁移,或者在业务低峰期执行切换,切换后立即手动执行一次 checkpoint:
PRAGMA journal_mode=WAL;
PRAGMA wal_checkpoint(FULL);
问题三:跨进程并发写入时出现数据库锁定
WAL 模式解决了读写互斥,但没有解决写写互斥。如果多个进程同时尝试写入,仍然会出现 SQLite busy 错误。这不是 WAL 的问题,而是 SQLite 本身的单写者设计决定的。
这种情况下应该考虑的应用层方案是:队列化写操作、使用应用程序级锁,或者在确实需要多写者并发的场景下切换到多库分片。
问题四:崩溃恢复时间变长
WAL 模式下,崩溃后恢复需要回放 WAL 文件中的未 checkpoint 数据。如果 WAL 文件很大且长时间没有 checkpoint,恢复时间会比传统 journal 模式长。这也是为什么生产环境一定要配 automatic_checkpoint_interval 的原因——把 WAL 文件大小控制在合理范围内,恢复时间才会有保障。
写在最后
SQLite WAL 模式不是什么神秘的优化手段,它就是 SQLite 官方提供的一个标准特性,配置一行 PRAGMA 就能开启。但"能开"和"开对了"之间差了整整一个工程实践的距离。
大多数性能问题的根源不是 SQLite 本身不够快,而是默认的 rollback journal 模式在读写混访场景下暴露了锁竞争的本质。WAL 解决了这个问题,但它引入了 checkpoint 管理和磁盘空间的新议题。
如果你的 SQLite 使用场景符合"有并发读 + 有持续写"的特征,建议尽早切换到 WAL 模式,并且配上合理的 checkpoint 策略。如果只有纯写或者纯读,传统模式反而更简单可靠。判断标准不需要复杂,对照场景对号入座就够了。

浙公网安备 33010602011771号