工具大全
开发者工具作者:Coocon2026年8月13日203 次阅读约 15 分钟阅读

一个藏了 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 本身。你今天可以做的,是三件具体的事:

  1. 查版本。 看你项目里的 SQLite 版本,落在受影响的区间就升级(官方现在稳定线是 3.53.x,带自愈索引那个)。不升级也行,但得知道自己在赌什么。
  2. 跑一次恢复演练。 别等数据丢了才想起来。现在就把备份拉下来,跑一遍 integrity_check + 重放,确认你的恢复流程真能跑通,而不是「理论上能」。
  3. 审视你的「非标准用法」。 你是不是也手动接管了什么默认行为——checkpoint、vacuum、连接池、事务隔离?每偏离一次默认路径,你就替自己签下了一份测试义务。

藏了 16 年的 bug 不叫「不存在」,只叫「还没人踩中」。轮到你之前,把能练的先练一遍。

✨ 本文由 DeepSeek 生成初稿,Claude 审核润色。

参考来源:

相关文章

Claude Code 报错 Invalid API key · Fix external API key 怎么排查:Not logged in、Credit balance is too low、API Error 401/429/529 原文与重试实测

Claude Code 报错 Invalid API key · Fix external API key 怎么排查:Not logged in、Credit balance is too low、API Error 401/429/529 原文与重试实测

用本地 stub 模拟 Messages API,在 Claude Code 2.1.280 上跑了 28 组 case、61 次 claude -p。结论:401 会重试 10 次、约 3 分钟后才报 Invalid API key · Fix external API key;key 含中文或中间换行则在本地就被拦,0 个请求、0.28 秒。所有报错都打在 stdout,stderr 为 0 字节,exit 1;JSON 里 subtype 仍是 success。CLAUDE_CODE_MAX_RETRIES=0 让 401 在 0.28 秒内失败。KEY 和 TOKEN 同时设置时两个 header 都会发。

claude-codeclaude-code-lab+4
pitfalls2026年9月27日10 min
3

码农早餐 · 2026-09-27

今日头菜:Excel 用 40 年的一值一格,被 Ctrl+J 打破了。另有 4 条快讯:301 字符的 Postgres 迁移,4 条语句逐条判安全;FTC 主席:agent 闯祸,责任在开发者;等。

daily-intel2026年9月27日11 min
18
把 13.9 万个果蝇神经元搬进 Mac mini:不给任何输入,它会自己动起来吗?

把 13.9 万个果蝇神经元搬进 Mac mini:不给任何输入,它会自己动起来吗?

Eon Systems 说他们『上传』了一只果蝇。我用同一套开源组件在 Mac mini 上把它拼了出来:13.9 万神经元、约 500 万突触的全脑 LIF 模型接上 MuJoCo 物理身体。给输入时它能做事——腿尝到糖 0.66 秒后开始进食,看到逼近的黑球 1.89 秒触发巨纤维后退。但不给任何输入时,原模型一个脉冲都不发。补上膜噪声和放电适应后,它确实自己『动』了起来:60 秒里出现静止、前进、后退、梳理、惊跳 140 个片段;可节律像时钟(阵发间隔 CV 仅 0.05),82% 的前进片段恰好等于解码器下限 0.3 秒。第二版把适应时间常数改成异质、读突触输入而不是脉冲,间隔 CV 升到 1.2,前进片段最长 4.6 秒。文章给出全部实测数字,也讲清楚哪些是连接组自己的,哪些是我加的。

实测flywire+7
hands-on2026年9月26日11 min
9
Claude Code 报错 Command timed out after 2m 0s 怎么解决:两条超时路径、BASH_DEFAULT_TIMEOUT_MS 与 run_in_background 实测

Claude Code 报错 Command timed out after 2m 0s 怎么解决:两条超时路径、BASH_DEFAULT_TIMEOUT_MS 与 run_in_background 实测

Claude Code 的 Bash 工具默认 120 秒超时。本文在 2.1.280 上跑了 19 次真实会话,结论是:超时分两条路径,只有首词是 sleep 的命令才会被杀,并报 Exit code 143 / Command timed out after 2m 0s;其它命令到点会被转到后台,在 claude -p 收尾 5 秒后被 kill。无论哪条路径,claude 进程都 exit 0、stderr 为 0 字节,JSON 顶层 is_error=false。BASH_DEFAULT_TIMEOUT_MS=8000 实测 8.17 秒被杀;设成 0 或 abc 会静默回落 120 秒;显式 timeout 超过 BASH_MAX_TIMEOUT_MS 会被静默夹到 15 秒。

claude-codeclaude-code-lab+4
pitfalls2026年9月26日8 min
30