Tailscale 最近花了六个月时间追踪一系列数据库损坏事件,最终挖出了一个潜伏在 SQLite 核心代码中长达 16 年的数据竞争 bug。这个被命名为"WAL-Reset bug"的缺陷,存在于 2010 年 7 月发布的 SQLite 3.7.0 到 2026 年 1 月的 3.51.2 之间的每一个版本中,已在 SQLite 3.51.3 中修复。

背景:SQLite 作为核心数据库
Tailscale 的控制平面虽然对外暴露为单一端点(controlplane.tailscale.com),但内部被划分为多个协调服务器(shard)。每个 tailnet 在某个时刻只驻留在一个 shard 上,但可以在 shard 之间无缝迁移。每个 shard 配备一个独立的 SQLite 数据库,由一个 Go 进程独占访问,负责该 shard 上所有 tailnet 的控制平面逻辑。
这种单写者设计正是 SQLite 推荐的使用模式。Tailscale 从 2022 年起将 SQLite 作为主要数据库,选择的理由直截了当:SQLite 是公认的"无聊技术"(boring technology),知名、可靠、被广泛使用。许多公司在更大规模的部署中使用 SQLite 而从未出过问题。
备份管道每隔几分钟对数据库做一次完整快照,然后将整个 SQLite 文件上传到 S3 存储桶。这套方案从 2023 年初一直运行无故障。
19 次损坏事件
2025 年 8 月,一个读取 S3 备份的数据管道报错。Tailscale 工程师对备份执行 SQLite 的 PRAGMA integrity_check,确认数据库确实已损坏。SQLite 损坏虽然有可能发生,但在正常操作中极其罕见。他们修复了受影响的数据库并调查原因,但没有找到根因。
随后,同样的损坏再次发生,一次接一次。在最终修复底层 bug 之前,他们在六个月内经历了 19 次独立的数据库损坏事件。
每次损坏发生时,必须停止受影响 shard 上的控制平面进程来修复或恢复数据库。这对该 shard 上的 tailnet 来说意味着整个控制平面在恢复窗口内不可用。在早期事件中,停机时间超过一小时,后续逐步缩短了恢复流程。
影响范围需要厘清:因为控制平面只处理配置数据,数据库损坏不涉及用户的私有加密密钥或网络流量。在最早的事件中,恢复过程意味着少量新添加的设备或配置变更未能持久化,部分元数据需要重新录入。大部分 shard 和 tailnet 从未涉及数据库损坏事件。
排查过程:排除一个又一个理论
这个 bug 抵抗了所有初步排查尝试。
Tailscale 工程师检查了最近的代码变更,没有任何与 SQLite 底层交互相关的修改。那些低层代码多年前写好后从未改动。他们逐行重新审查了所有与 SQLite 交互的代码,没有找到能导致观察到损坏的 bug。
他们试图在损坏事件之间寻找共同因素:不限于单个 shard、单个客户、特定 tailnet 功能、特定时间或负载水平。由于找不到可靠的触发条件,无法在实验室环境中复现这个 bug。他们只能在生产环境中部署被动取证遥测,等待损坏再次发生时抓住现场。
损坏的发生也不规律——有时相隔几小时,有时相隔几周。2025 年 10 月到 12 月之间有连续六周没有损坏事件,然后作为"不受欢迎的圣诞礼物"再次出现。
由于这不是一个快速或容易的修复,Tailscale 联系了 SQLite 开发者,签订了专业支持合同。这让 Tailscale 工程团队和 SQLite 核心开发者之间展开了深入的技术对话。他们提出了多种理论:close() 时 POSIX 锁断裂、错误管理 SQLite 拥有的内存、在禁用线程安全的情况下从多线程使用 SQLite。每次事件后收集更多数据,添加更多诊断,系统性地排除了这些理论。
关键线索:消失的写入
在调查根因的同时,Tailscale 还需要维持平台运行。他们采取了激进的自动化恢复措施:控制平面 shard 遇到损坏时立即硬停止、部署持续对备份运行 PRAGMA integrity_check 的自动化备份监控、改进运维手册和值班培训。
为了找到一种不依赖回滚备份(会丢失大量数据)或修复损坏数据库(有风险)的恢复方案,他们构建了事务日志管道。管道将每条修改数据库的 SQL 语句流式传输到单独的日志文件。由于 SQLite 是具有可串行化事务的单写者数据库,事务历史完全线性且确定化。重放这些事务可以安全地恢复数据库到最新状态。
这个管道本身工作正常,但它提供了一条关键线索。在两次事件中,事务日志无法干净地重放。经仔细检查,他们发现一个事务写入并提交的数据对后续事务不可见。一次写入凭空消失了,且没有引发任何错误。在 ACID 数据库中,这是不可能的。
WAL-Reset Bug:16 年的数据竞争
与此同时,SQLite 开发者开发了一个新的调试工具。他们怀疑 bug 出在 checkpoint(检查点)过程中。
理解这个 bug 需要 SQLite WAL(Write-Ahead Log)机制的基础知识:
SQLite 数据库由一系列"页面"(page)组成,每个页面是一个小信息块。更新数据库时,部分页面需要被替换。在 WAL 模式下,新页面不直接写入数据库文件,而是先写入"预写日志"(WAL 文件)。当 WAL 文件积累到一定量,页面需要被复制回主数据库文件,这个过程叫做 checkpoint(检查点)。
SQLite 官方文档记录的 bug 触发条件如下:
- 一个连接执行 checkpoint,成功将 WAL 文件中的所有内容复制回数据库文件
- 第一个 checkpoint 完成后不久,第二个 checkpoint 启动
- 在第二个 checkpoint 启动期间,另一个数据库连接提交了一个事务,该事务重置了 WAL 文件并向 WAL 文件开头写入新内容
- 由于数据竞争,第二个 checkpoint 没有意识到 WAL 文件已被步骤 3 中的事务提交重置。它在 WAL-Index 头部留下了一个错误字段,该字段表示 WAL 文件的某部分已经被 checkpoint 处理过,实际上并没有
- 后续事务提交增加了 WAL 文件中的页面数量,使其超过第一个 checkpoint 时的页面数
- 当第三个 checkpoint 发生时,它跳过了步骤 3 中写入的全部或部分事务。这些事务的页面永远不会到达数据库文件,数据库文件因此损坏
SQLite 开发者为此创建了 tmstmpvfs 虚拟文件系统 shim——一个包装层,在虚拟文件系统层写入额外的跟踪信息。源码已在 SQLite 公开仓库发布。Tailscale 将这个 shim 部署到生产环境后不久,下一次损坏就发生了,额外的日志帮助 SQLite 开发者找到了并修复了 bug。
SQLite 开发者将此 bug 命名为"WAL-Reset bug"。修复方式是在 checkpoint 函数中添加一个额外检查,检测 WAL 是否已被另一个线程重置。
Tailscale 更容易触发这个 bug 的原因在于:他们手动控制 checkpoint 过程,并且 checkpoint 频率非常激进。即使是由罕见条件触发的 bug,在高频操作下也终究会命中。
版本修复与误报插曲
SQLite 在 3.52.0 中发布了修复,Tailscale 准备部署。他们采用了分阶段策略——先部署到几个金丝雀 shard,运行平稳后再推送到整个控制平面。
结果备份监控立刻变红,报告 13 个数据库损坏。这极其令人警觉。工程师们按照恢复程序修复了所有"损坏",一切恢复正常。
事实证明这些数据库并未真正损坏,而是遇到了 SQLite 中的第二个问题:与"陈旧表达式索引"(stale expression indexes)相关的 bug。如果你在计算值上创建索引,然后计算方式发生变化,索引将包含不匹配的值,被 PRAGMA integrity_check 报告为损坏。
具体到 Tailscale 的情况:他们将高精度时间戳存储为文本,通过 VIRTUAL 生成列转换为浮点数。修复 WAL-Reset bug 的 SQLite 3.52.0 同时做了一个优化,微妙的改变了文本到浮点数转换的舍入行为。金丝雀 shard 上没有能触发改变后舍入行为的时间戳,因此分阶段部署未能提前发现。
SQLite 开发者因此撤回了 3.52.0,改为发布 3.51.3,其中只包含 WAL-Reset bug 的修复。Tailscale 在自己这边将时间戳精度降低到整数秒来解决问题。SQLite 开发者在 3.53.0 中创建了自动化自愈索引功能,防止陈旧表达式索引问题。
验证修复:等待了两个月的警报
修复部署到整个控制平面后,Tailscale 并没有立即宣布胜利。他们深知,没有损坏事件不代表问题已修复——之前已经有过六周的虚假平静。
他们需要正面证据,证明这个数据竞争确实在生产环境中发生过。理解了 bug 的触发条件——写事务与 WAL 重置之间的碰撞——他们给 SQLite 驱动打了补丁,当这两个操作重叠时记录一条警告。如果警告触发但数据库保持完好,就说明修复确实阻止了潜在的损坏。
警告部署后,等待开始了。一周、两周、一个月过去了,警报始终没有触发。他们开始怀疑:警告功能坏了?理论错了?真正的 bug 还藏在暗处?
两个月后,等待的警报触发了。

这条警报来自 shard2,代码为 SQLITE_WARNING。警报描述写道:"sqlite_log_codes_total code=SQLITE_WARNING 在 shard2 上非零。这极可能意味着本应发生一次损坏事件,但 SQLite 阻止了它。" 技术人员被指示检查服务器日志中的详细消息 "wal wrapped after header was read for this checkpoint"。
这条警报证明 WAL-Reset bug 的精确触发条件确实在 Tailscale 的生产环境中出现。自那条警报触发以来,控制平面又连续运行了四个月,没有任何数据库事件。
受影响范围与升级建议
SQLite 官方文档对此 bug 的定性和建议:
影响版本范围:SQLite 3.7.0(2010-07-21)至 3.51.2(2026-01-09)。修复在 3.51.3(2026-03-13)及更高版本中。部分早期版本的向后移植修复也可用:3.44.6 和 3.50.7。
触发条件:仅影响 WAL 模式下的数据库,且需要两个或多个数据库连接在同一文件上、在独立的线程或进程中打开,并且这两个连接试图在同一时刻写入或执行 checkpoint。
发生概率:SQLite 开发者从未能在实验室中有机复现这个 bug。他们必须修改 SQLite 源码,通过 sqlite3_test_control() 调用注入回调来在第二个 checkpoint 期间的精确时刻触发写事务。根据遥测数据,这个问题在野外的发生率似乎低于或等于 SSD 故障或宇宙射线撞击的预期发生率。
严重性评级:SQLite 官方认为这不是紧急事件。除非你正在做非常不寻常的事情(如手动控制激进的 checkpoint 频率),即使运行未打补丁的版本,也不太可能遇到这个问题。
Canonical 的 dqlite 团队后续用 TLA+ 形式化方法验证了 dqlite(基于 SQLite 的分布式数据库)是否受此 bug 影响,分析结果于 2026 年 6 月发布。
经验教训
Tailscale 在文章末尾总结了一条核心教训:以非标准方式运行"无聊技术"本身就是一种风险。
通用的路径和标准配置经过了充分测试和验证。大多数人使用 SQLite 的标准配置,永远不会遇到这类问题。Tailscale 做的一切都是公开的、文档化的、支持的配置——但通过手动控制 checkpoint 过程并以自己的激进节奏运行,他们走出了常规操作路径。
这场调查是一个跨职能的大型工程,涉及数十人,包括 Tailscale 的工程和支持团队以及 SQLite 的核心维护者。他们资助了帮助隔离数据竞争的 SQLite VFS shim 开源工具,改进了数据库备份和恢复流程,并在十余次真实事件中实战验证。