SQLite 是一款令人惊叹的数据库引擎,被广泛用作嵌入式项目中的本地数据存储方案,可以说已经达到了行业标准级别。有些服务器软件甚至直接运行在 SQLite 之上——例如 lobste.rs 目前就在使用。与传统关系数据库管理系统(RDBMS)不同,SQLite 并非独立进程,而是以库形式嵌入的 RDBMS。这意味着你的应用依然保持干净独立,无需额外管理一个数据库服务进程。同时,相比传统文件格式,你也不必自行编写序列化器和解析器。从某些角度来看,这简直是两全其美的选择。
但有一个大问题——它的默认配置几乎全是陷阱。
错误的默认值 #1:默认情况下忽略外键约束
你没看错。外键约束是保证数据库数据一致性、避免悬空引用的核心工具。快速回顾一下,一个典型的外键约束定义如下:
所有其他 RDBMS 的默认行为是:帖子的 user_id 列必须始终引用一个有效的用户 ID。如果未提供有效的用户 ID,就无法创建新帖子;如果不先清理掉用户的帖子,就无法删除用户,否则会抛出外键违规错误。据我所知,唯一默认不强制这种约束的主流 RDBMS 就是 SQLite。
更糟糕的是,SQLite 倾向于重用 ROWID。在这个例子中,INTEGER PRIMARY KEY 的行实际上是表 ROWID 的别名——一个为每行分配的唯一整数 ID。ROWID 的分配算法略显复杂(详见 SQLite 文档),但在某些情况下会导致 ID 重用。这意味着悬空引用很容易变成引用到错误的列,这比单纯的悬空引用更危险:一切看起来都正常,甚至排查错误时也不会报错。
看看这个假设的 toy 数据库模式中的操作序列:
修复方法很简单,用 PRAGMA foreign_keys = ON 编译指示启用外键约束。如果一开始就这样做,上面那个有问题的 DELETE 操作就会直接报错。
错误的默认值 #2:列可以存储错误的数据类型
SQLite 的类型系统很简洁:值可以是 NULL、INTEGER、REAL(双精度浮点)、TEXT 或 BLOB(二进制数据)。但令人头疼的是,定义了类型的列并非只能存储该类型,而是“带有某种亲和力”。这意味着什么?
- 如果你插入一个
TEXT值,并且这个字符串恰好是整数的有效表示,它就会被转换成整数存进去。 - 如果是实数的有效表示,就转换成实数(双精度浮点)存进去。
- 否则,原样存储。
其他亲和力的规则也各有不同:BLOB 亲和力原样存储;TEXT 亲和力原样存储 BLOB、TEXT 和 NULL,但会把数值转为 TEXT;REAL 亲和力与 INTEGER 类似,只是整数值会转成 REAL。
这在实践中的表现就是——一个定义为 INTEGER 的列,你居然可以塞进去一个字符串,只要它看起来像数字?数据库对数据验证如此粗心,显然是个坏主意。如果 SQLite 是一个显式动态类型的文档数据库,那倒也罢了,可它不是——它明明在语法层问“你想在这一列放什么类型”,结果却不严格校验。
曾经有一个项目需要清理,代码不小心把字符串 "1" 和 "0" 写进了本应存储布尔值(整数 1 和 0)的列里。这可不是一个有趣的调试故事。
好在 SQLite 后来引入了“严格表”(Strict Tables)的概念——当你试图插入错误类型时它会报错。但不幸的是,没有一个全局编译指示可以把所有表都变成严格模式,你必须在每个建表语句中手动加上 STRICT 标签。
关于严格表也有一些反对意见。SQLite 的作者写过一篇偏爱“灵活类型”的文章,说实话读起来挺奇怪的——它没有给出任何实际例子说明把 BLOB 塞进 INTEGER 列有啥用,只是强调某些场景下需要能存任意类型的列。严格表其实有解决方案:用 ANY 数据类型。你仍然可以创建接受任意值的列,只需显式声明即可。
lobste.rs 上的用户 zie 提供了一个更好的论点:严格表不仅仅强制类型,还改变了类型说明符的解析规则。非严格表使用以下规则确定列的亲和性(来自 SQLite 文档):
- 如果声明的类型包含字符串
INT,则分配INTEGER亲和力。 - 如果包含
CHAR、CLOB或TEXT,则分配TEXT亲和力(比如VARCHAR包含CHAR所以是TEXT)。 - 如果包含
BLOB或未指定类型,则分配BLOB亲和力。 - 如果包含
REAL、FLOA或DOUB,则分配REAL亲和力。 - 否则,亲和力为
NUMERIC。
这条规则加上松散类型的结果是:你可以给列起类似 DATETIME、KEY_VALUE_SET、COLOR 这样的“类型名称”,然后数据库连接器/包装器自动知道如何序列化和反序列化自定义类型。至少这些自定义类型名可以作为有用的文档。
必须承认,如果只是简单地把默认表从非严格改成严格,而没有任何额外改动,就会丢失这种有点讨喜的怪癖。但自定义类型别名可能是一个更好的方向——如果我们能写 KEY_VALUE_SET 并在严格表里直接用它,相信每个人都会很高兴。这个功能可以大量用来记录列中预期的数据模式,因为现实世界的模式里总有一些必须由应用代码解析的 TEXT 列。如果还能把 CHECK 约束和自定义类型关联起来,那就更完美了。
补充一点:lobste.rs 上的 masklinn 指出,SQL 99 标准其实已经规定了类型别名——CREATE DOMAIN,并且支持约束。SQLite 只需要增加对这一标准语句的支持即可。
错误的默认值 #3:并发写入器出现 SQLITE_BUSY 错误
SQLite 允许多个并发读取器,但一次只能有一个写入器。默认情况下,如果有两个进程同时尝试获取写锁,其中一个会立刻收到 SQLITE_BUSY 错误。这不是人们期望的行为——既然它在做磁盘 IO,代码本就应该假设写入可能慢一些。默认行为导致过真实世界的 bug:系统偶尔因并发写入崩溃,不得不用手动重试循环来修复。
解决方法是使用 PRAGMA busy_timeout = 5000 设置忙超时,这样 SQLite 在报 SQLITE_BUSY 之前会尝试最多 5 秒获取锁。这个设置直到最近才被广泛了解,而它本应是个明显的默认值。
需要补充一点:为什么需要支持并发写入器?正常操作下,最好的做法是让所有写入都由单个进程(最好是单线程)完成,并发写入永远不会快。但也有不典型的情况——比如你在命令行上用 sqlite3 工具交互式手动清理数据库,或者有一些不常见的管理任务脚本,但不需要专门写个前端。这些都是完全合法的常见用例。而 SQLite 的默认设置却很可能在这些场景下抛出意外 SQLITE_BUSY 错误,进而导致软件崩溃。
糟糕的默认值 #4:性能
SQLite 的性能调优大有文章可做。配置得当的话,它可以成为真正快速的 RDBMS,甚至能填补通常留给 PostgreSQL 或 MySQL 等大型服务器的角色。但它的默认性能并不好。对此话题感兴趣的读者可以参考 Sylvain Kerkour 的《Optimizing SQLite for Servers》。
最严重的默认问题在于:预写日志(WAL)默认是禁用的。通过 PRAGMA journal_mode = WAL 可以启用它。大多数情况下 WAL 能显著提升写入速度,并且还能通过调整 PRAGMA synchronous = NORMAL 之类的设置来大幅减少磁盘同步次数,同时不冒数据损坏的风险。
解决方案:版本化?
为什么这些默认值还是默认值?最常被引用的理由是向后兼容性。现在改变默认值可能会破坏大量旧软件,并让用户不敢升级 SQLite,担心又像 Python 升级那样一堆东西坏掉。尽量不让依赖者受伤,这是一个值得称赞且罕见的目标。
然而,一个合理的方案是:添加一个“超级编译指示”来一次性更改所有错误的默认值。比如:
PRAGMA Edition = 2026 应该成为至少以下一组编译指示的别名,并把严格模式设为表的默认模式:
PRAGMA foreign_keys = ONPRAGMA busy_timeout = 5000PRAGMA journal_mode = WALPRAGMA synchronous = NORMAL
并且默认表为严格模式。这应该是一个很好的中间立场:避免破坏向后兼容性,同时让数据库引擎能向前发展,不被自己的历史所牵绊。
版本化的想法直接借鉴自 Rust 的版本机制。基于年份的版本比 JavaScript 的 "use strict" 更有优势——随着时间推移,合理的默认值可能变化。也许像 Hctree 的 WAL2 这样的东西会在 2034 年进入主分支,那么 PRAGMA Edition = 2034 有一天就会设置 PRAGMA journal_mode = WAL2。
总而言之,SQLite 应该有一个包含更新默认值集的版本系统。不知道是不是遗漏了什么导致这个想法不成立?或者是否还有更多实用建议应该加入这个想象中的“2026 版本”?
