你手机App都在用的数据库,藏了16年的bug

数据库SQLite基础设施

数据源:HN + web research · HN

你手机App都在用的数据库,藏了16年的bug

2026年8月,一家做远程办公软件的公司发布复盘,坦承自家数据库在过去半年里莫名损坏了19次,每一次都让一部分用户的网络短暂失灵,恢复常常要花一个多小时。工程师追查了几个月,最后挖出的元凶,藏在全世界装机量最大的一个数据库软件里,已经至少16年。

这家公司叫 Tailscale,做的是把几台电脑安全地连成一个私密网络。普通人不一定听过它,但这个故事值得读——因为出事的主角,你每天的手机里就有。

一个你天天在用、却从没听说过的软件

先交代主角。SQLite 是全世界用得最多的数据库,官方口径是:几十亿台设备上运行着它。你的微信、支付宝、浏览器、游戏,几乎每个 App 都在用它存数据。

它的特别之处在于「嵌入」。大型数据库像银行的金库,需要专人看管、单独一栋楼(独立服务器)。SQLite 则是每个 App 随身带的小保险箱——不需要安装,不需要维护,打开就能存。开发者把数据往里一放,几十年不用管它。

这个设计太省心了,它成了全球数字世界的默认地基。数据点要这么读:正因为几十亿台设备都在用,它的可靠性被检验了无数次,任何错误都被压到极低的概率——低到连作者本人都以为它没有错误。

一个藏在地基里的错误

Tailscale 从 2022 年起,把全部核心数据放在 SQLite 里,因为它「可靠、被广泛使用」,是行话里说的「无聊的技术」——无聊是褒义,意思是不会出事。从 2023 年初到 2025 年夏天,确实没出过事。

2025 年 8 月,备份管道突然报错:某份数据库文件损坏了。他们修复、排查,没找到原因。然后第二次、第三次……半年内共 19 次。工程上要这么理解:单次损坏的根因如果是「极罕见事件」,那么规模够大、次数够多,罕见事件也会变成例行公事。

更折磨人的是,19 次事故之间找不到任何共同点:不是同一台服务器,不是同一批用户,不是同一个时段,也不是同一种负载。工程师手里没有任何线索,甚至无法在实验室复现。

「先记便签,再誊正本」的写入模式

要听懂后面的破案过程,需要知道 SQLite 的一种工作模式,叫 WAL(Write-Ahead Log,中文常译「写前日志」)。

想象一个账本。普通模式是每来一笔账,直接翻开账本写。WAL 模式则是:先记在一叠便签纸上,攒到一定数量,再统一誊写进正本。便签纸就是 WAL 文件,正本才是真正的数据库文件,「誊写」这个动作有个专门的名字,叫检查点(checkpoint)。

WAL 与数据库文件

图:新数据先写进 WAL 文件,再誊回主数据库文件。来源:tailscale.com

好处很直接:写便签比翻大账本快,誊写还能错峰进行,多个读账本的人互不打扰。很多追求性能的 App 都用这个模式。Tailscale 不但用了,还做了一件激进的事:自己控制誊写的时机,而且誊得非常频繁——为了备份方便。这个决定,是后面所有故事的引线。

竞态:两个动作撞了车

bug 的机制,用一句话说:誊写和记新账同时发生时,顺序出了错。行话叫竞态(race condition)。

具体是这样。誊写员清点便签纸,准备把 10 张誊进正本。就在他誊到一半时,另一个人往便签纸上加了一张新纸,还把便签纸的编号起点重置了——bug 的名字「WAL-Reset」(WAL 重置)就是这么来的。誊写员没察觉,按旧编号继续誊。结果:有一张纸上记的账,他以为自己已经誊过了,其实没有。

检查点流程

图:检查点把 WAL 里的数据页复制回主数据库文件。来源:tailscale.com

那笔账消失了,连划掉的痕迹都没留下。更糟的是,账本里其他页还留着指向这笔账的记号(索引),于是整个账本被判定为「损坏」。Tailscale 的工程师后来看到一个矛盾数字:WAL 里明明只有 10 页数据,检查点却报告复制了 20 页。多出来的 10 页,就是那个「以为自己誊过了」的幻觉。这个数字,成了破案的关键线索。

为什么能藏 16 年

SQLite 官方估计,这个 bug 至少存在了 16 年。它能藏这么久,是因为触发条件苛刻到离谱:需要特定的软件版本组合、需要写操作恰好卡在誊写流程的某个瞬间、还需要特定的文件系统行为。三个条件凑齐的概率之低,官方原话是「在常见使用中几乎不可能发生」。为了验证修复有效,SQLite 的开发者不得不往代码里加一段特殊逻辑,故意制造撞车条件——他们头一次为一个 bug 这么做。

大多数用户用默认配置,一辈子碰不上。Tailscale 碰上了,是因为他们自己控制誊写时机、又誊得太猛,等于主动提高了撞车概率。一个「罕见事件」,在它面前变成了「迟早的事」。

侦探是怎么破案的

笔者读过不少故障复盘,这一篇的细致程度在业界少见。

前几个月毫无进展。他们审查了自己所有相关代码,没有发现错误;找不到共同点,无法复现。中间还有过一段 6 周的无事故平静期——反过来说明,没有事故不等于修好了,怀疑没消失,只是没露头。于是他们做了一件事:把数据库的每一次修改命令,全部录进一个独立的日志文件。

这个日志立了大功。在两次事故里,重放日志时发现:一笔已经确认写进去的数据,后来居然「看不见」了。一笔写入凭空消失,而且系统一声不吭。这在数据库的世界里,相当于账房先生记了账、盖上章,第二天账本上却没有这一笔——按常理,这不可能发生。

接着,SQLite 官方开发者加入战局(Tailscale 为此买了专业支持服务)。他们造了一个专门的调试工具,名字叫 tmstmpvfs shim(shim 是「垫片」,这里指包在原有模块外面的一层监控壳)。SQLite 的底层结构可以简单理解为三层:最上层负责听懂人话(SQL 指令),中间层把指令拆成小数据块,最底层负责真正往磁盘上写东西——这最底层叫 VFS(Virtual File System,虚拟文件系统,可以理解成「存储层接口」)。shim 就包在存储层外面,把每一次磁盘操作的细节都记录下来,像个装在金库门口的监控摄像头。

VFS shim 调试层

图:SQLite 最底层的存储接口被包上一层监控层(shim)。来源:tailscale.com

摄像头装好后,他们等下一次事故。没等太久。事故发生后,完整的操作记录交到 SQLite 核心开发者手里,竞态被当场抓住:检查点进行到一半时,写事务重置了 WAL,而检查点没有发现。

几乎同时,另一条战线也传来捷报。一家专做软件测试的公司 Antithesis,用一套叫「基于属性的测试」(property-based testing)的方法独立复现了这个 bug:让程序自动生成海量随机操作序列——并发写入、并发检查点,反复跑,同时盯着两条硬规矩:「已提交的写入不许丢」「数据库不许坏」。测试工具在旧版本上抓到了违规,在新版本上一切正常。人类想不到的操作顺序,机器替你想了。这告诉我们:对付藏得深的 bug,靠人肉排查往往不够,得让机器用穷举的笨办法去撞。

修复,以及一场虚惊

修复本身只有一处改动:给检查点函数加一道检查,发现 WAL 被别的线程重置了就停下。修复随 SQLite 3.51.3 版本发布。

但故事没完。Tailscale 先部署修复版,备份监控立刻全红——一大批「损坏」。虚惊一场:那是另一个更隐蔽的问题(旧版本里生成的索引和计算值不匹配),被新版本的某个优化「激发」了出来。SQLite 官方为此撤回新版本,重新发布只含修复的版本。Tailscale 自己也改了数据存储方式绕开它。一个 bug 修完,又带出另一个 bug——这种连锁反应在工程里很常见,说明「修好」从来不是终点。

最关键的一步在最后。为了确认 bug 确实在自家生产环境里发生过(而不是自己吓自己),Tailscale 给驱动加了个告警:只要「写入」和「WAL 重置」这两个动作撞上,就记录一声。告警部署后,安静了两个月。两个月后,警报响了——证明这个藏了 16 年的竞态确实在他们眼皮底下发生过,而修复挡下了它。此后四个月,再没有一起数据库事故。

最值得信任的,最值得怀疑

这个故事有一个耐人寻味的反差。SQLite 是全世界信任度最高的软件之一:没有哪家公司在立项时会怀疑它,所有人默认它可靠。而恰恰是这种「默认可靠」,让它的错误能潜伏 16 年、影响几十亿设备。最底层的地基一旦出错,上面盖的所有楼都跟着遭殃,而且没人会往地基想。

这也是工程师们给同行的提醒:被人依赖越深的组件,越值得用最笨、最严格的方法去检验。Tailscale 在这件事上的做法,赢得了同行一致的赞赏——评论区里出现频率最高的词是「refreshing」(耳目一新):他们没甩锅,买了 SQLite 官方的支持服务,还掏钱资助了那个开源的调试工具,让全世界的开发者以后排查类似问题时都能用。

公司出钱给开源项目做专用调试工具,在商业世界里并不多见。对普通人来说,这件事还有一层含义:你手机里那些从来不出声、从不刷存在感的底层软件,背后站着无数像这样追查了半年、只为消灭一个「几乎不可能发生」的错误的工程师。

一个 16 年的 bug 被找到了。下一个呢?也许正藏在你手机里某个你最信任的软件中。

参考链接:

  • Tailscale: SQLite WAL-Reset Bug 复盘
  • Antithesis: Breaking the WAL
  • HN 讨论 (item?id=49272832)