Skip to content
Published on

SQLite 的 WAL-reset 缺陷 — 一场潜伏了 15 年的数据损坏竞态

分享
Authors

引言 — "Choose any three" 背后的一条注脚

SQLite 文档页面的顶部,永远挂着同一句话:"Small. Fast. Reliable. Choose any three."。而 SQLite 大体上也确实兑现了这个承诺。正如SQLite 无处不在一文所梳理的那样,这个引擎正在你的手机、浏览器、航电设备里默默运行,它的可靠性几乎被当作一项基础设施常量来对待。

正因如此,2026 年 3 月发生的这件事才显得格外有意思。用官方文档自己的话说,"其中一位 SQLite 开发者(Dan)"在 2026 年 3 月 3 日发现并修复了一个会在 WAL 模式下罕见地损坏数据库的缺陷。SQLite 团队自己给它起的名字是"WAL-reset bug"。而这个缺陷,从 WAL 首次登场的 3.7.0(2010-07-21)一直存活到 3.51.2(2026-01-09)。照搬发布说明的原话,就是"一个存在了 15 年的数据库损坏缺陷,在几天前被发现,现已修复"。

先把温度调好。这不是那种会让你今晚被传呼机叫醒的事件。SQLite 文档自己就明确写着"这不是紧急事态(this is not an emergency)",并且给出了理由。但这个缺陷是怎么产生的、又是怎么被抓到的,对任何使用 WAL 的人来说都值得一读 — 因为这是一个相当干净的教科书级案例,讲的是当一个字段由两把锁而不是一把锁分管时会发生什么。

WAL 回卷 — 这个缺陷所在的街区

在看这个缺陷之前,需要先搭好舞台。WAL 的基本结构已经在SQLite 内部结构一文中讲过,这里只谈与本缺陷直接相关的部分。

在 WAL 模式下,写入不是写进数据库文件,而是作为帧(frame)追加到 WAL 文件末尾。读取则会同时查看数据库文件和 WAL。但如果 WAL 无限制地变大,读取就会变慢 — 用文档的话说,读取性能会随 WAL 文件变大而恶化,因为检查 WAL 所需的时间与其大小成正比。

于是检查点(checkpoint)就登场了。检查点会把 WAL 的内容写回数据库文件(backfill)。默认策略是让 WAL 长到约 1000 页,然后在此后的每次 COMMIT 上都跑一次检查点,直到 WAL 重新缩小到 1000 页以下。

这里出现了本文的核心概念。一旦整个 WAL 都已被搬进数据库、同步也已完成,并且没有任何读者仍在读取 WAL,下一个 writer 就会把 WAL 回卷(rewind)到起点,从 WAL 的最前面开始写入新事务。这就是"WAL reset"。它不是删除文件再重建,而是把游标归零、从头开始覆写。正是这个回卷机制,让 WAL 文件不会无限膨胀。

回卷本身是一种纯粹的优化,绝大多数时候都运作得完美无缺。问题出在判断"是否可以回卷"的那本账簿上。

账簿上的两行 — mxFrame 与 nBackfill

那本账簿位于 wal-index(共享内存,通常是 -shm 文件)的头部。本文需要关注的字段有两个。

mxFrame     WAL 中有效帧的数量。
            由于帧编号从 1 开始,这个值同时也是最后一个有效提交帧的索引。
            为 0 表示 WAL 为空 -> 可以直接从 DB 文件读取全部内容。

nBackfill   WAL 帧中已经被复制进数据库的数量。
            始终满足 nBackfill <= mxFrame。

            mxFrame == nBackfill  ->  WAL 的内容已全部落盘到 DB
                                  ->  只要没有连接持有 WAL_READ_LOCK(N>0),
                                      下一个 writer 就可以回卷 WAL。

也就是说,mxFrame == nBackfill 就是回卷的通行证。而 nBackfill 同时也是检查点器留下的进度标记 — 它记的是"到这里为止的内容已经搬过去了,下一次检查点可以从这里开始"。

接下来是本文中最重要的两句话。原样引用 SQLite 文档(walformat.html)的说法:

  • nBackfill 只能在持有 WAL_CKPT_LOCK 的状态下递增。
  • nBackfill 会在 WAL reset 期间被置为 0,而这发生在持有 WAL_WRITE_LOCK 的状态下。

这两句话很容易一读而过,但整个缺陷就藏在里面。字段只有一个,但触碰它的两条路径拿的是两把不同的锁。 检查点器握着 CKPT 锁去抬高 nBackfill,而执行回卷的 writer 握着 WRITE 锁把 nBackfill 降为 0。两者互不排斥 — 就像两个人握着不同的钥匙走进同一个房间。

这套设计能够安稳运行 15 年是有原因的。在正常路径上,检查点器想要抬高 nBackfill 的那一刻,和 writer 把它归零的那一刻,分属两个不同的状态(state),中间由其他检查挡住了。问题出在一个极窄的窗口 — "检查点刚刚开始"的那一瞬间。

损坏分六步发生

SQLite 文档整理出的场景,恰好是六个步骤。我们按顺序看一遍。

1. 连接 A 完整跑完一次检查点。
   必须是"完整的"检查点 —  把 WAL 的全部内容搬进 DB,
   并让 WAL 停留在可以回卷的状态。(即 mxFrame == nBackfill)

2. 紧接着,第二个检查点开始了。

3. 就在检查点 #2 处于"正在启动"的时候,另一个连接提交了一个事务。
   这次提交把 WAL 回卷(reset),并把新内容写到 WAL 的最前面。

4. 数据竞态发生了。检查点 #2 无从得知 WAL 已在第 3 步被回卷。
   于是它在 wal-index 头部留下了一个错误的字段值 --
   声称"WAL 的这部分已经检查点过了",但实际上并没有。

5. 更多事务陆续提交,WAL 的帧数超过了检查点 #1 那一刻的水平。

6. 后来第三个检查点跑起来时,
   跳过了第 3 步写入事务的全部或部分内容。
   -> 那个事务永远到不了数据库文件。
   -> 数据库损坏。

第 4 步就是事故发生的瞬间。回卷一旦发生,nBackfill 本该变成 0 — 因为 WAL 正在从头重写,"已经搬过去的帧数"理应是 0。但刚刚启动的检查点 #2,脑子里还停留在回卷之前的世界观。它留下的 nBackfill 声称"前面 N 个帧已经处理过了"。可是回卷之后,那前面 N 个帧已经是完全不同的数据 — 是第 3 步里新写入的事务。

于是第 6 步的检查点器会相信这些帧"已经做过了",于是跳过它们。被跳过的数据永远到不了 DB 文件,而 WAL 最终又会被回卷、被覆盖到那上面。一个曾被确认提交成功的事务,就这样悄无声息地蒸发了。这既是对持久性(durability)的违背,也是损坏本身。

值得注意的是,损坏不会立刻显现。错误的值在第 4 步就被记录了下来,而真正的伤害发生在第 6 步。中间必须夹着第 5 步(更多的提交)。事故与症状之间横亘着时间和一批事务 — 这正是那种让事后分析变得异常困难的缺陷。

触发条件 — 为什么大多数人安然无恙

文档明确列出的触发条件是:

  • 数据库必须处于 WAL 模式
  • 同一个文件上必须打开两个以上的连接,并且这些连接分属不同的线程或进程
  • 这两个连接必须在完全相同的瞬间尝试写入或做检查点。

这里先给出第一个诚实的观察:这个条件把单进程、单线程的应用 — 桌面应用、移动应用、CLI 工具、测试夹具 — 整个排除在外了。SQLite 绝大多数的部署都属于这一类。

但公平起见,也该给出第二个诚实的观察。剩下的那个条件 — "WAL 模式 + 多个进程写入同一个文件" — 恰好和近年流行起来的一种部署形态严丝合缝地重合:一个跑着多个 worker 进程的 Web 应用,共享同一个本地 SQLite 文件。也就是说,这个缺陷的触发条件瞄准的不是 SQLite 的传统用法,而是近来才兴起的用法。

这么说不是为了吓唬谁。满足条件和真正触发,完全是两回事,而这中间的落差,正是这整件事最有意思的地方。

该怎么说自己"修好"了一个无法复现的缺陷

这才是这件事真正的看点所在。照搬 SQLite 文档的原话:

开发者们从未成功在实验室里自然地(organically)复现过这个缺陷。

要让这个缺陷发作,好几处细节必须恰好在同一时刻对齐。尤其是第 3 步的提交,必须精确地落进第 2 步检查点"正在启动"的那个狭窄区间里。这是一个纳秒级的窗口。于是 SQLite 团队选择了这样的做法。

他们修改了 SQLite 自身的源码,埋入了一个由 sqlite3_test_control() 控制的回调。 这个回调让测试程序可以在检查点 #2 运行期间的恰好那一刻,触发第 3 步的写入事务。也就是说,与其等待竞态自己出现,不如把竞态变成一条可以按需下达的命令。用文档的话说,如果没有这个代码层面的破解,这个问题在开发和测试过程中从来没有被观察到过一次。

这个做法为什么是对的,值得停下来想一想。对于一个只会概率性发作的竞态,能够可信地宣称"修好了",只有两条路:

  1. 证明它(模型检验、形式化验证)。
  2. 把竞态变成确定性的,然后测试它。

SQLite 选择了第二条路,为此付出的代价是往生产代码里塞进一个测试专用钩子。这不是免费的 — 这是一个明确的权衡:为了可验证性而扩大代码的暴露面。但另一个选项是"我们觉得应该是修好了",而对于一个数据损坏缺陷来说,"觉得"从来都不算答案。

这里有一条实务上的教训。如果你自己的系统里也存在概率性的竞态,"加大负载,总有一天会自己复现"并不是一种策略。就连 SQLite 都做不到。埋入一个能够确定性地操控调度顺序的钩子 — 哪怕看起来不那么优雅 — 往往才是真正能给出答案的那条路。(把同样的思路推广到整个系统的规模,就是确定性模拟测试。)

到底有多危险 — 文档自己给出的概率

这一节,我会尽量原样保留 SQLite 文档的措辞。这不是一个由我来编数字的地方。

文档首先承认了严重性。这个缺陷罕见,但后果严重,因此应用开发者应当升级到修复后的版本。

紧接着,它立刻把温度降了下来。"然而,这并不是紧急事态。" 给出的理由是这样的 — 根据可获得的遥测数据,这个问题在实际环境中发生的比例,看起来低于 SSD 故障或宇宙射线击中的预期发生率。因此,即便你运行的是未打补丁的版本,只要没有做什么特别反常的事情,大概率也不会遇到这个问题。

正确读懂这个比较很重要。"低于 SSD 故障率"不等于"零"。它的意思是:这份风险,已经被淹没在你本就在承受的硬件故障概率的噪声里了。 如果你已经为应对 SSD 故障或比特翻转准备了备份和完整性校验,这个缺陷就落在那份防护之内。反过来,如果你没有这些防护,那么该担心的就不是这个缺陷,而是你的 SSD。

还留一个但书。文档用"根据可获得的遥测数据(based on available telemetry)"这句话加了限定条件。是哪些遥测数据、样本量有多大,并未公开。而且正如前面所见,由于这种损坏的成因和症状在时间上是分离的,即便它确实发生过,也存在一种结构性的可能:它从未被归因为"SQLite 的缺陷",而是被归类成了"原因不明的损坏"或"磁盘问题"。也就是说,这个估计值属于那种可能偏向乐观的估计。这不是说 SQLite 团队不诚实,而是说这类缺陷的观测本身就很困难。

混乱的两周 — 3.52.0 的撤回

故事在这里开始变得有点奇怪。而这部分特别容易被误读,所以要讲得精确一些。

时间线是这样的:

2026-01-09   3.51.2 发布        (缺陷仍然存活的最后一个版本)
2026-03-03   Dan 发现并修复了 WAL-reset 缺陷
2026-03-06   3.52.0 发布        (包含 WAL-reset 修复 + CLI 大改)
2026-03-13   3.52.0 撤回(Withdrawn)
             同一天,3.51.3 发布  (仅回移 WAL-reset 修复)
2026-04-09   3.53.0 发布        (3.52.0 的重新发布 + 追加修复)
2026-05-05   3.53.1  /  2026-06-03  3.53.2  /  2026-06-26  3.53.3

发现后三天就发布了一个大版本,一周后这个版本就被撤回了。

重要提示:3.52.0 被撤回的原因不是 WAL-reset 缺陷。 这在时间顺序上非常容易被搞混,所以要明确讲清楚。撤回的原因是一个完全不同的问题 — 3.52.0 的部分新特性与旧版本不能 100% 兼容,必须返工那个特性及相关的 API。

具体而言,发布说明是这样收窄范围的:只要数据库没有表达式索引(expression index),或没有针对 VIRTUAL 计算列的索引,3.52.0 就运作良好,并且完全向后兼容。真正出问题的,是索引的表达式最终被求值为从文本或 JSONB 输入派生出的浮点值的情形。有这类索引的话,3.52.0 在少数情况下可能无法与旧版本正确互操作。

顺带一提,同一个版本的变更日志里还有一条独立记录,说浮点数与文本之间的转换被重新实现了,舍入的默认精度也从此前所有版本的 15 位有效数字改成了 17 位。这两条记录触及了同一个领域,这一点很扎眼,但公开文档并未明说这个改动就是上述兼容性问题的成因 — 所以这里不去断言因果关系。

那团队是怎么应对的呢?这才是值得学习的地方。他们没有说"请等 3.53.0 发布"。而是在 3.51 分支上发布了补丁版本 3.51.3。 3.51.3 只修复 WAL-reset 缺陷,以及 3.51.2 之后暴露出的一些细小问题。没有新特性。

拆开来看,这个选择意味着:损坏修复被和 CLI 大改、新的 SQL 语法、QRF 库、浮点转换重构捆绑成了一整块。 而兼容性问题正是在这一整块里爆发的。如果 3.52.0 是唯一的路径,用户就会被迫做这样的选择:"要修复损坏缺陷,就得囫囵吞下一整个带兼容性风险的特性版本。" 能在老的稳定分支上发布回移版本,正是消除这种强迫 — 把风险切开,让只想要损坏修复的人,可以只带走损坏修复。

而这也不是纸上谈兵。node-sqlite3 仓库在撤回当天,也就是 2026-03-13,就出现了一个 issue,标题是"用稳定版 3.51.3 替换掉已撤回的 3.52.0",报告者明确说明自己并未受到损害,只是在做预防性的清理工作。如果没有这个回移版本,这个仓库的选项会糟糕得多。

3.53.0(2026-04-09),用发布说明自己的话说,是"3.52.0 的重新发布(re-release),并加入了处理过期表达式索引问题的改进"版本。而针对 WAL-reset 缺陷,它写道:"如果你还没有用过 3.52.0,这就是包含此修复的第一个大版本",并建议升级。

还有一条值得补上的注脚。3.52.0 的发布说明里还有这样一句话 — 团队正努力把 SQLite 的发布周期放慢到大约每六个月一次,但这是一个目标,而不是承诺。写下这句话的那个版本一周之内就被撤回了,这种反讽让人不由得心生共鸣。

你实际上该做什么

归纳下来,行动清单很短。

1. 检查版本。 确认你的应用实际链接的 SQLite 版本 — 不是包管理器报告的版本,而是运行时报告的版本。

SELECT sqlite_version();

2. 基准线是 3.51.3(2026-03-13)及以上。 低于这个版本、但在 3.7.0 及以上的所有版本,都存在这个缺陷。如果想走大版本路线,那就是 3.53.0 及以上;截至本文写作时(2026-07-16),最新版本是 3.53.3(2026-06-26)。

3. 如果无法升级,也有回移版本可用。 SQLite 文档为部分旧版本提供了回移 — 3.44.6 和 3.50.7。不过请注意,这些是以 Fossil check-in 的形式提供的,而不是正式的发布 tarball。

4. 而这才是真正的陷阱 — 升级一次并不等于万事大吉。

为了说明最后这一点,我们来看一个真实案例。OpenAI 的 codex 仓库在 2026 年 6 月合并了一个 PR,标题是"把捆绑的 SQLite 钉死在 WAL-reset 已修复的版本上",合并时间是 2026-06-14。根据 PR 描述,事故的经过是这样的。

SQLx 0.9libsqlite3-sys 允许了一个很宽的版本范围。于是,一次与这个问题毫无关系的锁文件更新,把 libsqlite3-sys 从 0.37.0 拉回到了 0.35.0,而捆绑的 SQLite 运行时也因此从 3.51.3 悄悄降级到了 3.50.2。 也就是说,一个原本已经到达了修复版本的项目,被一次毫不相干的依赖清理工作,重新推回了脆弱版本。

这就是 SQLite 作为一个库所要付出的代价。SQLite 无处不在一文谈到了无服务器设计的好处,而这枚硬币的反面就在这里。没有服务器,也就意味着没有一个中心节点可以打补丁。 如果是 PostgreSQL,升级一台服务器,所有客户端就都修好了。而 SQLite 需要每一个链接了那份代码的二进制文件各自修好,而这个版本通常并不是你直接选定的,而是在依赖树的某个角落被传递性地决定的。传递性依赖会在你没留意的时候悄悄移动。

实务上的建议是:如果你的项目捆绑了 SQLite,就该给版本同时钉上上限和下限,并在 CI 里真正断言(assert) SELECT sqlite_version() 的结果。不要指望靠人眼审查锁文件的 diff 来抓住这种问题 — codex 的案例正是这种做法失败的实例。

这件事留下了什么

有几点值得整理出来。

第一,十五年不是代码正确的证据。 这个缺陷在 SQLite 历史上被测试得最多的代码路径之一里,藏在一个业界公认最偏执的测试套件的项目内部,存活了 15 年。原因很简单 — 测试从未控制过调度顺序,而这个缺陷只存在于调度顺序之中。100% 的覆盖率,不等于 100% 的交错(interleaving)覆盖。

第二,不变式依附的是锁,而不是字段。 把这件事压缩成一句话,就是"递增 nBackfill 的路径和把它归零的路径,各自拿着不同的锁"。这两条路径各自都严格守住了自己的锁。它们各自都是对的。错的地方在于,"守护这个字段的锁到底是哪一把"这个问题,居然有两个答案。审查你自己代码里的共享状态时,该问的不是"有没有加锁",而是"触碰这个状态的每一条路径,拿的是不是同一把锁"。

第三,这是一次良好披露(disclosure)的典范。 看看 SQLite 团队做了什么:开辟了专门的文档小节,精确写明受影响的版本范围,公开了六步机制,承认了自己没能复现它,甚至写出了为了复现不得不去改源码这件事,公布了发生率估计值及其依据,用"这不是紧急事态"把温度调了下来,还把自己的这个缺陷,作为条目添加进了How To Corrupt An SQLite Database File的损坏成因清单里。最后这一条尤其漂亮 — 那份文档原本是用来说明"损坏大多是你自己的错"的,而他们自己动手,往里面加上了一句"或者也可能是我们的错"。

第四,不要把风险捆在一起。 3.52.0 给出的教训,不是那个兼容性问题本身,而是损坏修复被和一个特性版本捆绑在了一起这种结构。能够向旧分支发布回移版本,不是发布工程里的一件奢侈品,而是一件安全装备。

结语

诚实的结论是这样的:你极不可能真的遭遇过 WAL-reset 缺陷,SQLite 文档自己也说明了它的发生率被淹没在 SSD 故障率的噪声里。这篇文章不是为了吓唬你而写的。

但应对方法很简单 — 升级到 3.51.3 及以上,并在 CI 里断言这个版本被维持住。这几乎没有成本,也正是文档所建议的做法。

而我认为,还有一种更好的方式来读这件事。这不是一个"SQLite 的可靠性被高估了"的故事。恰恰相反 — 一位开发者靠读代码,找出了一个 15 年来从未有人在实际使用中自然触发过的竞态;为了复现它,埋入了测试钩子;三天之内就修好了;发布过程出了乱子,就往旧分支发了回移版本;并把整个过程原原本本地写成了文档公开出来。"Reliable"从来不是"没有缺陷"的意思。它的意思是:缺陷会被这样对待。

参考资料