全世界最普及的数据库,藏了一个 16 年的 bug

全世界最普及的数据库,藏了一个 16 年的 bug

数据库基础设施可靠性

数据源:Lobsters + web research · HN

一个 bug 在世界上最普及的数据库软件里藏了 16 年,没有一个人发现;直到一家公司半年内连续遭遇 19 次数据损坏,才把它揪了出来。今年 3 月 3 日,SQLite 的开发者之一 Dan 修复了这个 bug,官方发布说明写得轻描淡写:极罕见的情况下可能导致数据库损坏,开发测试中从未自然复现过。有技术社区的用户吐槽,这段说明读起来像是「靠形式化方法推出来的近乎理论性的竞态」。直到这家公司——Tailscale——发文回顾,人们才看清,轻描淡写的背后,是一个真实用户 19 次生产事故的血泪。这篇文章在技术社区 Lobsters 上被顶到 128 分,是当天热度最高的帖子。

数据库是什么:手机里的账本

把手机想象成一家小店。你加的微信好友、聊天记录、游戏存档、App 设置,全都记在一本「账本」里——这本账本就是数据库。每个 App 的每一次读写,都是在这本账上记账、翻账。

SQLite 就是全世界装机量最大的账本软件。它不需要单独安装服务器,一个文件就是一本完整的账。你手机里几乎每个 App 都在用它:很多人的微信聊天记录,底层就存在 SQLite 里;浏览器、银行 App、各种工具软件,也都依赖它。它太普及了,普及到没有人会特意提起它——这正是它最成功的地方。

半年 19 次数据损坏

Tailscale 是一家做「内网组网」的公司,帮用户的设备之间建立加密的专属连接。它的服务器把用户数据分成许多片,每片存放在一个独立的 SQLite 数据库里,由一个进程独占读写——这恰好是 SQLite 官方推荐的标准用法。

去年 8 月,Tailscale 的备份系统报错:某个数据库损坏了。工程师修复后继续排查,一无所获。然后它又坏了,又坏了……半年里,整整 19 次。每次损坏,受影响的那片服务都要停机恢复,早期一次要停一个多小时;停机期间新上线的设备连不上网络,正在用的用户虽然没断线,但网络里的任何变更都学不到了。

对普通用户来说,「数据库损坏」这几个字意味着聊天记录、笔记、游戏存档可能凭空消失——这也是为什么数据库软件把可靠性看得比什么都重。

19 这个数字值得停下来想一想。半年 19 次,平均不到十天一次,听起来像是家常便饭。但工程师们找不到任何规律:不是特定某台机器,不是特定用户,不是特定功能,不是特定时段。故障时来时不来,甚至安静了整整六周,然后在圣诞节前后卷土重来。没有规律就意味着无法复现,无法复现就没法在实验室里修——只能给生产环境装上「法医级」监控,等它下次犯案。半年 19 次没有两次共享任何共同特征,这本身就是一条重要线索:问题藏在更深的地方,Tailscale 自己的代码只是替罪羊。

数据凭空消失了

破案的关键线索有两条。

第一条来自 Tailscale 自己搭的「事务日志」。为了不依赖可能已损坏的备份,他们把每一次数据库修改都流式记录到单独的文件里,出事后可以按顺序重放。结果在两次事故中,日志重放对不上账:一条明明提交成功的数据,对后面的操作来说却不存在。写入成功了,没有任何报错,数据就凭空消失了。这在数据库里「应该是不可能的」——数据库存在的全部意义,就是保证写进去的东西不会丢。

第二条线索来自 SQLite 官方开发的调试工具。要理解它,得先懂一点点 WAL。

WAL:先记草稿,再誊正

数据库为了防止写到一半断电丢数据,改动会先记在一本「草稿本」上,等时机合适再把草稿誊写进正式账本——正式账本本身,不会在写入中途被改动。这本草稿本在 SQLite 里叫 WAL(预写日志),誊写的过程叫「检查点」(checkpoint)。

SQLite 的草稿本 WAL 与正式数据库文件的关系示意图:新改动先写进 WAL,再由检查点誊写进数据库文件 图:SQLite 的「草稿本」WAL 与正式数据库文件的关系——新改动先记进草稿本。来源:tailscale.com

检查点示意图:WAL 里的页面被复制回数据库文件,可替换旧页面,也可追加到文件末尾 图:检查点(checkpoint)把草稿誊写进正式账本的过程,bug 就藏在这个过程的竞态里。来源:tailscale.com

大多数用户从不关心检查点,SQLite 自己会挑合适的时间悄悄做完。但 Tailscale 为了做又快又一致的备份,选择手动控制检查点,而且做得又频繁又激进——这是文档支持、但极少有人走的用法。

SQLite 的开发者怀疑问题出在检查点上,专门写了一个叫 tmstmpvfs 的探针工具,包在 SQLite 的磁盘读写层外面,记录每一次底层操作。Tailscale 把它部署到生产环境,等下一次故障。等来的结果触目惊心:WAL 里明明只有 10 页数据,日志却显示复制了 20 页——多出来的 10 页是从哪来的?

16 年:罕见,不等于不存在

真相终于水落石出:这是一个「竞态」——两个动作几乎同时发生,先后顺序出了岔子。当一个写入恰好撞上检查点重置 WAL 的那一刻,检查点误以为某些页已经誊写进了正式账本,其实没有。这些页的数据永久丢失,而引用它们的其他页(比如索引)却被写进了数据库,账本从此对不上。SQLite 开发者把这个 bug 命名为 WAL-Reset,估计它已经存在了至少 16 年。

16 年意味着什么?意味着它躲过了数不清的版本更新、自动化测试和代码审查。它之所以能藏这么久,是因为触发条件太苛刻:必须有一笔写入,恰好发生在检查点重置 WAL 的精确时刻。绝大多数用户用默认配置、让软件自己管理检查点,永远走不到这条路径;Tailscale 手动控制检查点、频率极高,等于主动提高了中奖概率。规模越大,撞上的概率越高——半年 19 次事故,其实是概率的必然,而不是运气差。

修复本身出乎意料地简单:给检查点函数加一个检查,发现 WAL 被别的线程重置就立刻停下。为了验证修复有效,开发者甚至不得不往代码里加一段「故意触发 bug」的逻辑——这足以说明它有多罕见。而 SQLite 官方发布修复的过程也一波三折:3.52.0 版本因为另一个优化引发误报被撤回,改发只含本修复的 3.51.3。

免费的软件,昂贵的代价

SQLite 是免费软件,作者把它无偿送给全世界用,靠出售技术支持服务维持。Tailscale 用了它两年多没出问题,出事之后才签下专业支持合同。评论区里有人为此惋惜:为什么非要等出了事才去买支持?一位疑似 Tailscale 工程师(网名 danderson)在评论区印证:因为签了 NDA,在回顾文章发布之前,他们一直不能公开任何细节。

这件事值得琢磨:免费软件没有成本,只是把成本换了一种存在方式——变成了「出问题时,有没有人能帮你」。专业支持合同的价值在这次事件里体现得淋漓尽致:SQLite 的核心开发者直接参与排查,专门为 Tailscale 开发调试工具,从去年夏天一路陪到今年春天。而 Tailscale 也没有白拿,排查中开发的探针工具被开源,回馈进了 SQLite 的官方代码库。评论区还有一位用户说得实在:规模一大,几乎所有系统都会暴露出这种隐藏问题——复杂软件大多如此,SQLite 并不特殊。

尾声:一条让人欢呼的警报

修复部署后,Tailscale 在自己代码里埋了一个「预警器」:当写入与 WAL 重置再次撞车时,打出一条警告。然后他们等了两个月,什么都没发生。就在工程师开始怀疑自己的理论时,警报响了——内容大致是:SQLite 试图在某台服务器上制造数据损坏,但被系统拦住了。

预警截图:SQLite 试图在 party mode 下制造数据损坏,但被系统拦住了 图:两个月后终于响起的警报——系统拦下了一次未遂的数据损坏。来源:tailscale.com

这条警报让全公司欢呼,因为它证明了两件事:bug 真的存在过,修复真的拦住了它。此后四个月,数据库零事故。

16 年才被发现,恰恰说明 SQLite 平时有多可靠;但它终究还是被发现了,说明再可靠的软件,也扛不住「罕见」二字。笔者特意去翻了评论区,大多数人没有指责 SQLite,反而在讨论这种 bug 的必然性。对普通用户来说,这件事真正的启示也许很简单:你手机里那个从不出错的账本,背后有无数人在你看不见的地方,替它把错误挡了下来。

参考链接:

  • Tailscale 博客: How we tracked down a 16-year-old SQLite bug
  • Lobsters 讨论 (s/e0lkmi): How Tailscale helped find the SQLite WAL-Reset bug
  • SQLite 官方发布说明: WAL-Reset bug 修复(3.51.3)
  • Crawshaw 博客: one-process programming(关于后端为何选用 SQLite)