Tailscale 追踪数据库损坏根源:潜伏 16 年的 SQLite WAL 重置 Bug
我们如何追查一个潜伏 16 年的 SQLite Bug

去年年底,我们的线上服务状况频出。你可以在 我们的状态页 上看到这个趋势,这种不稳定一直延续到了新的一年。这些故障中有不少都源于一个深藏在 SQLite 里的 Bug,我们花了数月时间进行密集的取证分析才最终定位到它。
如今入夏,我们已经确认找到了这个 Bug,也弄清了它的来龙去脉,更重要的是——我们已经修复了它。
我们知道客户期望 Tailscale 是一项稳定可靠的服务,而在过去几个月里,我们辜负了这份信任。这造成了不好的影响,我们深表歉意。发布这篇文章,是为了向大家说明问题出在哪里、我们是如何应对的,以及最终如何帮助发现了 SQLite 数据库核心深处一个长期存在的 Bug。
Tailscale 的数据库架构
虽然我们的客户端与 控制平面 交互时,看到的是统一的公共端点(controlplane.tailscale.com),但在内部,我们的控制平面被拆分成了多个协调服务器(即"分片")。每个 tailnet 同一时间只落在某一个内部分片上,但可以在分片之间无缝迁移。这些分片是内部的实现细节:你并不知道自己的 tailnet 跑在哪个分片上,也不需要知道。
每个分片都有一个 SQLite 数据库,存储该分片上所有 tailnet 的相关信息。由单个 Go 进程独占访问该数据库,并为这些 tailnet 提供控制平面服务。这种单写者(single-writer)的设计,正是 SQLite 原本设想的典型使用方式。
自 2022 年起,我们就将 SQLite 作为主数据库,选用它的原因是它广为人知、稳定可靠、生态成熟。SQLite 属于那种"无聊的技术"——这是褒义。许多公司在更大规模的部署中使用 SQLite 都毫无问题,我们也预期能享受到同样的省心体验。
在当前的备份流程中,我们每隔几分钟对数据库做一次完整快照,然后将整个 SQLite 文件上传到 S3 存储桶。这套方案从 2023 年初开始运行,一直相安无事。
直到去年八月,一个读取 S3 备份的数据管道报告了某个数据库出错。我们对备份执行了 SQLite 的 PRAGMA integrity_check 命令,确认它确实已损坏。SQLite 损坏虽然有可能发生,但极为罕见,正常运行中不应遇到。我们修复了受影响的数据库,并着手调查原因,却一无所获。
在规模化的运营中,再罕见的事件也会以一定频率出现。所以当损坏一再发生时,我们不该感到意外——事实也确实如此,它反复出现了一次又一次。在最终定位到底层 bug 之前,半年内我们共遭遇了 19 次独立的数据库损坏事件。
听到"数据库损坏"这个词,人们自然会担心数据丢失。鉴于我们的控制平面只处理配置数据,这些数据库里存放的是 tailnet 和设备的元数据,绝不包含你的私有加密密钥或网络流量。在最早期的几次事故中,恢复过程意味着少量新加入的设备或配置变更未能持久化,需要重新录入一小部分元数据。
每次发生损坏时,我们都必须停止该分片上的控制平面进程,同时修复或恢复数据库。这对该分片上的 tailnet 来说非常痛苦,因为在恢复期间它们的整个控制平面都不可用。早期几次故障中,停机时间超过一小时,但我们在后续故障中逐步加快了恢复速度。
每个 tailnet 都是一个网状网络,设备之间通过 WireGuard® 建立点对点连接。当设备加入 tailnet 时,必须先从控制平面获取其他设备的列表,才能建立新的连接——因此如果设备在 SQLite 停机期间上线,就无法连接。在数据库修复期间,已经在线的设备之间仍然保持连接,但无法获知网络的变化。这些 tailnet 也会暂时无法访问基于网页的管理控制台和 Tailscale API。
这对信任也有更广泛的影响。即使只有少数 tailnet 受到影响,我们也会在状态页上发布全局事件公告。很多人看到状态页上的事件,却发现并没有影响到自己。事实上,绝大多数分片和 tailnet 从未经历过数据库损坏事件!但无论是否直接受到影响,反复的停机都会侵蚀信任。
从第一次损坏事件开始,我们就知道这对可靠性是一个严重威胁,并投入了大量工程时间来解决这个问题——但修复并不容易。
寻找故障根源
这个 bug 抵抗住了我们所有最初的排查尝试。
我们查阅了最近的变更,但并没有发现任何相关改动。也没人在维护我们与 SQLite 交互的低层代码——因为这些代码都是多年前写的,此前从未出过问题。我们用极其严格的方式重新审查了所有相关代码,试图找出此前遗漏的 bug,但仍然没有发现任何能解释我们所遇到的损坏现象的问题。
我们在各次损坏事件之间寻找共同点,但一无所获。它和单个分片、客户、tailnet 功能、时段或负载水平都没有关联。我们完全摸不着头脑,不知道是什么触发了这个行为。
由于缺乏稳定的触发条件,我们无法在实验中复现这个 bug,只能部署被动的取证式遥测,在生产环境中当场捕获损坏过程。对一个数据库问题采集实时诊断数据是我们最不想做的事,但当时别无选择。
更麻烦的是,损坏并不是按固定周期发生的,有时相隔几小时,有时相隔几周。这让我们很难预测进度或规划后续工作,因为谁也不知道下一次诊断数据什么时候会来。10 月到 12 月之间有整整六周没出过损坏事件,结果它们在圣诞节又杀了回来,给我们送了一份不受欢迎的"礼物"。
鉴于这不会是一个能快速或轻松解决的修复,我们联系了 SQLite 开发团队,签订了专业技术支持合同。这是一个非常正确的决定,让我们可以直接接触到他们深厚的专业知识和丰富经验,双方就我们的架构和故障事件进行了大量深入的技术讨论。
Tailscale 工程团队和 SQLite 核心开发者一起梳理出了若干可能导致损坏的假设——包括close() 导致 POSIX 锁被破坏、错误管理 SQLite 拥有的内存、以及在禁用了线程安全的情况下从多个线程同时使用 SQLite。每一次故障发生后,我们都采集更多数据、增加更多诊断信息,并逐一排除这些假设,逐步逼近真正的 bug。
沉默的交易
在排查根因的同时,我们的平台还在照常运行。于是我们采取了一系列激进的措施来自动化恢复流程、缩短停机时间:
- 将控制平面分片配置为一旦检测到损坏立即硬停
- 部署自动备份监控,持续对备份执行
PRAGMA integrity_check - 完善运行手册和值班培训
这些举措把响应时间压缩到了不到一小时——随后我们发现了一条意料之外的线索。
我们希望找到一种恢复服务的方式,既不需要回滚到最后一个已知完好的备份(那样会丢失大量数据),也不需要去修复已知损坏的数据库(风险较高)。
为此,我们搭建了一套事务日志流水线,把每一条修改数据库的 SQL 语句实时流式写入单独的日志文件。由于 SQLite 是单写者数据库,事务具备可串行化的特性,我们的事务历史是完全线性、确定的。(如果是 Postgres 或 MySQL 这类多写者数据库,就做不到这一点。)将这些事务在最新的已知完好备份上重放,就能安全地绕过损坏,把数据库恢复到最近的状态。
这条流水线确实有效,但随后它给了我们更大的惊喜:它提供了一条线索。
在两起事故中,我们的事务日志都无法干净地重放。仔细排查后发现,某事务写入并提交的数据,对后续事务而言竟然凭空消失了——一次写入悄无声息地消失了,没有报任何错误。这本不该发生!
WAL 上的蛛丝马迹
这些事故发生期间,SQLite 的开发者们正好在开发一款新的调试工具。有一段时间,我们一直怀疑问题出在 checkpoint 流程上。他们开发的这款新工具正是为了让 checkpoint 期间发生的事情更加清晰可见。
要理解这个工具发现了什么,我们先简单介绍一下 SQLite 的 checkpoint 是怎么工作的。
一个 SQLite 数据库由一系列“页”(pages)组成,页是非常小的数据块。当你更新数据库时,其中一些页需要被包含新数据的新页替换。
为了提升性能和并发能力,我们启用了 Write-Ahead Logging(WAL)模式,也就是说新页不会直接写入数据库文件,而是先写入“预写日志”,也就是 WAL 文件。
新页不能无限制地往 WAL 文件里写,到了一定阶段,它们必须被拷回主数据库文件,这个过程就叫 checkpoint。
在大多数部署中,checkpoint 的时机由 SQLite 自己决定,对终端用户和开发者完全透明。而在我们的控制平面里,我们手动接管了 checkpoint 流程,这样就能跑出快速且一致的备份。随着我们逐步排除各种可能的原因,这种非标准的做法就显得格外可疑。
有一条线索是:在数据损坏事故发生时,我们的监控指标显示,SQLite 报告从 WAL 文件拷贝出来的页数比实际可用的还多。如果 WAL 文件里有 10 个页,却向数据库拷了 20 个,那显然有问题。
为了弄清楚这些异常检查点背后到底发生了什么,SQLite 的开发者为虚拟文件系统层专门打造了一个调试工具。
SQLite 的内部架构是分层设计的。最上层是解析器和代码生成器,负责把 SQL 语句转化为 SQLite 的内部数据结构;这些数据结构随后交给页管理模块(pager),由其拆分成一个个页面;最后由操作系统接口——也就是"虚拟文件系统"层——实际写入磁盘。目前 SQLite 有两套主流的虚拟文件系统实现,分别面向 Unix 和 Windows。
如果你想深入了解这些内部机制,推荐观看 Richard Hipp(SQLite 主要作者)的这次讲座。
这种分层架构的好处在于,你可以用不同实现替换任意一层,或者在现有层外面包一层来获取更多信息。为了诊断我们遇到的问题,SQLite 开发者就在虚拟文件系统外面包了一层,用于记录关于数据库变更的额外追踪信息。该包装层被称为 tmstmpvfs 垫片(shim),源代码已发布在 SQLite 公共代码仓库 中。
我们将该垫片部署到线上环境,等待下一次数据库损坏发生。幸运的是,没等太久。
WAL 重置 Bug
再次发生数据损坏后,新版 tmstmpvfs shim 带来的额外日志帮助 SQLite 开发者定位并修复了这个 bug:SQLite 源码中 checkpoint 与写事务之间存在一个罕见的数据竞争。
具体来说,如果在 checkpoint 执行过程中的某个特定时刻发生了一次写入,checkpoint 进程就会陷入混乱——它以为某些 page 已经从 WAL 复制到了主数据库文件,但实际上并没有。这些 page 永远不会被写入数据库文件,数据也就永久丢失了。而引用这些 page 的其他内容(比如索引)却被写入了数据库,最终导致数据库文件损坏。
SQLite 开发者将这个 bug 命名为"WAL-Reset bug",并估计它在 SQLite 中存在了至少 16 年。它能潜伏这么久是因为极其罕见——罕见到 SQLite 开发者不得不在测试环境中专门添加代码来主动触发它。修复方案是在 checkpoint 函数中增加一道检查,用于检测 WAL 是否被其他线程重置。
开发者确认,我们之前遇到的所有诡异现象都是由这个 bug 引起的。它解释了数据损坏、无法正常应用的事务日志,以及不一致的 checkpoint 统计数据。同时也解释了为什么我们比其他 SQLite 用户更容易踩到这个 bug:我们手动接管了 checkpoint 流程,而且 checkpoint 非常激进。即便是由极低概率条件触发的 bug,迟早也会被我们撞上。
这是一个令人振奋的时刻。经过数月的困惑与不确定,我们终于有了一个合理的理论来解释数据损坏的原因,以及一个可以部署的修复方案。
SQLite 开发者将修复版本作为 SQLite 3.52.0 发布,我们也准备在版本可用后立即部署。
修复,但伴随一次虚惊
我们谨慎地推进 SQLite 3.52.0 的上线——先在几个 canary 分片上部署,确认运行稳定后,再推广到控制平面的其余部分。
我们的备份监控立刻亮起红灯,报告有 13 个不同的数据库出现了损坏。这非常吓人,不过我们按照恢复流程把所有"损坏"都修好了,一切恢复平静。后来发现这些数据库并没有真的损坏,而是碰到了该版本 SQLite 的另一个问题。
我们把错误信息反馈给了 SQLite 开发团队,由此挖出了 SQLite 中一个与陈旧表达式索引相关的 bug。如果你在某个计算结果上建了索引,之后计算逻辑发生了变化,索引里就会留下不一致的值,PRAGMA integrity_check 会把这种情况误报为损坏。
在我们的场景中,部分高精度时间戳是以文本形式存储的,再通过一个 VIRTUAL 生成列转换为浮点数。修复了我们那个数据竞争的 SQLite 3.52.0 版本同时还做了一项优化,微妙地改变了文本转浮点时的舍入行为。我们的金丝雀分片里没有触发新舍入行为的时间戳,所以分阶段发布时漏掉了这个问题。
由于这个改动会引发虚假的损坏告警,SQLite 开发团队撤回了 3.52.0 版本,转而发布了 3.51.3,其中只包含对 WAL-Reset bug 的修复。
我们这边的解决办法是把时间戳精度降到整数秒——文本转整数的转换是无歧义的。与此同时,SQLite 团队在 3.53.0 中加入了一个自动的索引自愈机制,避免了陈旧表达式索引的问题。
庆祝时刻!
在整个控制平面部署完修复之后,我们准备宣告胜利,但仍然保持谨慎。损坏事件不再出现,并不代表问题已经解决——我们之前就经历过长达六周的虚假平静期。
我们需要确凿证据来证明这个数据竞争确实在生产环境中发生。既然已经弄清了 bug 的成因——写事务与 WAL 重置之间的冲突——我们便给 SQLite 驱动打上补丁,在这两类操作发生重叠时输出一条警告日志。如果警告触发但数据库没有损坏,就说明补丁成功帮我们避免了一次潜在的损坏事故。
我们部署了警告,然后就等着。一周又一周,一月又一月。几周过去后,我们开始怀疑:警告是不是有毛病?我们的理论是不是不对?真正的 bug 是不是还藏在暗处?
两个月后,苦苦等待的告警终于触发了:

这次告警证明,WAL-Reset bug 所需的精确条件在生产环境中确实存在,这也意味着它很可能就是导致我们长达六个月不稳定运行的元凶。
自从那次令人既兴奋又欣慰的告警触发以来,截至本文撰写时,我们又平稳运行了四个月,没有再出现任何数据库故障。终于可以松一口气了。
少有人走的路
没有人愿意花六个月的时间在 SQLite 里找 bug。这段经历对客户和团队来说都极其令人沮丧,大家都很高兴能把这段不稳定的时期抛在身后。
这次调查给我们带来一个很有价值的提醒:用非标准方式运行一项成熟的技术,本身就是一种风险。常规的路径和标准配置经过无数次验证,可靠性极高。大多数人都在标准配置下使用 SQLite,从未遇到过这类问题。我们所采用的虽然也是公开、有文档支持、被官方认可的配置——但由于我们手动接管了 checkpoint 流程,并按照自己的激进节奏运行,实际上已经偏离了久经验证的最佳实践路径。
这次事件的解决是一场大规模的跨团队协作,数十人参与其中——包括 Tailscale 的工程和客服团队,以及 SQLite 的核心维护者。正是因为所有人的努力,这些事件的影响才没有进一步扩大。
我们清楚,反复的故障会侵蚀信任,无论波及多少用户。我们由衷感谢客户在整个排查过程中的耐心和支持。
虽然这段经历令人沮丧,但我们也比之前站得更稳。SQLite 中存在多年的这个 Bug 已被修复,借此机会我们还顺手解决了排查过程中发现的数十个其他问题。我们资助了开源 SQLite VFS shim 的开发,它帮助我们迅速隔离了竞态条件,未来也将助力发现类似的 Bug。此外,我们完善了数据库备份与恢复流程,并已实际演练了十多次。
希望今后不会再出现类似的数据库事故——但万一发生,我们已经做好了准备。
Share作者
Alex Chan作者
Alex ChanShareLoading...