为什么 SQLite 的 WAL 模式“不能在 NFS 上用”——一个存储引擎的原子性边界
SQLite 的 WAL(Write-Ahead Logging)模式几乎总被当作“让读写并发起来”的首选方案。但它的官方文档里有一句很直白的话:WAL 在 NFS 上不工作,因为所有进程必须能共享同一块内存。这句话背后藏着的,是一个存储引擎对“原子性边界”的认定方式,而不只是一条部署注意事项。
WAL 带来的不是“读写不阻塞”,而是“读写互不等待”
在回滚日志(rollback journal)模式下,写事务开始前会先把原始页复制到日志文件里,写入期间读取者看到的是数据库文件本身,但写者提交前需要持有排他锁。结果是读和写互相等待。
WAL 模式把顺序倒过来:修改先追加到 -wal 文件,提交只是写一个帧头,读取者通过共享内存里的索引找到最新版本的页。读事务在快照上工作,不阻塞写;写事务追加到 WAL 末尾,不阻塞已经在读的事务。SQLite 文档对此的表述是“readers do not block writers and a writer does not block readers”。
这个收益的代价是一个很具体的依赖:读进程需要知道 WAL 里哪些帧是有效的、哪些帧对应自己快照的末尾。这个信息放在 -shm 文件里——一块共享内存。
-shm 文件与锁协议:为什么必须是同一台主机
-shm 文件不是数据,是索引和锁。它记录 WAL 里每个页的最新版本位置,以及读写事务的锁状态。SQLite 通过内存映射把 -shm 映射到所有连接进程的地址空间,锁的获取和释放直接作用在这块共享内存上,而不是通过操作系统的文件锁。
这就是 NFS 场景失效的根因:NFS 提供的是文件语义,不是共享内存语义。不同主机上的进程映射同一路径下的 -shm 文件,看到的并不是同一块物理内存;它们各自持有自己的副本,锁状态无法互相可见。SQLite 在 NFS 上要么检测到 -shm 不可用而拒绝进入 WAL,要么在锁协议失效的情况下产生不一致。
很多人会把这条限制理解成“NFS 性能不好”或“网络文件系统不可靠”,但它更准确的表述是:WAL 的原子性建立在“所有参与进程共享同一块可映射内存”这个前提上。NFS 不提供这个前提,所以不是性能退化,而是协议层面的不成立。
检查点:写者最终会反过来阻塞读者
WAL 模式另一个常被忽略的边界是检查点(checkpoint)。WAL 文件不会无限增长,后台需要把已提交的帧写回主数据库文件,这个过程叫 checkpoint。默认情况下,SQLite 在 WAL 达到 1000 页时自动触发一次 PASSIVE 检查点。
问题在于:检查点要把 WAL 里某一帧对应的页写回主库,前提是没有活跃的读事务还持有比该帧更早的快照。如果有长事务正在读,检查点只能做到它能做的那一段,WAL 继续增长。写者本身不会因为检查点被阻塞,但 WAL 文件会持续变大,直到某个时刻触发一次 RESTART 或 TRUNCATE 级别的检查点——这时它需要等待所有读事务结束,写者就可能被间接拖住。
所以“读写互不阻塞”是有时间窗口的:它成立的前提是读事务足够短、检查点跟得上 WAL 的增长速度。一个长时间运行的读事务会把 WAL 钉在某个长度上,把“不阻塞”变成“延迟阻塞”。
什么时候 WAL 反而更慢
WAL 的写入路径是追加,通常比回滚日志的随机写更快,但有两种情况会让它反过来吃亏。
第一种是写事务非常大。WAL 的提交需要把修改的页全部追加进去,一个大事务可能让 WAL 文件瞬间膨胀到接近数据库大小;如果此时有并发读,检查点又没法把这段 WAL 收缩回去。相比之下,回滚日志在大事务下只记录原始页,不一定更差。
第二种是单连接、低并发的场景。WAL 的收益来自读写并发,如果应用本身就是串行访问,额外的 -shm 映射、帧索引维护和检查点开销都是纯成本。SQLite 文档自己也建议在这种场景下用 journal_mode=DELETE 或 TRUNCATE 即可。
边界才是设计的一部分
把 WAL 的局限性串起来看,会发现它们都指向同一个问题:这个引擎把原子性边界画在了“同一主机、共享内存、可控检查点”这三件事上。NFS 破坏了第一条,长事务破坏了第三条,单连接场景让第二条的收益归零。这不是实现缺陷,而是存储引擎在“并发收益”和“原子性前提”之间做的一次明确取舍。
判断一个存储引擎是否适合自己的场景,比读它的性能曲线更有用的,是先读它自己划出来的边界。边界之外不是“慢一点”,而是“协议不成立”。
- 点赞
- 收藏
- 关注作者
评论(0)