Tailscale 如何帮助发现 SQLite WAL-Reset 缺陷

Lobsters Hottest 新闻

摘要

Tailscale 回顾了他们如何追踪一个存在了 16 年的 SQLite bug,该 bug 导致数据库损坏和服务中断,经过数月调查后最终得到修复。

<p>此前,修复该 bug 的 <a href="https://lobste.rs/s/mqpba7/sqlite_wal_reset_database_corruption_bug" rel="ugc">SQLite 发布版本</a>。</p> <p><a href="https://lobste.rs/s/e0lkmi/how_tailscale_helped_find_sqlite_wal">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/08/12 16:22

# 我们如何追踪到一个存在了16年的 SQLite 漏洞 Source: https://tailscale.com/blog/sqlite-wal-reset-bug 去年年底,我们的正常运行时间[相当不稳定](https://tailscale.com/blog/hypergrowth-isnt-always-easy)。你可以在我们的[状态页面](https://status.tailscale.com/history)上看到这一趋势,这种不稳定性一直持续到了新年。这些中断中有许多是由一个深藏在 [SQLite](https://sqlite.org/index.html) 中的单一漏洞引起的。我们花了数月时间进行深入的取证调查才找到它。 现在已经是夏天,我们有信心已经找到了这个漏洞,理解了它——更重要的是,我们已经修复了它。 我们知道客户期望 Tailscale 是一个可靠的服务,而有好几个月我们辜负了这一承诺。这给大家带来了困扰,我们深表歉意。我们发布这篇博客文章,是为了解释出了什么问题、我们如何应对,以及我们如何最终帮助揭示了 SQLite 数据库核心中一个长期的漏洞。 ## Tailscale 的数据库架构 虽然我们的客户端将我们的[控制平面](https://tailscale.com/docs/concepts/control-data-planes)视为一个公共端点(controlplane.tailscale.com),但在内部,我们的控制平面被拆分成一系列协调服务器(或“分片”)。每个 tailnet 在任一时刻都位于一个内部分片上,但可以在分片之间无缝迁移。这些分片是内部实现细节:你不知道你的 tailnet 在哪个分片上,也永远不需要知道。 每个分片都有一个 SQLite 数据库,保存着该分片上所有 tailnet 的信息。一个 Go 进程独占访问该数据库,并为这些 tailnet 提供控制平面服务。这种单写入者设计正是 SQLite 的预期使用方式。 架构图:说明 Tailscale 控制平面由隔离的分片组成,每个分片都有一个独立的 SQLite 数据库。我们从 [2022 年](https://tailscale.com/blog/database-for-2022)开始将 SQLite 作为我们的主数据库,我们选择它是因为它知名、可靠且被广泛使用。SQLite 是“无聊的技术”——从好的意义上说。许多公司在更大的部署中使用 SQLite 都没有遇到问题,我们也期望同样无忧无虑地使用它。 在我们当前的备份管道中,我们每隔几分钟拍摄一次数据库的完整快照,然后将整个 SQLite 文件上传到 S3 存储桶。自 2023 年初以来,我们一直运行着这个设置,从未发生任何事故。 时间快进到去年八月,一个读取这些 S3 备份的数据管道在我们的一个数据库中报告了一个错误。我们对备份运行了 SQLite 的 `PRAGMA integrity_check` 命令,发现它确实已损坏。SQLite 损坏[是有可能发生的](https://www.sqlite.org/howtocorrupt.html),但这是极不寻常的,你不应该在正常操作中遇到它。我们修复了受影响的数据库,并调查了原因,但无果。 当以规模运营时,即使是罕见的事件也可能以一定的频率发生。因此当这种情况再次发生——一次又一次又一次——我们本不应感到意外。在我们最终解决了底层漏洞之前,我们总共在六个月内面临了 19 次独立的数据库损坏事件。 当你听到“数据库损坏”这个短语时,很自然地会担心数据丢失。因为我们的控制平面只处理配置数据,这些数据库包含关于你的 tailnet 和设备元数据,但绝不包括你的私密加密密钥或网络流量。在最早的事件中,恢复过程意味着少数新添加的设备或配置变更没有被持久化,少量元数据不得不重新输入。 架构图:说明 Tailscale 控制平面如何应对单个分片上的数据库问题。当 SQLite 数据库发生损坏时,会影响该分片上的流量,但其他分片不受影响。每当发生损坏时,我们都必须在修复或恢复数据库的同时停止该分片上的控制平面进程。这对于该分片上的 tailnet 来说非常痛苦,因为它们的整个控制平面在恢复窗口期都消失了。在早期事件中,停机时间超过一个小时,但我们在后续事件中逐渐加快了恢复过程。 每个 tailnet 都是一个网状网络,设备之间通过点对点 WireGuard® 连接。当设备加入 tailnet 时,它必须先从控制平面获取其他设备的列表,然后才能建立新连接——因此,如果设备在 SQLite 停机期间上线,它将无法连接。在数据库修复期间,已经在线设备之间仍然保持连接,但它们无法获知网络的变化。这些 tailnet 还暂时失去了对基于 Web 的管理控制台和 Tailscale API 的访问。 还有一个更广泛的信任影响。即使只有少数 tailnet 受到影响,我们也会在我们的[状态页面](https://status.tailscale.com/)上发布一个全局事件。许多人看到一个并未影响到他们的事件状态页面通知。事实上,大多数分片和 tailnet 从未涉及任何数据库损坏事件!尽管如此,重复的停机侵蚀着信任,无论你是否直接受到影响。 从第一起损坏事件开始,我们就知道这是对我们可靠性的严重威胁,我们投入了大量工程时间来解决这个问题——但修复并不容易。 ## 试图找到故障 这个漏洞抵抗了我们所有最初的寻找尝试。 我们查看了最近的变更,但没有任何看起来相关的变更。没有人动过我们与 SQLite 交互的底层代码,因为那些代码都是多年前编写的,而且直到那时都没有出现过任何问题。我们非常仔细地重新审查了所有这些代码,寻找之前遗漏的漏洞,但我们没有找到任何会导致我们看到的损坏的东西。 我们寻找损坏事件之间的共同因素,但找不到任何共同因素。它不绑定于某个分片、客户、tailnet 功能、一天中的某个时间或负载水平。我们完全不知道可能是什么触发了这种行为。 由于缺乏可靠的触发条件,我们无法在合成环境中复现这个漏洞。相反,我们必须依靠在真实环境中部署被动的取证遥测,来当场捕获损坏。为数据库问题收集实时诊断信息是我们最不想做的事情,但我们别无选择。 另一个复杂的因素是,损坏并不是按固定规律发生的。有时事件之间相隔数小时,有时则相隔数周。这使得预测进展或规划后续工作变得困难,因为我们从不确知下一次诊断转储什么时候会来。在十月到十二月之间有一段六周的窗口期,期间没有发生任何损坏事件,然后它们又像一份不受欢迎的圣诞礼物一样回来了。 由于这不是一个快速或容易的修复,我们联系了 SQLite 开发者,寻求[专业支持合同](https://sqlite.org/prosupport.html)。这是一个非常正确的决定。它让我们直接获得了他们深厚的专业知识和经验,我们就我们的架构和事件进行了许多详细的技术讨论。 在 Tailscale 工程团队和 SQLite 核心开发者之间,我们规划了多种可能导致损坏的理论——包括[close() 时 POSIX 锁被破坏](https://www.sqlite.org/howtocorrupt.html#_posix_advisory_locks_canceled_by_a_separate_thread_doing_close_)、错误管理 SQLite 拥有的内存,或者在禁用线程安全的情况下意外从多个线程使用 SQLite。每次事件后,我们都收集更多数据,增加更多诊断,并系统地排除了这些理论。我们逐渐向真正的漏洞收敛。 ## 那些没有“吠叫”的事务 在我们调查根本原因的同时,我们仍然运营着实时平台。我们采取了激进的措施来实现恢复自动化和减少停机时间: - 配置我们的控制平面分片,在遇到损坏时立即硬停止 - 部署一个自动备份监控器,持续对我们的备份运行 `PRAGMA integrity_check` - 改进我们的 runbook 和值班培训 这些努力将我们的响应时间缩短到一小时以内——然后我们意外地发现了一个线索。 我们想要一种恢复服务的方式,而不必回滚到最后一个已知良好的备份(这会丢失大量数据)或修复已知损坏的数据库(这有潜在风险)。 为此,我们构建了一个事务日志管道。我们将每一条修改数据库的 SQL 语句流式写入单独的日志文件。因为 SQLite 是[单写入者数据库](https://sqlite.org/faq.html#:~:text=only%20one%20process%20can%20be%20making%20changes%20to%20the%20database%20at%20any%20moment%20in%20time),具有[可序列化事务](https://www.sqlite.org/isolation.html),所以我们的事务历史是完全线性且确定性的。(在像 Postgres 或 MySQL 这样的多写入者数据库中,情况并非如此。)将那些事务重放到最新的已知良好备份上,应该能将数据库恢复到其最近的状态,从而安全地绕过损坏。 图:说明如何通过重放两次备份之间发生的事务,来重建两次备份之间的变更。这个管道起作用了,但然后它做了件更好的事:它给了我们一个线索。 在两起事件中,我们的事务日志未能干净地重放。经过仔细检查,我们发现一个事务写入并提交的数据,不知为何对后续事务不可见。一个写入凭空消失了,没有引发任何错误。这应该是不可能的! ## WAL 上的文字 在这些事件持续进行的同时,SQLite 开发者一直在开发一个新的调试工具。有一段时间,我们怀疑漏洞就在检查点过程中的某个地方。他们正在构建一个新工具,用于更清楚地了解检查点期间发生的情况。 要理解这个工具发现了什么,我们需要简要解释 SQLite 检查点是如何工作的。 SQLite 数据库由一系列“页”组成,即一个个小的信息块。当你更新数据库时,其中一些页需要用包含更新信息的新页替换。 为了获得更好的性能和更高的并发性,我们使用预写式日志(Write-Ahead Logging)运行 SQLite,这意味着新页不会直接写入数据库文件。而是先写入“预写式日志”,也就是“WAL 文件”。 图:说明数据库文件和预写式日志(也称为“WAL 文件”)之间的区别。两者都由独立的“页”组成,新页首先写入 WAL 文件。新页不能无限期地写入 WAL 文件;在某个时刻,它们必须被复制回主数据库文件。这个过程被称为“检查点”(checkpointing)。 图:说明 SQLite 检查点过程。WAL 文件中的页被复制回数据库文件。新页可以替换数据库文件中的任何现有页,也可以追加到文件末尾。在大多数部署中,SQLite 自己决定何时进行检查点,这个过程对最终用户和开发者是不可见的。在我们的控制平面中,我们手动控制检查点过程,以便运行快速且一致的备份。这种非标准的方法在我们逐步排除潜在原因时显得可疑。 一个线索是,在损坏事件期间,我们的指标显示 SQLite 报告从 WAL 文件复制的页数多于实际可用的页数。如果 WAL 文件中有 10 页,但有 20 页被复制到数据库中,那显然有问题。 为了理解这些错误检查点期间发生了什么,SQLite 开发者创建了一个用于虚拟文件系统层的新调试工具。 SQLite 分为多个层。顶层是解析器和代码生成器,它将 SQL 语句转换为 SQLite 的内部数据结构。这些数据结构传递给分页器(pager),分页器将它们拆分成将要写入磁盘的独立页。实际将它们写入磁盘由操作系统接口,即“虚拟文件系统”处理。目前 SQLite 有两个主流的虚拟文件系统实现——Unix 和 Windows。 图:说明 SQLite 的内部结构。它接收 SQL 语句作为输入,这些语句经过三个层:解析器/代码生成器、分页器和操作系统接口/虚拟文件系统。文件系统层将变更写入磁盘。如果你想更深入地了解这些内部机制,我推荐 Richard Hipp(SQLite 的主要作者)的[这个讲座](https://www.youtube.com/watch?v=ZSKLA81tBis)。 这种方法允许你用不同的实现替换不同的层,或者包装现有层以获取更多信息。为了帮助诊断我们的问题,SQLite 开发者创建了一个包装虚拟文件系统的封装层,它会写入关于数据库变更的额外跟踪信息和日志。这个封装层被称为 `tmstmpvfs` shim,源代码可在 [SQLite 公共仓库](https://sqlite.org/src/file/ext/misc/tmstmpvfs.c?proof=394548354) 中获得。 图:说明我们新增调试层后 SQLite 的内部结构。操作系统接口/虚拟文件系统层现在被包裹在 tmstmpvfs shim 中。我们将这个 shim 部署到真实环境中,等待下一次损坏发生。幸运的是,我们并没有等太久。 ## WAL-Reset 漏洞 在我们下一次损坏事件之后,来自新的 `tmstmpvfs` shim 的额外日志使 SQLite 开发者能够找到并修复这个漏洞:SQLite 源代码中检查点与写入事务之间的一个罕见数据竞争。 具体来说,如果在检查点期间的某个特定时刻发生写入,检查点进程就会困惑——它认为某些页已从 WAL 复制到主数据库文件中,但实际上并没有。那些页从未被写入数据库文件,这些数据被永久丢失。数据库文件变得损坏,因为引用那些页的其他页——例如索引——被写入了数据库。 SQLite 开发者将其命名为 [“WAL-Reset bug”](https://sqlite.org/wal.html#the_wal_reset_bug),他们估计这个漏洞在 SQLite 中至少存在了 16 年。它能存在那么久,是因为它非常罕见——罕见得 SQLite 开发者不得不添加代码,以便在他们的测试环境中故意触发它。他们的修复[为检查点函数增加了一个额外的检查](https://sqlite.org/src/info/7168988acbec2d8d),用于检测 WAL 是否已被另一个线程重置。 他们确认这个漏洞导致了我们所见过的所有令人困惑的行为。它解释了损坏、无法干净应用的事务日志,以及不一致的检查点统计信息。他们还解释了为什么我们比其他 SQLite 用户更容易遇到这个漏洞:我们手动控制检查点过程,并且我们的检查点非常激进。即使是一个由罕见条件触发的漏洞,也注定最终会找上我们。 这是一个激动人心的时刻。在数月困惑和不确定之后,我们终于有了一个合理的理论来解释……

相似文章

再次审视SQLite的WAL-Reset漏洞

Lobsters Hottest

本文讨论了SQLite WAL-reset机制中一个长期存在的漏洞,该漏洞导致数据丢失和损坏。通过一个100行的C语言工作负载进行复现,以展示竞争条件。

打破 WAL

Hacker News Top

作者描述了使用 Antithesis 与 Claude 在 15 分钟内重现了一个长期存在的 SQLite WAL 重置错误,并在 3.51.3 版本中验证了修复。

iPad 在 Tailscale 上:一个 WebRTC 调试故事

Hacker News Top

一位开发者的调试历程,揭示了基于 WebRTC 的应用 (p2claw) 在 iPad 上因两个 bug 而失败:webrtc-rs 中的一个硬编码常量,以及 Tailscale 中的一个单行设计决策,导致数据通道消息丢失。

用细齿梳检查 SQLite

Lobsters Hottest

John Regehr 描述了使用 tis-interpreter 在 SQLite 中搜索未定义行为,发现了其他工具遗漏的错误,如悬空指针使用和未初始化读取。