摘要:Tailscale六个月遭遇19次SQLite损坏。根因不是两个业务写者,而是checkpoint与写者回绕WAL之间的极窄竞态。本文还原事务重放、tmstmpvfs取证和版本修复波折。
## 摘要
SQLite 以“小、快、可靠”著称,但任何成熟软件都无法脱离具体运行路径谈可靠性。Tailscale 的控制平面自 2022 年起使用 SQLite;在一次持续六个月的故障周期中,团队遭遇了十九次数据库损坏。问题没有稳定的业务触发条件,也无法在实验室自然复现。最终,Tailscale 与 SQLite 核心开发者通过事务重放、检查点指标和一个名为 tmstmpvfs 的虚拟文件系统追踪层,把根因锁定为 checkpoint 与写事务之间极窄窗口内的数据竞态,即 WAL-Reset bug。修复最初随 SQLite 3.52.0 发布,却又因浮点转换变化触发陈旧表达式索引告警而撤回,随后以聚焦修复的 3.51.3 重新发布。本文梳理这次调查的证据链,解释竞态如何造成已提交页面永久丢失,并将已确认事实与工程推论明确分开。
一、事故表象:六个月十九次损坏
已确认事实
Tailscale 的公开复盘显示,其控制平面由多个 coordination server,也就是内部所谓的 shard 组成。每个 tailnet 在任一时刻归属一个 shard;每个 shard 使用一份 SQLite 数据库保存设备和网络配置等元数据,并由单个 Go 进程独占访问。这里的“单进程”并不等于 SQLite 内部只有一个连接或一条执行路径,写入与检查点仍可能在不同线程或连接上发生重叠。
Tailscale 从 2022 年开始把 SQLite 用作主要数据库,并从 2023 年初稳定运行定期备份流程:每隔几分钟生成完整数据库快照,再将 SQLite 文件上传到 S3。第一次异常来自读取备份的数据流水线;团队对备份执行 PRAGMA integrity_check,确认文件确实损坏。此后相同类型的事故反复出现,六个月累计十九次,间隔可能短至数小时,也可能长达数周,期间甚至有过六周完全平静。
损坏发生时,相关 shard 的控制平面进程必须停机修复或恢复数据库。在线设备已有的点对点连接通常还能继续,但新上线设备无法取得对等节点列表,管理后台与 API 也会暂时不可用。早期恢复耗时超过一小时,且少量最新设备或配置变更没有保留下来。团队随后让进程在发现损坏时立即硬停,为备份持续执行完整性检查,并完善值班手册,逐步压缩恢复时间。
初期排查覆盖了应用层近期变更、SQLite 调用代码、客户与 shard 特征、时间和负载相关性,但没有找到共同因素。SQLite 开发者参与后,双方又检查了 POSIX 锁可能被其他线程 close() 破坏、SQLite 所有内存被误用、以非线程安全方式跨线程使用连接等假设。这些都是合理嫌疑,却被后续遥测逐一排除。
工程推论
十九次并不说明 SQLite 在普通场景下容易损坏。更合理的解释是:Tailscale 的规模和运行方式把一个概率极低的窗口重复放大。事故间隔高度随机,也意味着按请求、客户或流量峰值寻找简单相关性很可能误导调查。对于这类问题,“近期改了什么”仍应优先检查,但不能把“多年未改的底层依赖”自动视为无罪。
二、理解 WAL:提交、checkpoint 与 reset
已确认事实
SQLite 的 WAL 模式不直接覆盖主数据库文件,而是把变更后的页面追加到 -wal 文件。事务的提交标记写入 WAL 后,提交即可成立;读取事务则结合主库、WAL 和共享内存中的 wal-index,获得一致快照。这样读者通常不会阻塞写者,写者也不会直接改动读者正在使用的主库页面。
WAL 不能无限增长,因此 SQLite 需要执行 checkpoint,把 WAL 中已提交的页面复制回主数据库。默认情况下,WAL 达到约一千页时可以自动 checkpoint;应用也可以关闭自动机制,主动调用检查点接口。若所有相关 WAL 内容都已安全回写、同步,且没有读者仍依赖旧 WAL,后续写者可把 WAL“倒回”开头,复用文件空间。这个动作就是理解 WAL reset 的关键。
Tailscale 为获得快速且一致的备份,主动管理 checkpoint,并且执行得较为激进。这是公开、受支持的 SQLite 用法,但不是最常见的默认路径。事故期间还有一个反常指标:SQLite 报告 checkpoint 复制的页面数,有时大于 WAL 实际拥有的页面数。若 WAL 只有十页,系统却声称复制了二十页,那么计数依据或 WAL 世代认知显然发生了错位。
工程推论
WAL 的安全性依赖的不只是文件写入顺序,还依赖多个参与者对“当前 WAL 是哪一代、哪些帧已回写”的一致认识。reset 前后的帧编号可以相似,但语义已属于不同世代。若 checkpoint 在关键时刻继续使用 reset 之前取得的状态,形式上合理的页号和计数就可能指向已经变化的事实。并发缺陷最危险之处,往往不是两个线程同时写同一字节,而是一个线程基于已经失效的快照作出不可逆决定。
三、没有报错的事务:调查中的决定性线索
已确认事实
为了避免每次都回滚到旧备份,Tailscale 建立了事务日志流水线,把所有修改数据库的 SQL 记录到独立日志。由于 SQLite 只有一个并发写者,且事务可串行化,这些修改形成线性、确定性的历史;理论上,从最近的健康备份开始重放,应该恢复到故障前的最新状态。
该方案既改善了恢复,也提供了关键证据。在两次事故中,事务日志无法干净重放。进一步检查发现,某事务写入并成功提交的数据,对后续事务却不可见。应用没有收到提交失败,历史日志也证明语句执行过,但结果仿佛凭空消失。这个现象把关注点从“错误 SQL 写坏了逻辑结构”转向“存储层丢失了已提交页面”。
此时三类证据开始相互咬合:integrity_check 证明 B-tree 或索引结构不一致;重放日志证明先前提交的状态没有留在数据库中;异常 checkpoint 计数证明 WAL 回写过程对页面范围产生了错误判断。单独看,每项都可能有多种解释;合并后,checkpoint 成为最值得观测的边界。
工程推论
事务日志的价值超出了灾难恢复。它把“用户观察到数据库坏了”转化为可验证的状态转移序列,使团队能区分应用逻辑错误、重放非确定性与存储层提交丢失。对于嵌入式数据库,记录业务事件固然重要,但在严重故障调查中,能够重建数据库修改顺序的低层日志往往更接近真相。
四、tmstmpvfs:在 SQLite 与操作系统之间架设示波器
已确认事实
SQLite 内部分层明确:SQL 解析与代码生成位于上层,pager 负责以页面为单位组织持久化,而实际文件打开、读写、同步、锁和共享内存操作由 VFS,即虚拟文件系统层完成。VFS 可以替换,也可以被包装,因此非常适合在不改动应用 SQL 语义的前提下记录真实 I/O 行为。
SQLite 开发者为本案制作了 tmstmpvfs shim。它包裹既有 VFS,对数据库、WAL 及相关操作附加时间戳和追踪信息,相当于把观测点放在 SQLite 存储状态机与操作系统之间。Tailscale 将该 shim 部署到生产环境,等待下一次低概率事故。新的损坏出现后,日志终于让 SQLite 开发者重建出足够精确的并发时序。
根因是一场 checkpoint 与写事务之间的罕见数据竞态:checkpoint 正在处理旧 WAL 状态时,写路径在极窄窗口内完成了 WAL reset 并开始复用 WAL。checkpoint 没有正确意识到 WAL 已被另一线程重置,于是误以为某些页面已经从 WAL 复制到主数据库,实际上这些页面从未落入主库。与此同时,引用这些页面的其他页面,例如索引页面,却可能已经写入,于是主数据库内部关系断裂,形成真实且永久的数据损坏。
SQLite 将其命名为 WAL-Reset bug。官方资料称该缺陷自 WAL 模式早期以来存在约十五至十六年,影响从 3.7.0 开始的一长段版本。由于自然触发条件极苛刻,SQLite 开发者无法靠普通压力测试稳定复现,只能在测试代码中主动制造所需时序。修复提交在 checkpoint 路径增加检查,以识别 WAL 是否被其他线程重置,避免继续使用过期状态。
工程推论
tmstmpvfs 的成功说明,面对无法复现的底层竞态,最有效的遥测不一定来自更多业务日志,而来自状态转换边界。日志应回答“何时打开、锁定、同步、截断或复用哪一个文件”,并能把不同线程的事件排序。与其猜测内部对象,不如记录系统真正执行的外部副作用。与此同时,生产追踪必须足够轻量、可控并注意敏感数据;本案值得复制的是观测层设计,而不是无条件记录所有页面内容。
五、3.52.0 的修复为何又引发“损坏”告警
已确认事实
WAL-Reset 修复最初进入 2026 年 3 月发布的 SQLite 3.52.0。Tailscale 先在少量 canary shard 上运行,确认平稳后扩大部署。然而备份监控很快在十三份数据库上报告损坏。后续确认,这十三份数据库并没有遭遇新的 WAL 页面丢失,而是触发了另一个问题:陈旧表达式索引。
Tailscale 把部分高精度时间戳存为文本,再通过 VIRTUAL 生成列转成浮点数,并对计算结果建立索引。3.52.0 中一项文本转浮点的优化细微改变了舍入结果。数据库表中原始文本没有变化,但新版本重新计算表达式时得到的浮点值,可能与旧版本写入索引的值存在末位差异。PRAGMA integrity_check 比较表表达式结果与索引项后,将这种不一致报告为损坏。canary 数据恰好没有包含能触发舍入差异的时间戳,所以分阶段发布未能提前发现。
SQLite 随后撤回 3.52.0,并在 2026 年 3 月 13 日发布 3.51.3。该补丁版本包含 WAL-Reset 修复和少量其他修正,避免携带引起兼容波折的整批新特性。官方说明指出,3.52.0 的风险集中在表达式索引,或对 VIRTUAL 生成列建立的索引,且表达式结果是由文本或 JSONB 输入推导出的浮点数。Tailscale 则把时间戳精度降低为整数秒,使用语义更明确的文本到整数转换。SQLite 3.53.0 后续重新发布相关能力,并加入针对陈旧表达式索引的自动自愈机制。
这里必须区分两种“损坏”:WAL-Reset 会让已提交页面未写入主库,是持久化层真实丢失;表达式索引事件中,主数据仍在,但索引保存的是旧计算语义下的结果,完整性检查据此告警。二者都需要处置,却不能混为同一根因。
工程推论
这段插曲揭示了数据库升级的一类隐蔽兼容面:不仅文件格式和 SQL 语法需要兼容,确定性表达式的计算结果也构成持久化契约。一旦表达式结果进入索引、生成列或约束,浮点舍入、字符排序、时间解析及 JSON 转换的细微变化都可能留下“旧派生数据”。因此,升级测试应包含真实数据分布、升级前后 integrity_check、关键索引重建对比,以及跨版本读写验证。canary 只覆盖少量实例,却未覆盖边界值,就不能代表语义兼容性已经验证。
六、闭环验证:没有事故还不够
已确认事实
部署修复后,Tailscale 没有立即宣告成功。此前曾有六周无事故,因此“最近没坏”只能说明暂未观察到失败。理解竞态后,团队修改 SQLite 驱动:当写事务与 WAL reset 出现危险重叠时记录警告。若警告出现而数据库仍保持健康,就能得到正向证据——过去会导致损坏的条件确实在生产环境发生过,而新检查成功阻断了后果。
团队等待了约两个月,告警终于触发;数据库没有损坏。此后截至原文写作时又连续运行四个月而无数据库事故。这条告警把“修复与事故消失相关”推进为更强的因果证据:危险时序真实存在,保护逻辑也真实介入。
工程推论
低频故障的修复验收应尽量观测“被避免的失败”,而不是只统计失败归零。可以为补丁增加命中计数、旧条件检测或影子断言,在不破坏服务的前提下证明保护分支被走到。否则,漫长静默既可能代表修好了,也可能只是随机窗口尚未再次出现。
七、给生产系统的工程启示
第一,成熟依赖不等于零风险。SQLite 的默认路径经过极广泛验证,但公开、受支持的非默认组合,其真实覆盖度仍可能较低。主动 checkpoint、激进频率、多连接重叠和大规模实例共同改变了风险暴露。采用“无聊技术”时,也应盘点自己是否正以不无聊的方式运行它。
第二,把恢复能力建设成取证能力。持续完整性检查、健康快照、线性事务日志、自动硬停和可重复重放,不仅减少停机,也保留了定位根因所需的证据。恢复脚本若只追求尽快上线,却覆盖现场或丢弃 WAL、共享内存与版本信息,可能让下一次调查重新从零开始。
第三,监控不变量,而不只监控错误码。checkpoint 复制页数超过 WAL 可用页数、已提交状态在后续事务中消失、索引与表达式重算不一致,都是比“数据库返回 corruption”更接近根因的不变量破坏。好的遥测应围绕系统宣称必然成立的关系设计。
第四,升级门禁要覆盖持久化语义。应记录 SQLite 版本与 source ID,对真实数据库执行升级前后检查,关注表达式索引、生成列、浮点、排序规则和自定义函数。对关键库,补丁发布也不应绕过 canary;但 canary 选择必须包含边界数据,而不能只按机器比例抽样。
第五,事实和推论必须分层。事实包括十九次事故、异常页数、事务重放缺口、VFS 时序和修复分支命中;“因为业务高峰”“因为某客户数据”“SQLite 不适合服务端”等则只是未经证实的解释。清晰标注证据等级,能减少团队在高压事故中被相关性带偏。
对于仍使用受影响版本的系统,首选措施是升级到包含官方修复的版本,并核对发行分支是否确实回移补丁。若短期无法升级,可降低手工 checkpoint 的激进程度并加强备份检查,但这只能减少暴露概率,不能替代根治。
结语
Tailscale 的调查并不是“SQLite 不可靠”的故事,而是一次关于概率、运行路径与可观测性的系统课。一个潜伏十六年左右的竞态,在默认场景中罕见到难以自然复现,却在主动且高频 checkpoint 的生产系统里累计造成六个月十九次损坏。最终突破口也不是某个神奇调试器,而是一条逐步收紧的证据链:完整性检查确认后果,事务日志证明提交消失,指标指向 checkpoint,tmstmpvfs 还原文件级时序,保护分支告警完成因果闭环。
真正值得借鉴的,是团队没有把“低概率”当作“可忽略”,也没有把“权威依赖”当作停止调查的理由。面对难复现竞态,工程上最可靠的办法仍是保护现场、定义不变量、下沉观测点、缩小假设空间,并为修复设计可被正向验证的证据。
参考资料
- Tailscale, How we tracked down a 16-year-old SQLite bug。
- SQLite Documentation, Write-Ahead Logging:The WAL-Reset Bug。
- SQLite Release Notes, SQLite 3.51.3。
- SQLite News, 3.52.0 撤回、3.51.3 与 3.53.0 发布说明。
- SQLite Documentation, Stale Expression Indexes。
- SQLite Source,
tmstmpvfs.c。 - SQLite Source Check-in, WAL-Reset 修复提交 7168988acbec2d8d。