Tailscale 把数据库损坏追查到了一个潜伏 16 年的 SQLite WAL-Reset bug

查看原文 HN 讨论

文章摘要

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 描述的技术辨析。