← 文章 / 数据与数据库
Hacker News 8小时前 · 2026-08-13 03:32:24 · 9 阅读

Tailscale 追踪数据库损坏根源:潜伏 16 年的 SQLite WAL 重置 Bug

博客 | 洞察2026 年 8 月 12 日

我们如何追查一个潜伏 16 年的 SQLite Bug

淡橙色和深黄色的椭圆形、方形、圆形及四分之一圆形等图形,背景为浅黄色。

去年年底,我们的线上服务状况频出。你可以在 我们的状态页 上看到这个趋势,这种不稳定一直延续到了新的一年。这些故障中有不少都源于一个深藏在 SQLite 里的 Bug,我们花了数月时间进行密集的取证分析才最终定位到它。

如今入夏,我们已经确认找到了这个 Bug,也弄清了它的来龙去脉,更重要的是——我们已经修复了它。

我们知道客户期望 Tailscale 是一项稳定可靠的服务,而在过去几个月里,我们辜负了这份信任。这造成了不好的影响,我们深表歉意。发布这篇文章,是为了向大家说明问题出在哪里、我们是如何应对的,以及最终如何帮助发现了 SQLite 数据库核心深处一个长期存在的 Bug。

Tailscale 的数据库架构

虽然我们的客户端与 控制平面 交互时,看到的是统一的公共端点(controlplane.tailscale.com),但在内部,我们的控制平面被拆分成了多个协调服务器(即"分片")。每个 tailnet 同一时间只落在某一个内部分片上,但可以在分片之间无缝迁移。这些分片是内部的实现细节:你并不知道自己的 tailnet 跑在哪个分片上,也不需要知道。

每个分片都有一个 SQLite 数据库,存储该分片上所有 tailnet 的相关信息。由单个 Go 进程独占访问该数据库,并为这些 tailnet 提供控制平面服务。这种单写者(single-writer)的设计,正是 SQLite 原本设想的典型使用方式。

架构示意图,展示 Tailscale 控制平面由多个隔离分片构成,每个分片拥有独立的 SQLite 数据库。

自 2022 年起,我们就将 SQLite 作为主数据库,选用它的原因是它广为人知、稳定可靠、生态成熟。SQLite 属于那种"无聊的技术"——这是褒义。许多公司在更大规模的部署中使用 SQLite 都毫无问题,我们也预期能享受到同样的省心体验。

在当前的备份流程中,我们每隔几分钟对数据库做一次完整快照,然后将整个 SQLite 文件上传到 S3 存储桶。这套方案从 2023 年初开始运行,一直相安无事。

直到去年八月,一个读取 S3 备份的数据管道报告了某个数据库出错。我们对备份执行了 SQLite 的 PRAGMA integrity_check 命令,确认它确实已损坏。SQLite 损坏虽然有可能发生,但极为罕见,正常运行中不应遇到。我们修复了受影响的数据库,并着手调查原因,却一无所获。

在规模化的运营中,再罕见的事件也会以一定频率出现。所以当损坏一再发生时,我们不该感到意外——事实也确实如此,它反复出现了一次又一次。在最终定位到底层 bug 之前,半年内我们共遭遇了 19 次独立的数据库损坏事件。

听到"数据库损坏"这个词,人们自然会担心数据丢失。鉴于我们的控制平面只处理配置数据,这些数据库里存放的是 tailnet 和设备的元数据,绝不包含你的私有加密密钥或网络流量。在最早期的几次事故中,恢复过程意味着少量新加入的设备或配置变更未能持久化,需要重新录入一小部分元数据。

架构图,展示 Tailscale 控制平面如何应对单个分片上的数据库问题。当 SQLite 数据库发生损坏时,只会影响该分片上的流量,其他分片不受影响。

每次发生损坏时,我们都必须停止该分片上的控制平面进程,同时修复或恢复数据库。这对该分片上的 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 文件。

Architecture diagram illustrating the difference between the database file and the write-ahead log (also known as the “WAL file”). Both are made of individual “pages”, and new pages are written to the WAL file first.

新页不能无限制地往 WAL 文件里写,到了一定阶段,它们必须被拷回主数据库文件,这个过程就叫 checkpoint。

Architecture diagram illustrating the SQLite checkpoint procedure. Pages in the WAL file are copied back into the database file. New pages can replace existing pages anywhere in the database file, or be appended to the end of the file.

在大多数部署中,checkpoint 的时机由 SQLite 自己决定,对终端用户和开发者完全透明。而在我们的控制平面里,我们手动接管了 checkpoint 流程,这样就能跑出快速且一致的备份。随着我们逐步排除各种可能的原因,这种非标准的做法就显得格外可疑。

有一条线索是:在数据损坏事故发生时,我们的监控指标显示,SQLite 报告从 WAL 文件拷贝出来的页数比实际可用的还多。如果 WAL 文件里有 10 个页,却向数据库拷了 20 个,那显然有问题。

为了弄清楚这些异常检查点背后到底发生了什么,SQLite 的开发者为虚拟文件系统层专门打造了一个调试工具。

SQLite 的内部架构是分层设计的。最上层是解析器和代码生成器,负责把 SQL 语句转化为 SQLite 的内部数据结构;这些数据结构随后交给页管理模块(pager),由其拆分成一个个页面;最后由操作系统接口——也就是"虚拟文件系统"层——实际写入磁盘。目前 SQLite 有两套主流的虚拟文件系统实现,分别面向 Unix 和 Windows。

SQLite 内部架构示意图。SQL 语句作为输入,依次穿过三层:解析器/代码生成器、页管理模块、操作系统接口/虚拟文件系统,最终由文件系统层将变更写入磁盘。

如果你想深入了解这些内部机制,推荐观看 Richard Hipp(SQLite 主要作者)的这次讲座

这种分层架构的好处在于,你可以用不同实现替换任意一层,或者在现有层外面包一层来获取更多信息。为了诊断我们遇到的问题,SQLite 开发者就在虚拟文件系统外面包了一层,用于记录关于数据库变更的额外追踪信息。该包装层被称为 tmstmpvfs 垫片(shim),源代码已发布在 SQLite 公共代码仓库 中。

新增调试层后的 SQLite 内部架构示意图。操作系统接口/虚拟文件系统层现在被一个 tmstmpvfs 垫片所包裹。

我们将该垫片部署到线上环境,等待下一次数据库损坏发生。幸运的是,没等太久。

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 是不是还藏在暗处?

两个月后,苦苦等待的告警终于触发了:

Alert Manager notification showing SQLitePartyMode warning: SQLite attempted corruption on shard2.corp.ts.net:8383 in party mode, but the system prevented it. Details include warning code, host, instance, job, namespace, severity, and shard information. Message advises checking server logs for corruption incident details.

这次告警证明,WAL-Reset bug 所需的精确条件在生产环境中确实存在,这也意味着它很可能就是导致我们长达六个月不稳定运行的元凶。

自从那次令人既兴奋又欣慰的告警触发以来,截至本文撰写时,我们又平稳运行了四个月,没有再出现任何数据库故障。终于可以松一口气了。

少有人走的路

没有人愿意花六个月的时间在 SQLite 里找 bug。这段经历对客户和团队来说都极其令人沮丧,大家都很高兴能把这段不稳定的时期抛在身后。

这次调查给我们带来一个很有价值的提醒:用非标准方式运行一项成熟的技术,本身就是一种风险。常规的路径和标准配置经过无数次验证,可靠性极高。大多数人都在标准配置下使用 SQLite,从未遇到过这类问题。我们所采用的虽然也是公开、有文档支持、被官方认可的配置——但由于我们手动接管了 checkpoint 流程,并按照自己的激进节奏运行,实际上已经偏离了久经验证的最佳实践路径。

这次事件的解决是一场大规模的跨团队协作,数十人参与其中——包括 Tailscale 的工程和客服团队,以及 SQLite 的核心维护者。正是因为所有人的努力,这些事件的影响才没有进一步扩大。

我们清楚,反复的故障会侵蚀信任,无论波及多少用户。我们由衷感谢客户在整个排查过程中的耐心和支持。

虽然这段经历令人沮丧,但我们也比之前站得更稳。SQLite 中存在多年的这个 Bug 已被修复,借此机会我们还顺手解决了排查过程中发现的数十个其他问题。我们资助了开源 SQLite VFS shim 的开发,它帮助我们迅速隔离了竞态条件,未来也将助力发现类似的 Bug。此外,我们完善了数据库备份与恢复流程,并已实际演练了十多次。

希望今后不会再出现类似的数据库事故——但万一发生,我们已经做好了准备。

Share

作者

Alex ChanAlex Chan

作者

Alex ChanAlex ChanShareLoading...
原始来源: Hacker News

评论 (0)