Tailscale 把数据库损坏追查到了一个潜伏 16 年的 SQLite WAL-Reset bug
文章摘要
Tailscale 的 Alex Chan 写了一篇相当坦诚的事故复盘:从去年年底到今年上半年,他们的可用性一直不稳定,而大部分故障都源自 SQLite 深处的同一个 bug。追查花了几个月,最终揪出的是一个在 SQLite 里潜伏了至少 16 年的数据竞争。
架构背景:客户端看到的 controlplane.tailscale.com 是单一公开端点,但内部的控制平面被切成若干协调服务器(shard)。每个 tailnet 在某一时刻只住在一个 shard 上,可以无缝迁移。每个 shard 有一个 SQLite 数据库,由单个 Go 进程独占访问——这正是 SQLite 被设计出来的用法。他们从 2022 年起就把 SQLite 当主数据库,理由是它是「无聊技术」(褒义)。备份方式是每隔几分钟做一次完整快照,把整个 SQLite 文件传到 S3,这套从 2023 年初一直跑得好好的。
问题浮现:去年 8 月,读取 S3 备份的数据管道报了一个错。跑 PRAGMA integrity_check 一看,备份确实损坏了。SQLite 损坏是可能的,但极不寻常。他们修复并调查,一无所获。然后它又发生了——一次又一次。六个月里总共 19 次独立的数据库损坏事件。
好消息是控制平面只存配置数据,包含的是 tailnet 和设备的元数据,绝不包含私钥或网络流量。早期事件的恢复代价是丢掉少量新增设备和配置变更。但每次损坏都必须停掉该 shard 上的控制平面进程来修复或恢复数据库,对该 shard 上的 tailnet 而言,整个控制平面在恢复窗口内消失。由于每个 tailnet 是 WireGuard 点对点网状网络,已在线的设备之间仍能保持连接,但新上线的设备拿不到对端列表就连不上,管理控制台和 API 也暂时不可用。早期的停机超过一小时。还有一层更广泛的影响是信任:即使只有少数 tailnet 受影响,他们也会在状态页发布全局事件,于是很多人看到了与自己无关的故障通告。
难以定位:这个 bug 抵抗了所有初期尝试。最近的变更里没有相关的;跟 SQLite 交互的底层代码多年没人动过,逐行重审也没发现问题。他们找不到事件之间的任何共同因素——不绑定某个 shard、某个客户、某个功能、某个时段或某个负载水平。没有可靠的触发条件就意味着无法合成复现,只能在生产环境部署被动的取证遥测,等着抓现行。更折磨人的是它不按规律出现:有时几小时一次,有时几周一次,十月到十二月还有整整六周风平浪静,然后作为「圣诞礼物」卷土重来。
于是他们跟 SQLite 开发者签了专业支持合同。文章说这是个绝佳决定,让他们直接接触到 SQLite 团队的深厚经验,围绕架构和事件做了大量技术讨论。双方一起列出了几种理论:close() 上被破坏的 POSIX 锁、错误管理 SQLite 拥有的内存、在禁用线程安全的情况下跨线程使用 SQLite。每次事件后收集更多数据、加更多诊断,逐条排除。
没有叫的那些事务:调查期间平台还得继续跑,他们采取了激进的自动化恢复措施——遇到损坏立刻硬停 shard、部署持续对备份跑 PRAGMA integrity_check 的备份监控、改进 runbook 和 on-call 培训,把响应时间压到一小时以内。随后一条意外线索出现了。为了不必回滚到上一个已知良好备份(会丢大量数据)也不必修复已损坏的库(有风险),他们建了一条事务日志管道:把每一条修改数据库的 SQL 语句流式写进单独的日志文件。因为 SQLite 是单写者、可串行化事务的数据库,事务历史完全线性且确定(他们指出在 Postgres 或 MySQL 这类多写者数据库里就不成立),把这些事务重放到最近的良好备份上就能恢复到最新状态并绕开损坏。这套管道成功了,而且带来了线索:在两起事件中,事务日志无法干净重放——仔细一看,某个事务写入并提交的数据,对后续事务竟然不可见。一次写入凭空消失,还没有报任何错。这不该是可能的。
WAL 上的字迹:与此同时,SQLite 开发者一直在做一个新的调试工具。他们早就怀疑 bug 藏在 checkpoint 过程里。文章顺便解释了原理:SQLite 数据库由一系列「页」组成,更新时需要用新页替换旧页;开了 Write-Ahead Logging 之后新页不直接写主库文件,而是写进 WAL 文件;WAL 不能无限增长,某个时刻必须把页拷回主库文件,这就是 checkpoint。绝大多数部署里 SQLite 自己决定何时 checkpoint,对用户完全透明;而 Tailscale 为了做快速一致的备份,手动接管了 checkpoint 过程——随着其他原因被逐一排除,这个非标准做法越来越可疑。
一条关键线索是:损坏期间的指标显示,SQLite 报告从 WAL 拷贝的页数比实际存在的还多。如果 WAL 里有 10 页却有 20 页被拷进数据库,显然出了问题。
为了看清故障 checkpoint 期间发生了什么,SQLite 开发者做了一个虚拟文件系统层的调试工具。SQLite 分若干层:最上层是解析器和代码生成器,把 SQL 转成内部数据结构;数据结构交给 pager,切分成要写盘的页;实际写盘由 OS 接口即「虚拟文件系统」负责,目前主流实现有 Unix 和 Windows 两个。这个分层结构允许替换或包装某一层来获取更多信息。SQLite 开发者做的就是一个包在虚拟文件系统外面、写出额外追踪信息和数据库变更日志的 shim,叫 tmstmpvfs,源码已进入 SQLite 公开仓库。他们把 shim 部署到生产,没等多久下一次损坏就来了。
WAL-Reset bug:借助 shim 的额外日志,SQLite 开发者找到并修复了 bug——checkpoint 与写事务之间一个罕见的数据竞争。具体来说,如果一次写入发生在 checkpoint 期间某个特定时刻,checkpoint 过程会被搞糊涂:它以为某些页已经从 WAL 拷进主库文件,实际上并没有。这些页永远不会被写进数据库文件,数据永久丢失;而引用这些页的其他页(比如索引)却被写了进去,于是数据库文件损坏。
SQLite 开发者把它命名为「WAL-Reset bug」,估计它在 SQLite 里存在了至少 16 年。它能潜伏这么久是因为太罕见——罕见到 SQLite 开发者不得不在测试环境里加代码来故意触发它。修复方案是在 checkpoint 函数里加一个额外检查,检测 WAL 是否被另一个线程重置过。开发者确认这个 bug 解释了他们看到的所有诡异现象:损坏本身、无法干净重放的事务日志、以及不一致的 checkpoint 统计。他们也解释了为什么 Tailscale 比其他 SQLite 用户更容易撞上:手动接管 checkpoint 且 checkpoint 得非常激进。
修复,以及一场虚惊:SQLite 3.52.0 发布后,Tailscale 先在少量金丝雀 shard 上灰度,看着运行平稳后推给整个控制平面。结果备份监控立刻变红,报告 13 个数据库损坏。虚惊一场——这些库并没有真的损坏,而是碰上了这版 SQLite 的第二个问题:陈旧表达式索引。如果你在一个计算值上建索引,而计算方式变了,索引里就会有不匹配的值,被 integrity_check 报成损坏。Tailscale 的场景是把高精度时间戳存成文本、在 VIRTUAL 生成列里转成浮点数,而修复数据竞争的 3.52.0 同时做了一个优化,微妙地改变了文本转浮点的舍入行为。金丝雀 shard 上恰好没有触发新舍入行为的时间戳,所以灰度没能拦住。因为这会造成误报损坏,SQLite 开发者撤回了 3.52.0,改发只含 WAL-Reset 修复的 3.51.3。Tailscale 自己这边把时间戳精度降到整数秒(文本转整数没有歧义);SQLite 则在 3.53.0 里做了自动自愈索引的特性来根治陈旧表达式索引问题。
庆祝时刻:推全之后他们仍然谨慎——没有损坏事件不等于修好了,之前已经有过一次六周的假性平静。他们想要的是这个数据竞争确实在生产环境发生过的正面证据。于是给 SQLite 驱动打了个补丁,在写事务与 WAL-reset 重叠时打一条警告。如果警告触发而数据库没损坏,就说明修复救了他们一次。他们部署后开始等,等了又等,几周过去开始怀疑警告是不是坏了、理论是不是错了。两个月后,那条盼望已久的告警终于响了——证明 WAL-Reset bug 的精确触发条件确实会在他们的生产环境中出现。截至发文,此后又跑了四个月没有任何数据库事故。
离开被踩熟的路:文章结尾的教训很朴素——用非标准方式运行无聊技术是一种风险。常见路径和标准配置经过极其充分的测试,大多数人用标准配置的 SQLite 一辈子碰不到这种事。Tailscale 做的每件事都是公开、有文档、受支持的配置,但通过手动接管 checkpoint 并按自己的激进节奏运行,他们踩出了那条被踩熟的运维路径。收尾是几件正面的事:那个长期潜伏的 SQLite bug 被修了,他们顺手修了几十个调查过程中发现的其他问题,出资支持了那个几乎立刻定位竞争条件的开源 VFS shim,并且把备份恢复流程打磨过并实地演练了十几次。
HN 评论精华
这条帖子拿到 1207 分、234 条评论。讨论集中在三处:企业出钱支持开源的价值、SQLite 该不该用在这种场景、以及对 bug 描述的技术辨析。
-
simonw 点出了他觉得最有意思的一句:Tailscale 出资支持了那个开源 VFS shim。「这是企业资助开源的一个有趣案例——具体到为一个新的、非常专门的调试工具付费。」不过 gavinsyancey 读文后补充了更准确的理解:与其说他们「资助」了这个工具,不如说他们买了 SQLite 支持合同,而 SQLite 开发者在帮他们排查的过程中顺手做了这个工具。alberth 补充说这本来就是 SQLite 的商业模式。buggymcbugfix 借机夸了 Tailscale 一把:他们不只是资助开源,还允许用户通过 headscale 自建控制平面——那是 Tailscale 控制协议的自由实现,开发者白天就在 Tailscale 上班。「这让我立刻信任并喜欢上他们,尽管我本能地不信任任何被大量炒作的东西。」
-
calmingsolitude 提出了一个精准的技术疑问:文中说「单个 Go 进程独占访问该数据库,这正是 SQLite 被设计的用法」,让他以为写入者和 checkpoint 逻辑在同一条数据库连接上。但 SQLite 官方页面写明,这个 bug 只有在同一文件上打开两个或更多连接、分属不同线程或进程时才可能发生——所以写入者和 checkpoint 一定在不同线程上。Naru41 顺着说:老实讲,他很惊讶用 SQLite 的人会在不担心数据竞争的情况下从多线程或多进程直接访问它。
-
ec109685 认为 SQLite 官方的措辞(「这是一个时序约束很紧的数据竞争,在常规使用中不太可能发生」)虽然技术上没错,却淡化了严重性——毕竟一个大客户实实在在遇到了损坏。Ariarule 反驳说,奇怪的是没人强调文章里回答了「为什么偏偏是 Tailscale」的那句:他们手动接管 checkpoint 且 checkpoint 非常激进,所以再罕见的触发条件也终究会撞上。
-
LgWoodenBadger 提出了本帖被讨论最多的技术困惑:文中两处描述看起来矛盾——一处说「拷贝的页数比实际存在的多」,另一处说「本该拷的页没被拷」。jpiasolutions 给出了最清晰的调和:这是同一个 bug 的两个角度。实际上并没有任何东西真的拷了 20 页,那个「20」只是一个坏掉的计数器。SQLite 内部记录「已经把多少 WAL 页存进主库」的账本被破坏、读数偏高;因为它信任这个账本,就以为那些页已经存好了、跳过了真正的写入,于是 WAL 重置时这些页就没了。「『拷得比存在的多』是这个 bug 在你的指标里的表现,『页从未被写入』才是真正的损害。」
-
riknos314 吐槽了单点故障。tptacek 给了一段很有分量的反驳:这大概是软件工程里贝尔曲线梗最纯粹的例子之一。为了消除 Tailscale 这套系统里所有单点故障而要做的那些事,只会让系统更不健壮、故障更多。「一般来说,只有在绝对必须的时候才去做纸面上没有单点故障的复杂分布式系统,因为那种系统不会有瞬时故障。而 Tailscale 这种网状网络完全不是那种情况。」arjie 和 dilyevsky 也从实用角度补充:控制平面大部分时候并不需要——节点通过控制平面协商完成后就能一直点对点通信;这是控制面的单点故障而非数据面的,已有的连接照常跑,只是起不了新的。
-
miki123211 提出了唯一一条比较有分量的反向意见:「大多数公司不会这么干。他们会开掉那个提议用某个奇怪数据库的怪人,然后像上帝本意那样换成 Postgres。」他强调这不是批评 Tailscale——付钱让 SQLite 维护者修真 bug 值得称道——但这件事确实没法给「SQLite 上生产」的炒作列车增加信心。SQLite 作为单用户 SQL 数据库确实是无聊技术,但对传统 CRUD 和网络服务而言,Postgres 是被踩得更熟、也更安全的路。(arendtio 则用一句反讽把这个观点顶到荒谬处:「大家都知道 SQLite 不能上生产也不能扩展。这就是它不能用于真实世界应用的铁证。SCNR」)
-
inigyou 对「手动 checkpoint 是危险的」这个说法提出质疑:他从没听说过不该随时跑 checkpoint 的可靠性理由,只听说过性能理由。他推测 Tailscale 用的是正规的 backup API 而不是趁数据库打开时 rsync 文件,而 backup API 本来就不需要 checkpoint 来保证一致性,所以这整套手动 checkpoint 在他看来是不必要的。
-
colomo 抛出了一个不太客气的观点:对这一类 bug 而言,SQLite 现有的测试方法论明显被现代确定性并发测试比下去了,并附上了 Antithesis 关于 WAL-reset bug 的博文链接。jeffbee 从另一个方向补充:块设备故障注入层对数据库杀伤力极强——他的同事多年前写过一个能复现《Parity Lost and Parity Regained》里各种危险场景的层来测 FoundationDB,立刻在一个自称测试充分的项目里挖出了好几个缺陷。(jnwatson 表示以后要用 jeffbee 生造的那个词代替「故障注入」。)
-
d-us-vb 带来了 SQLite 那边的最新动态:Richard Hipp 最近在 Software Should Work 的演讲里提到,AI agent 一直在测试 SQLite,他们收到了大量来自类 fuzz 测试的新 bug 报告——但这些不是 SQLite 内部做的,而是爱好者和其他组织用自己的 agent 驱动的 fuzzing 跑出来的。dilyevsky 也说,在某几代模型刚发布、还没被锁死的窗口期,在很多被大量使用的数据库和软件里找 crash bug 简单得惊人。
-
sandeepkd 从行业角度感慨:这是一个好读的提醒,说明业界正在逐渐流失专家。「我不是 DBA,但我过去至少听说过两次这种行为应该避免。这只是那类没机会被写进文档的事情之一——因为专家们都绕开它,而普通人根本走不到那一步。」
-
declan_roberts 引用了文中「现在我们有信心找到了它、理解了它、更重要的是修好了它」这句,说:「这就是我作为软件工程师一直在追的那种感觉。它是最强的驱动力。」tyho 则代表了大多数人的心声:「多残忍的一个 bug。我永远不会想到 SQLite 里的 bug 会导致我自己写的代码出问题。」