SQLite 该不该学 Rust 搞版本「代际」?——一份关于基础软件如何进化的提案

SQLite 该不该学 Rust 搞版本「代际」?——一份关于基础软件如何进化的提案

SQLiteRustAPI设计向后兼容基础软件数据库软件工程

数据源:HN + Lobsters

2026 年 7 月 15 日,一篇题为「SQLite should have (Rust-style) editions」的博客同时登上了 Hacker News(355 分、171 条评论)和 Lobsters(134 分、34 条评论)的首页。作者 mort96 是 SQLite 的长期使用者,提出了一条简洁但极具争议的建议:SQLite 应该引入类似 Rust 的「editions」机制,用一条超级 pragma 打包所有现代化的安全默认值。

这条建议之所以引发激烈讨论,不只是因为它切中了 SQLite 长期以来的棘手问题,更因为它触碰了一个更根本的问题:当你的软件被部署了数万亿份,在全球数十亿台设备上运行,你的向后兼容承诺要守到什么程度才算合理?

在深入讨论之前,需要先理解 Rust editions 到底是个什么东西。

SQLite logo

Rust editions 是怎么工作的?

Rust 在 2015 年发布了 1.0 版本,确立了一条核心原则:「稳定而不停滞」(stability without stagnation)。这意味着一旦某个特性通过 stable 通道发布,就要在所有后续版本中继续支持。

但问题很快出现了。Rust 需要引入新关键字(比如 asyncawait),需要改变某些默认行为,需要修复早期设计中的缺陷——这些事情在严格向后兼容的约束下做不到。

Editions 就是 Rust 对这个问题给出的答案,核心设计只有三条:

第一,向后不兼容的变更打包进新版 edition。 每个 crate 在 Cargo.toml 中通过 edition = "2024" 声明自己使用的版本。不声明就用旧行为,声明了就用新行为。这意味着老代码一行不改,也能在新编译器上编译。

第二,不同 edition 的 crate 可以无缝互操作。 这是最关键的设计约束——一个用 edition 2018 的库和一个用 edition 2024 的应用,编译在一起不会有任何问题。所有 Rust 代码无论来自哪个 edition,最终都编译到相同的编译器内部表示。

第三,迁移高度自动化。 执行 cargo fix --edition 就能自动完成绝大部分迁移工作。比如从 2015 迁移到 2018 时,所有叫 async 的变量名会被自动重命名为 r#async

Rust 已经发布了四个 editions:2015(即 1.0)、2018、2021 和 2024。语言本身在持续引入新的能力和更好的默认值,但从未真正「分裂」过生态。Editions 用年份命名,意味着合理的默认值可以随时间演进——2030 年的最佳实践可能和 2026 年不同。

Rust logo

理解了这套机制,再看 SQLite 的处境,就能明白为什么 mort96 觉得 editions 是个精准的答案。

SQLite 的四颗「默认地雷」

SQLite 可能是地球上部署量最大的数据库引擎。它内置在每一台智能手机、每一台电脑、每一款主流浏览器中。它也被大量应用作为数据存储的核心——lobste.rs 最近刚刚迁移到 SQLite 上运行。

但 mort96 指出,SQLite 的默认配置「全错了」。他列出了四个问题,每一个都确实是真实世界的问题:

第一颗雷:外键约束默认不生效

在几乎所有关系型数据库里,外键约束是用来保证数据一致性的基本工具——你不能引用不存在的用户,不能删除还有文章引用的作者。SQLite 是 mort96 知道的唯一一个默认不执行外键检查的 RDBMS。

更糟糕的是,SQLite 的 ROWID 复用机制会放大这个漏洞。如果你删除了一个用户但没有清理其关联数据,新的用户可能被分配到相同的 ROWID,然后「继承」旧用户的帖子。数据看起来一切正常——只是归到了错误的人名下。

修复方法只有一行:PRAGMA foreign_keys = ON;。但你必须记得写。

第二颗雷:类型系统形同虚设

SQLite 允许你把字符串写进 INTEGER 列。它不会报错,只是默默地存储。这叫做「类型亲和性」(type affinity),是 SQLite 从早期就保留下来的设计决策。

结果就是,你可以在声明为 INTEGERduration_sec 列里插入 'Way too long, I mean come on'。mort96 提到他曾经在真实项目中清理过这样的混乱:有人把字符串 '1''0' 写进了一个本应存储布尔值的 INTEGER 列。

解决方法是 STRICT 表——在 CREATE TABLE 末尾加上 STRICT 关键字,SQLite 就会拒绝类型不匹配的写入。但没有全局 pragma 可以一键启用 strict 模式,每张表都要手动声明。

第三颗雷:并发写入直接报错

SQLite 允许并发读,但写操作必须排队。默认情况下,如果两个进程同时尝试获取写锁,其中之一会立即收到一个 SQLITE_BUSY 错误——没有等待,没有重试。

这导致了真实世界的崩溃。mort96 写道:「我手动写了很多重试循环来修复这个问题。」

解决方案同样简单:PRAGMA busy_timeout = 5000;,告诉 SQLite 最多等待 5 秒再报错。但默认值是 0。

第四颗雷:写性能被默认配置锁死

SQLite 的 Write-Ahead Log(WAL)模式默认关闭。WAL 是提升写入性能最显著的手段,同时允许把 synchronousFULL 降到 NORMAL,在不牺牲数据安全的前提下大幅减少磁盘同步次数。

启用只需要:PRAGMA journal_mode = WAL;PRAGMA synchronous = NORMAL;

但同样,你必须在每次打开数据库时手动设置。

提案:一条 super pragma 统治所有

mort96 的提案可以用一句话概括:

PRAGMA edition = 2026;

这一行应该等价于:

PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;

并且,所有新表默认使用 STRICT 模式。

为什么是年份而不是像 JavaScript "use strict" 那样的字符串标志?因为合理的默认值是会随时代变化的。2034 年 SQLite 可能内建了更好的日志机制(比如 Hctree 的 WAL2),那么 PRAGMA edition = 2034 可以很自然地引入 PRAGMA journal_mode = WAL2。年份提供了一个不言自明的线性演进路径。

这个想法的原型来自 Rust,但它不只是在名字上借了一个概念。两者的结构性问题是一致的:你有一批历史遗留下来的默认值,你不能改,因为改了会破坏向后兼容承诺;但你也不能不改,因为不改意味着每一个新项目都要带着一份「最佳实践清单」手动调参。

什么叫「向后兼容」?从 Rust 到 SQLite 的距离

Rust editions 和 SQLite editions 之间有一个根本性的差异,这个差异在 HN 讨论中被反复提及。

Rust 是一个编译器。editions 只影响编译行为,不影响运行时行为。不同 edition 的代码最终产生相同的二进制内部表示,可以自由链接。

SQLite 是一个数据库。它不仅影响代码行为,还影响数据文件。如果你在一个数据库上执行了 PRAGMA journal_mode = WAL,这个设置是「粘性」的——它会持久化到数据库文件中。一个旧版本的 SQLite 库可能无法打开启用 WAL 模式的数据库文件。

用户 kccqzy 在 HN 上指出了这一点:「SQLite 和 Rust 稍有不同——SQLite 是一个数据容器。人们经常把 SQLite 数据库文件从一台机器复制到另一台机器,然后用不同版本的命令行工具去检查。editions 可能会破坏这种使用场景。」

mort96 的回应很直接:这个问题在你单独设置 pragma 时就已经存在了,editions 并没有制造新问题。如果你创建了一个包含 STRICT 表的数据库,旧版本的 SQLite 本来也打不开。真正起决定性作用的是你用了哪些具体的数据库特性,而不是你用单个 pragma 还是一组打包好的 pragma 来启用它们。

另一个被质疑的点来自 amluto:editions 可能根本不需要存储在数据库文件里。它可以是一个「库层面的构造」——当应用通过编程接口打开数据库时,设置 edition = 2026 只是触发 SQLite 库在连接层面设置一组标志。数据库文件本身不携带 edition 信息,旧版本的工具按自己的默认行为打开它。

社区的分歧:支持方和质疑方的关键论点

支持方的论点凝聚在一条来自 tptacek 的 HN 评论里:「这与其说是个人吐槽清单,不如说是几乎所有认真使用 SQLite 的人配置数据库的标准方式。合理的推论是:这些替代设置在 2026 年才是合理的默认值。」

sethev 补充了更结构化的视角:「看到一个问题列表后面跟着一个保持向后兼容的解决方案,这令人愉快。太多时候我们只看到抱怨和希望默认值改变的愿望。你不想要新默认值?不运行 PRAGMA edition = 2026 就完了。」

pstuart 也指出了 editions 的附加价值:它让新项目可以「一枪命中」所有最佳实践,而不是依赖团队经验传递。Rendello 提供了历史背景——SQLite 论坛上曾经讨论过一个从未实现的 PRAGMA strict 提案,和这次讨论形成了有趣的镜像。

质疑方的论点同样有力。kccqzy 指出了一个技术细节:「editions 让问题更糟,因为需要 SQLite 版本不仅支持底层的 pragma,还要理解 edition 映射。假设 PRAGMA foo=1 在 2027 年被引入,而 PRAGMA edition=2030 暗含了这个 pragma——你就不必要地锁死了三年内的发行版。」

PunchyHamster 提出了替代方案:「用 feature-set 取代 edition 可能更好,因为 feature-set 更明确你在启用什么。」

不过这更像是一种偏好——feature-set 和 edition 解决的是相同的问题,区别只在于粒度。editions 的年份编号有一个特征集不具备的优势:它暗示了线性演进的方向感。Rust 社区成员 tialaramex 在 HN 上说得很清楚:「Rust 的 2015 edition 不只是和 2024 edition 不同——它是 更差。有一个清晰的演进方向。」

如果 editions 这么聪明,C++ 为什么没做成?

讨论中出现了一个有趣的旁支。Animats 评论道:「现在我们 C/C++ 也需要这个,因为太多老旧的东西应该在写新代码时消失。」

tialaramex 回复了一条冷峻的历史记录:2019 年,Vittorio Romeo 向 C++ 标准委员会提交了 P1881「Epochs」提案——核心思想和 Rust editions 本质相同。委员会发现了很多问题,并且明确表示:如果你把所有问题都解决了,我们会发现更多问题。P1881 被放弃了。

tialaramex 的结论是:「好消息,有很多人尝试在 C++ 上做这件事。坏消息,C++ 标准委员会根本无意让他们的语言演进。」

这个对比其实对 SQLite 有利。SQLite 不是一个委员会驱动的语言标准,它有明确的维护者、清晰的治理模型、以及对长期支持的坚定承诺。如果 SQLite 团队认为 editions 值得做,他们的决策速度比 C++ 标准委员会快得多。

什么才是合理的判断?

这次讨论之所以吸引这么多人参与,不只是因为 SQLite 的默认配置让人恼火。更深层的张力在于:基础软件的「正确」默认值到底应该由什么决定?

SQLite 的维护者 D. Richard Hipp 和他的团队选择了一种非常保守的策略:默认值保持不变,功能通过 PRAGMA 逐步增加。这个策略的代价是每个新项目都要携带一组启封咒语。收益是——正如 SQLite 官网反复强调的——你可以放心地升级库版本,不需要担心任何现有行为发生变化。

这是一种有意识的取舍。不是疏漏,不是懒惰,是一种价值排序。

但 mort96 的提案恰恰绕开了这个取舍的核心——它没有要求改变默认值。它只是在默认值之上增加了一个「快捷方式」:你声明你用的是哪个时代的 SQLite,库就帮你把那个时代认为合理的一揽子设置打开。

这个设计有三个工程上的优点:

  1. 完全可逆。不设置 edition 的行为和今天一模一样。应用可以逐步迁移,甚至在同一进程中为不同数据库连接使用不同的 edition。
  2. 语义清晰PRAGMA edition = 2026 比一段由五六个 pragma 组成的咒语更好理解,也更好记忆。
  3. 方向明确。年份命名暗示了演进方向——2026 edition 比 2016 edition 更好,就像 Rust 2024 edition 比 Rust 2015 edition 更好。

也有两个不可忽视的挑战:

  1. 映射关系的维护成本。SQLite 需要维护「edition X 包含哪些 pragma」的映射关系,而且这个关系会随时间变化。如果一个应用声明了 edition 2026,但用的是 2028 年的库,它实际获得了哪些行为?如果新版本改变了之前 edition 的映射,就违背了「向后兼容」承诺。
  2. 跨版本互操作的不确定性。如果一个数据库被 edition 2026 的应用创建,然后被一个不使用 editions 的旧版命令行工具打开,可能会发生什么?这个问题在单独设置 pragma 时也存在,但 editions 会系统性地放大它。

这不仅仅是关于 SQLite

回过头看,这场讨论的参与者提到了大量类似的案例。Postfix 邮件的 compatibility_level 参数、CMake 的 policy 系统、Perl 的 use v5.44 版本声明——这些都是在不同领域尝试解决「如何在不破坏旧用户的情况下改变默认行为」的机制。

CMake 的案例特别有启发意义。IshKebab 在 HN 上说 CMake 拥有「最好的默认演进系统」:每个 policy 可以手动设为旧或新,还有一个全局配置可以基于 CMake 版本一键设置所有 policy。但 mort96 立刻反驳:「我几乎每周都会遇到 CMake 4 向后兼容断裂导致的问题。」

这揭示了 editions 体系的一个深层矛盾:当你足够长时间地维护向后兼容,你积累的 legacy 行为会成为一个越来越重的包袱。总有一天,有人会提议把包袱扔掉。而一旦扔掉,那个「向后兼容不破」的承诺就不再完整了。

SQLite 面临的选择比 CMake 更难——因为它承载着数量级更大的部署基数和更长的时间跨度。CMake 是一个构建系统,你做错了一件事,重新构建就行。SQLite 是一个存储引擎,你做错了一件事,可能影响的是用户的持久化数据。

结论

mort96 的提案在 HN 和 Lobsters 上引发了 200 多条评论。这不是偶然的。它用一种简单优雅的形式,把基础软件演化中的结构性张力摆在了桌面上。

Editions 确实是一个聪明的方案。它没有要求 SQLite 放弃向后兼容承诺,只是在默认值之上加了一个分层——让「现代化默认值」的选择变得像一行 pragma 一样简单。Rust 已经用事实证明,这个模型可以把语言从 2015 带到 2024,社区生态完好无损。

但 SQLite 不是 Rust。一个编译器 editions 和一个数据库 editions 之间的区别,不在于机制,在于风险剖面。当你管理的是数十亿人的持久化数据而非源码文件时,「不破坏任何东西」的优先级自然高于「引入合理的现代化默认值」。

最终来说,SQLite 的默认配置问题是一个所有新用户都会撞上的坑——但 editions 是否能填平这个坑,取决于 SQLite 团队是否认为增加一套映射层的维护负担,比继续让每个新用户提交一段 pragma 咒语更划算。

这是一个关于基础设施的本质问题:当你已经赢了,为什么还要冒险改变战术?当你的软件运行在太阳系里几乎每一台计算设备上,最小阻力路径永远是「什么都不改」。但最小阻力路径不等于正确路径。一个在 2004 年设计的默认值集合,在 2026 年仍然是最好的起点——这件事本身的概率,并不大。

本文的素材来自公开信息和社区讨论。如果你对这个话题有更深入的一手经验,欢迎指出文中的不足。

参考链接

  • SQLite should have (Rust-style) editions —— mort96 的原始博客,发表于 mort.coffee
  • Hacker News 讨论 —— 355 分、171 条评论,涵盖支持方和质疑方的主要论点
  • Lobsters 讨论 —— 134 分、34 条评论,包含 SQL 标准领域(CREATE DOMAIN)等技术细节
  • SQLite 论坛关于 PRAGMA strict 的历史讨论 —— Rendello 在 HN 中引用的早期相关提案
  • Rust Edition Guide —— Rust 官方文档,解释 editions 机制如何工作
  • Postfix compatibility_level 文档 —— 邮件服务器领域类似的默认值演进机制
  • P1881: C++ Epochs 提案 —— Vittorio Romeo 2019 年向 C++ 标准委员会提交的类似方案,已被放弃