Reed's News
← 返回精选

追查 SQLite 中潜伏16年的 WAL-Reset 漏洞

Tech 83 ropbear 2026/8/12 2841 字 原文 ↗

去年年底,我们的服务可用性相当不稳定,相关趋势可在状态页面查看,且这种不稳定状态一直持续到今年年初。多数故障源于SQLite底层的一个单一bug,我们耗时数月开展深度排查才最终定位问题。

如今已至夏季,我们确信已找到并理解该bug,更重要的是,我们已完成修复。

我们深知客户期望Tailscale是一项可靠的服务,但过去数月我们未能兑现这一承诺,给大家带来了困扰,我们深表歉意。发布这篇博客,旨在说明问题根源、我们的应对过程,以及最终如何协助发现SQLite数据库核心存在的一个长期bug。

尽管客户通过单一公共端点(controlplane.tailscale.com)与我们的控制平面交互,但在内部,控制平面被拆分为一系列协调服务器(或称"分片")。每个tailnet(私有网络)同一时间仅归属一个内部分片,但可在分片间无缝迁移。分片属于内部实现细节:用户无需知晓自己的tailnet位于哪个分片,也完全没有必要了解。

每个分片都配有一个SQLite数据库,存储该分片上所有tailnet的信息。单个Go进程独占访问该数据库,并为这些tailnet提供控制平面服务。这种单写入者设计完全符合SQLite的预期使用方式。

自2022年起,我们便将SQLite作为主数据库,选择它是因为其知名度高、性能可靠且应用广泛。SQLite是一种"乏味技术"——这是褒义的说法。许多企业在规模更大的部署中使用SQLite都未出现问题,我们原本也期望能同样省心。

当前的备份流程中,我们每隔几分钟对数据库进行一次完整快照,再将整个SQLite文件上传至S3存储桶。自2023年初以来,这套流程一直运行顺畅,未发生任何意外。

时间来到去年8月,读取这些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等多写入者数据库中无法实现)。将这些事务记录重放至最新的已知完好备份,就能安全绕过损坏,将数据库恢复到最近状态。

这条流水线成功运行,还带来了意外收获:它提供了一条关键线索。

在两起事件中,事务日志无法顺利重放。经仔细检查发现,某一事务写入并提交的数据,后续事务竟无法读取——写入的数据凭空消失,且未触发任何错误。这在理论上是不可能发生的!

在我们调查期间,SQLite开发者正在开发一款新的调试工具。我们曾怀疑bug出现在检查点(checkpoint)流程中,而这款工具正是为了更清晰地展示检查点期间的运行状态。

要理解该工具的发现,需先简要说明SQLite检查点的工作原理。

SQLite数据库由一系列"页"组成,即存储数据的小块单元。更新数据库时,部分页会被包含更新内容的新页替换。

为提升性能和并发能力,我们启用了SQLite的预写日志(Write-Ahead Logging)功能,这意味着新页不会直接写入数据库文件,而是先写入"预写日志"(即WAL文件)。

新页不能无限期存储在WAL文件中,最终必须复制回主数据库文件,这个过程称为"检查点"。

在大多数部署场景中,SQLite会自动决定何时执行检查点,该过程对终端用户和开发者完全透明。但在我们的控制平面中,为实现快速且一致的备份,我们手动控制检查点流程。随着其他潜在原因被逐一排除,这种非标准操作方式开始显得可疑。

一条线索显示,在损坏事件发生时,我们的指标显示SQLite报告从WAL文件复制的页数超过了实际可用页数。如果WAL文件中只有10页,却有20页被复制到数据库,显然存在问题。

为弄清故障检查点期间的具体情况,SQLite开发者为虚拟文件系统层开发了一款新调试工具。

SQLite分为多个层:顶层是解析器和代码生成器,负责将SQL语句转换为SQLite内部数据结构;这些数据结构被传递给分页器(pager),分页器将其拆分为要写入磁盘的独立页;实际写入磁盘的操作由操作系统接口(即"虚拟文件系统")处理。目前SQLite有两种主流虚拟文件系统实现——Unix和Windows。

如果想深入了解这些内部机制,我推荐SQLite主要作者Richard Hipp的这场讲座

这种分层设计允许替换不同层的实现,或对现有层进行包装以获取更多信息。为协助诊断我们的问题,SQLite开发者在虚拟文件系统外添加了一个包装层,用于记录数据库变更的额外追踪信息和日志。这个包装层名为tmstmpvfs垫片(shim),源代码可在SQLite公共代码库中获取。

我们将该垫片部署到生产环境,等待下一次损坏事件发生。幸运的是,我们没有等太久。

在下次损坏事件发生后,新的tmstmpvfs垫片生成的额外日志帮助SQLite开发者找到了并修复了bug:SQLite源代码中存在一个罕见的检查点与写入事务之间的数据竞争问题。

具体来说,如果在检查点执行的特定时间点发生写入操作,检查点流程会出现混淆——它误以为部分页已从WAL复制到主数据库文件,但实际上并未完成复制。这些页永远不会写入数据库文件,对应数据永久丢失。由于其他引用这些页的页(如索引)已写入数据库,数据库文件因此损坏。

SQLite开发者将此命名为WAL重置bug,据估计它已存在于SQLite中至少16年。之所以能存在这么久,是因为它触发的概率极低——低到SQLite开发者不得不添加代码在测试环境中刻意触发它。修复方案是在检查点函数中添加一项额外检查,用于检测WAL是否被其他线程重置。

他们确认正是这个bug导致了我们遇到的所有异常现象,包括数据库损坏、事务日志无法正常重放,以及检查点统计数据不一致。他们还解释了为何我们比其他SQLite用户更易触发该bug:我们手动控制检查点流程,且执行检查点的频率极高。即便触发条件罕见,长此以往也必然会遇到。

这是一个令人振奋的时刻。历经数月的困惑与不确定,我们终于找到了数据库损坏的合理解释,以及可部署的修复方案。

SQLite开发者在SQLite 3.52.0版本中发布了修复,我们准备一旦该版本可用就立即部署。

我们谨慎地推进SQLite 3.52.0的部署:先在少数金丝雀分片(canary shard)上线,确认运行平稳后,再部署到整个控制平面。

很快,我们的备份监控工具发出警报,报告13个不同数据库出现损坏。这让我们极为震惊,但我们按照恢复流程处理了所有所谓的"损坏",最终一切恢复正常。后来发现这些数据库并未真正损坏,而是受到SQLite另一个问题的影响。

我们将错误信息反馈给SQLite开发者,发现这是一个与过期表达式索引相关的bug。如果在计算值上创建索引,之后计算逻辑发生变化,索引就会包含不匹配的值,被PRAGMA integrity_check报告为损坏。

我们的场景是将一些高精度时间戳存储为文本,在虚拟生成列中转换为浮点数。而修复数据竞争问题的SQLite 3.52.0版本还做了一项优化,细微改变了文本转浮点数的舍入行为。由于金丝雀分片没有触发舍入行为变更的时间戳,我们在分阶段部署中未发现此问题。

由于该变更会导致虚假的损坏警告,SQLite开发者撤回了3.52.0版本,转而发布了仅包含WAL重置bug修复的3.51.3版本。

我们通过将时间戳精度降低到整数秒解决了这个问题——文本转整数的转换是明确无歧义的。同时,SQLite开发者在3.53.0版本中新增了自动化的自修复索引功能,可防止过期表达式索引问题。

修复方案部署到整个控制平面后,我们准备宣布问题解决,但仍保持谨慎。没有损坏事件不代表问题已彻底解决——我们此前就经历过长达六周的"虚假平静"。

我们需要确凿证据证明该数据竞争确实在生产环境中发生。既然已理解bug的根源——写入事务与WAL重置的冲突,我们便修改SQLite驱动,在这两种操作重叠时记录警告。如果警告触发但数据库未损坏,就说明修复方案成功避免了一次潜在的损坏事件。

我们部署了该警告机制,开始等待。一周又一周过去,我们开始疑惑为何没有收到警告:是警告机制失效?还是我们的理论错误?抑或真正的bug仍隐藏在暗处?

两个月后,我们等待的警报终于触发:

Alert Manager通知显示SQLitePartyMode警告:SQLite在shard2.corp.ts.net:8383的"派对模式"下尝试损坏数据库,但系统已阻止。详情包括警告代码、主机、实例、任务、命名空间、严重性和分片信息。提示建议查看服务器日志获取损坏事件详细信息。

这条警报证明,触发WAL重置bug的精确条件确实存在于我们的生产环境中,这意味着它很可能就是导致我们六个月服务不稳定的罪魁祸首。

截至本文撰写时,自那条令人欣喜的警报触发后,我们已连续四个月未发生任何数据库事件。终于,我们可以松一口气了。

没人愿意花六个月时间在SQLite中寻找bug。这段经历对客户和员工来说都无比煎熬,我们都很高兴能摆脱这种不稳定状态。

这次调查提醒我们:以非标准方式使用"乏味技术"存在风险。常规路径和标准配置经过了充分测试,可靠性极高。大多数人以标准配置使用SQLite,从未遇到过此类问题。我们的操作都是公开、有文档记录且受支持的配置,但通过手动控制检查点流程并以高频执行,我们偏离了经过验证的常规操作路径。

解决这些事件是一项涉及数十人的大规模跨团队协作成果,包括Tailscale的工程和支持团队,以及SQLite的核心维护者。正是他们的努力,才未让事件造成更严重的影响。

我们深知无论受影响人数多少,反复停机都会削弱用户信任,感谢客户在我们排查问题期间的耐心与支持。

尽管这段经历令人沮丧,但我们现在的处境比之前更稳固。SQLite中的长期bug已被修复,我们还解决了排查过程中发现的数十个其他次要问题。我们资助开发的开源SQLite VFS垫片,几乎立即帮助我们定位了数据竞争问题,未来也将有助于排查类似bug。此外,我们优化了数据库备份和恢复流程,并进行了十多次实战测试。

希望不会再发生此类数据库事件,但如果真的发生,我们已做好准备。