在大规模运营中,即使罕见事件也会以一定频率发生,所以当它一次又一次、一次又一次地发生时,我们本应感到不足为奇。总计在六个月里,我们面临了 19 次独立的数据库损坏事件,最终才解决了根本问题。
听到“数据库损坏”这个说法,人们自然会担心数据丢失。由于我们的控制平面只处理配置数据,这些数据库包含关于你的 tailnet 和设备的元数据,但绝不包含你的私密加密密钥或网络流量。在最早的几次事件中,恢复过程意味着少数新添加的设备或配置更改没有被持久化,少量元数据不得不重新输入。
每当发生损坏时,我们都必须在修复或恢复数据库的过程中停止该分片上的控制平面进程。这对该分片上的 tailnet 来说非常痛苦,因为在恢复窗口期内,它们的整个控制平面都消失了。在早期事件中,停机时间超过一小时,但在后续事件中我们逐渐加快了恢复速度。
每个 tailnet 都是一个网状网络,设备之间通过 WireGuard® 建立点对点连接。当一台设备加入 tailnet 时,它必须先从控制平面获取其他设备的列表,然后才能建立新连接——因此,如果一台设备在 SQLite 停机期间上线,它将无法连接。在数据库修复期间,已经在线设备之间仍能保持连接,但它们无法获知网络的变更。这些 tailnet 还暂时无法访问基于 Web 的管理控制台和 Tailscale API。
这还会对信任造成更广泛的影响。即使只有少数 tailnet 受影响,我们也会在状态页面上发布全球性事件公告。许多人看到了与自己无关的状态页面事件。事实上,大多数分片和 tailnet 从未卷入数据库损坏事件!尽管如此,反复的停机侵蚀着信任,无论你是否直接受到影响。
从第一次发生损坏起,我们就知道这是对我们可靠性的严重威胁,并投入了大量工程时间来解决这个问题——但修复并不容易。
这个缺陷抵住了我们最初的所有排查尝试。
我们检查了最近的更改,但似乎没有相关的改动。没有人动过我们与 SQLite 交互的底层代码,因为这些代码写于多年前,而且直到那时都没有出现过问题。我们用细齿梳重新审查了所有相关代码,寻找之前未发现的缺陷,但没有找到任何可能导致我们所看到的损坏的原因。
我们寻找了损坏事件之间的共同因素,但一无所获。它不关联到某个特定分片、客户、tailnet 功能、时间段或负载水平。我们完全不知道什么可能触发了该行为。
由于缺乏可靠的触发条件,我们无法在合成环境中重现该缺陷。相反,我们不得不依赖在我们的线上环境中部署被动的取证遥测来当场捕获损坏。为数据库问题收集实时诊断是我们最不想做的事情,但我们别无选择。
另一个复杂的因素是,损坏并没有固定的发生规律。有时事件间隔数小时,有时数周。这使得我们难以预测进展或规划后续工作,因为我们永远不确定下一次诊断转储什么时候会来。在 10 月到 12 月之间有一个六周窗口期,期间没有发生任何损坏事件,然后它们作为一份不受欢迎的圣诞礼物回来了。
由于这不是一个能快速轻松修复的问题,我们联系了 SQLite 开发者,签订了专业支持合同。这是一个很明智的决定。它让我们直接接触到了他们的深厚专业知识和经验,我们围绕架构和事件进行了许多详细的技术讨论。
在 Tailscale 工程团队和 SQLite 核心开发者之间,我们梳理出了几个可能导致损坏的理论——包括close() 上损坏的 POSIX 锁、错误管理 SQLite 拥有的内存,或在禁用线程安全的同时意外地从多个线程使用 SQLite。每次事件后,我们都收集更多数据、添加更多诊断信息,并系统地排除这些理论。我们逐渐收敛到了真正的缺陷。
在调查根本原因的同时,我们仍在运营一个活跃的平台。我们采取了积极措施来自动化恢复并减少停机时间:
这些努力将响应时间缩短到一小时以内——然后我们发现了一条意想不到的线索。
我们想要一种恢复服务的方式,既不需要回滚到上一个已知良好的备份(这会丢失大量数据),也不需要修复已知损坏的数据库(这有潜在风险)。
为此,我们构建了一个事务日志管道。我们将每一条修改数据库的 SQL 语句流式写入一个单独的日志文件。由于 SQLite 是单写入者数据库,具有可串行化事务,我们的事务历史完全是线性和确定性的。(在 Postgres 或 MySQL 这样的多写入者数据库中则不是这样。)将这些事务重放到最新已知良好的备份上,应该能将数据库恢复到最近状态,安全地绕过损坏。
这个管道确实起作用了,但随后它做得更好:它给了我们一条线索。
在两次事件中,我们的事务日志未能干净地重放。仔细检查后,我们发现,由一个事务写入并提交的数据,莫名地对后续事务不可见。一次写入凭空消失了,却没有引发任何错误。这按理说是不可能的!
在这些事件进行期间,SQLite 开发者一直在开发一个新的调试工具。有一段时间,我们一直怀疑缺陷出在 checkpoint 过程的某个地方。他们正在构建一个新工具,以便更好地观察 checkpoint 期间发生的情况。
要理解这个工具发现了什么,我们需要简要解释一下 SQLite checkpoint 的工作原理。
一个 SQLite 数据库由一系列“页面”组成——微小的信息块。当你更新数据库时,其中一些页面需要用包含新数据的新页面替换。为了提高性能和并发性,我们使用预写日志(Write-Ahead Logging)运行 SQLite,这意味着新页面不会直接写入数据库文件,而是写入“预写日志”或“WAL 文件”。
新页面不能无限地写入 WAL 文件;在某个时刻,它们必须被复制回主数据库文件。这个过程被称为“checkpoint”(检查点)。
在大多数部署中,SQLite 自己决定何时执行 checkpoint,这个过程对最终用户和开发者来说是不可见的。在我们的控制平面中,我们手动控制 checkpoint 过程,以便能够运行快速且一致的备份。这种非标准做法在我们逐步排除潜在原因时显得很可疑。
有一条线索是:在损坏事件期间,我们的指标显示 SQLite 报告的从 WAL 文件复制的页面数量超过了实际可用的页面数。如果 WAL 文件中有 10 个页面,却有 20 个页面被复制到数据库,那显然是出了问题。
为了理解这些错误 checkpoint 期间发生了什么,SQLite 开发者专门为虚拟文件系统层创建了一个新的调试工具。
SQLite 分为多个层。顶层是解析器和代码生成器,它将 SQL 语句转换为 SQLite 的内部数据结构。这些数据结构被传递给 pager 层,pager 将它们拆分为单个页面以写入磁盘。实际写入磁盘由操作系统接口(即“虚拟文件系统”)处理。目前 SQLite 有两个主流的虚拟文件系统实现——Unix 和 Windows。
如果你对这些内部机制感兴趣,我推荐Richard Hipp 的这次演讲,他是 SQLite 的主要作者。
这种架构允许你用不同的实现替换不同的层,或者包装现有层以获取更多信息。为了帮助我们诊断问题,SQLite 开发者创建了一个围绕虚拟文件系统的包装器,它会记录额外的跟踪信息以及关于数据库变更的日志。这个包装器被称为 tmstmpvfs shim,源代码位于 SQLite 公共仓库中。
我们将这个 shim 部署到线上环境中,等待下一次损坏发生。幸运的是,我们没有等太久。
在一次损坏事件之后,来自新 tmstmpvfs shim 的额外日志使 SQLite 开发者能够找到并修复这个缺陷:SQLite 源代码中一个罕见的 checkpoint 与写入事务之间的数据竞争。
具体来说,如果写入发生在 checkpoint 期间的特定时刻,checkpoint 进程就会产生混淆——它以为一些页面已经从 WAL 复制到了主数据库文件,但实际上没有。这些页面永远不会被写入数据库文件,数据被永久丢失。数据库文件变得损坏,因为引用这些页面的其他页面(例如索引)被写入了数据库。
SQLite 开发者将此命名为“WAL-Reset 缺陷”,并估计它已在 SQLite 中存在至少 16 年。它之所以能存在那么久,是因为它很罕见——罕见得连 SQLite 开发者都不得不添加代码在测试环境中故意触发它。他们的修复方案为 checkpoint 函数增加了一个额外的检查,用于检测 WAL 是否已被另一个线程重置。
他们确认这个缺陷导致我们看到的全部令人困惑的行为。它解释了损坏、事务日志无法干净重放,以及 checkpoint 统计信息不一致的原因。他们还解释了为什么我们比其他 SQLite 用户更容易遇到这个缺陷:我们手动控制 checkpoint 过程,而且 checkpoint 非常频繁。即使是一个由罕见条件触发的缺陷,最终也必然会被我们命中。
这是一个激动人心的时刻。经过数月的困惑和不确定,我们终于有了一个合理的理论来解释损坏发生的原因,以及一个可以部署来防止它的修复方案。
SQLite 开发者以 SQLite 3.52.0 的形式发布了修复,我们准备在可用后立即部署。
我们谨慎地推送了 SQLite 3.52.0——先到几个金丝雀分片,当看到运行平稳后,再部署到控制平面的其余部分。
我们的备份监控器迅速变红,报告在 13 个不同的数据库 中存在损坏。这非常令人警觉,但我们按照恢复流程修复了所有所谓的损坏,一切恢复正常。事实证明,这些数据库并没有遭受真正的损坏,而是遇到了该版本 SQLite 中的另一个问题。
我们将错误反馈给 SQLite 开发者,这揭露出 SQLite 中一个与陈旧表达式索引相关的缺陷。如果你在一个计算值上创建索引,然后计算结果发生变化,索引将包含不匹配的值,这会被 PRAGMA integrity_check 报告为损坏。
在我们的案例中,我们将一些高精度时间戳存储为文本,并在一个虚拟生成列中将它们转换为浮点数。修复了我们的数据竞争的 SQLite 3.52.0 版本还做了一个优化,微妙地改变了文本到浮点数转换的舍入行为。我们的金丝雀分片没有包含任何会触发改变的舍入行为的时间戳,因此我们在分阶段部署中错过了这个问题。