一个藏了 16 年的 SQLite bug,把 Tailscale 折腾了半年
一个藏了 16 年的 SQLite bug,把 Tailscale 折腾了半年
凌晨三点,你被值班电话叫醒:生产数据库损坏了,PRAGMA integrity_check 报了一堆红。你翻 git log、查最近的变更,什么都没改过。这不是你的锅——但用户不会听解释。
Tailscale 的工程师过去半年就在反复经历这个凌晨三点。他们最后找到的元凶,是一个在 SQLite 里藏了至少 16 年的竞态 bug。官方给它起了个名字:WAL-Reset bug。
19 次损坏,半年查不出原因
事情从去年 8 月开始。Tailscale 的控制面拆成很多个 shard,每个 shard 用一份 SQLite 数据库,一个 Go 进程独占访问。这是 SQLite 教科书式的单写者用法,他们从 2022 年就这么跑,一直没事。
然后某天,一条读 S3 备份的数据管线报错了。他们对备份跑 integrity_check,发现数据库真的坏了。SQLite 会损坏,理论上可能,但正常操作下几乎不该发生。他们修好了,没查出原因。
然后就又来了。一次又一次。半年里,他们一共撞上 19 次数据库损坏。最折磨人的是:这些事故之间没有任何共同点。不绑某个 shard、不绑某个客户、不绑某个功能、不绑时段、不绑负载。你连复现的入口都找不到。
那个「凭空消失」的写操作
没法复现,就只能在生产环境装被动探针,等着抓现行。这期间他们没闲着:为了尽量不丢数据,他们搭了一条事务日志管线,把每一条改数据的 SQL 流式写进独立日志,准备用重放来绕过损坏。
结果这条管线没用来救火,先给了一个关键线索。有两次,事务日志重放不干净——一条已经 commit 的写操作,后面的读竟然看不见它。数据凭空消失了,而且没报任何错。
这在单写者的 SQLite 里本该是不可能的。除非,写的地方出了事。
WAL、checkpoint,和那个 16 年前的竞态
要理解这个 bug,得先搞懂 SQLite 的 checkpoint。SQLite 的数据库是一堆 page。更新数据时,新 page 不直接写主文件,而是先写进一个叫 WAL(Write-Ahead Log,预写日志)的旁路文件——这也是「先写日志、再写数据」这个老规矩的由来。WAL 不能无限膨胀,攒到一定程度,就得把里面的 page 拷回主数据库文件,这一步叫 checkpoint。
打个比方:WAL 是餐厅后厨的传菜窗口,checkpoint 是服务员把窗口里的菜端上桌。正常流程是端一盘、少一盘。但如果窗口有 10 盘菜,服务员却报「我端了 20 盘」,那说明中间某个瞬间,窗口被别的手偷偷清空了。
Tailscale 观察到的正是这一幕:checkpoint 时,SQLite 报告拷贝的 page 数比 WAL 里实际有的还多。SQLite 官方后来查明了原因:checkpoint 和写事务之间存在一个罕见的数据竞态。某个写操作在 checkpoint 的特定时刻插入,checkpoint 就会误以为一些 page 已经拷回主文件——其实没有。这些 page 再也没被写进数据库文件,数据永久丢失;而引用这些 page 的索引却被写了进去,于是数据库结构上「自洽」地坏掉了。
SQLite 官方估算,这个 bug 存在了至少 16 年。它能藏这么久,是因为触发条件太苛刻,苛刻到官方测试时得专门写代码去故意触发它。修复版加了额外检查,在 checkpoint 时探测 WAL 是否被别的线程重置过。
为什么偏偏是 Tailscale
这才是整件事最值得记的一课:不是 SQLite 不可靠,而是 Tailscale 走了一条「非标准的路」。
绝大多数人用 SQLite,checkpoint 由数据库自己决定时机,用户无感知。Tailscale 为了做快速、一致的备份,手动接管了 checkpoint 的控制权,而且跑得特别激进。SQLite 官方明确说:这正是他们比别人更容易踩中这个竞态的原因。触发条件再罕见的 bug,最终也会找上戳触发器最勤的那个人。
用 Tailscale 自己的话说:跑「无聊技术」本身没问题,但用「非标准方式」跑无聊技术,就是风险。那些久经考验的默认路径,被海量用户反复碾压过;一旦你为了某个特殊需求偏离了主路,你就成了那条路的唯一测试者。
我的看法:别把「应该没事」当备份策略
这件事容易读歪成「SQLite 有 bug,别用」。错。任何数据库都有 bug,PostgreSQL 2018 年也翻出一个藏了 15 年的 WAL 相关缺陷。区别只在于你有没有踩中那条代码路径。数据库的可靠性承诺,永远建立在「你还没触发那个条件」的前提上。
真正值得警惕的,是那句我们都在心里默念的话:「这个数据库用了十年,应该没事。」连 Tailscale 的修复过程都不干净:他们升级到修好的版本,结果又触发一个 false alarm——3.52.0 改了文本转浮点的舍入行为,导致他们用虚拟列存的浮点时间戳索引「假损坏」。官方被迫撤回 3.52.0,改发只修 WAL-Reset 的 3.51.3。连「升级修复」这一步都能踩出新坑。
所以别只盯着 bug 本身。你今天可以做的,是三件具体的事:
- 查版本。 看你项目里的 SQLite 版本,落在受影响的区间就升级(官方现在稳定线是 3.53.x,带自愈索引那个)。不升级也行,但得知道自己在赌什么。
- 跑一次恢复演练。 别等数据丢了才想起来。现在就把备份拉下来,跑一遍 integrity_check + 重放,确认你的恢复流程真能跑通,而不是「理论上能」。
- 审视你的「非标准用法」。 你是不是也手动接管了什么默认行为——checkpoint、vacuum、连接池、事务隔离?每偏离一次默认路径,你就替自己签下了一份测试义务。
藏了 16 年的 bug 不叫「不存在」,只叫「还没人踩中」。轮到你之前,把能练的先练一遍。
✨ 本文由 DeepSeek 生成初稿,Claude 审核润色。
参考来源: