一个存在十六年的陈旧读取路径

SQLite 采用物理预写式日志。数据库页在内存中修改后,每个被改动的页都会排队写入预写日志(WAL)。WAL 把页保存为“帧”,一帧由帧头和实际页组成,同一事务内对同一页的多次修改会合并成一帧。

当 WAL 达到默认的 1,000 页阈值,SQLite 会触发检查点。检查点把 WAL 里的页复制到磁盘上的永久位置。但长期存在的读事务可能阻止检查点推进,而 WAL 越大,读者从磁盘取页的速度就越慢,因为 WAL 中的页没有适合读取的有序结构。

最温和的“被动”检查点只复制未被其他事务占用的页。最激进的“截断”检查点会等待所有读者和写者结束,再把全部页复制出 WAL 并将 WAL 截断到零字节。自动检查点只会执行被动模式,应用如果认为自己能更好地安排检查点,可以通过 PRAGMA wal_checkpoint 主动触发。

并发检查点中的错误标记

本月早些时候,Tailscale 发文提到并发检查点中的一个缺陷:检查点进程会错误地把 WAL 中尚未真正复制出去的页标记为已复制。这至少会造成数据丢失,偶尔还会被 PRAGMA integrity_check 发现数据与索引不同步,表现为数据库损坏。

SQLite 在 2026 年 3 月修复了该问题。Tailscale 直到最近才发布博客,更可能是因为他们刚刚确认缺陷确实已被修复。

用 100 行 C 代码复现竞态

复现过程使用两个线程和三个数据库连接。线程 1 的连接 A 作为检查点进程执行检查点;线程 2 的连接 B 作为写者更新表 Z 中的一行并提交;线程 1 的连接 C 作为读者从表 Z 读取数据。在合适的并发和时机下,三者交错就会触发缺陷。

仅通过公开的 SQLite API,这段约 100 行的 C 工作负载就能在几秒内同时得到丢失写入和损坏的数据库文件。陈旧读取让检查点丢弃了已经提交的 WAL 帧,而这些帧本应被保留到永久存储中。