一个平时看起来挺顺的 SQLite 项目往往是在第一次需要升级表结构时开始乱的。起初只是PRAGMA user_version 1;接着是2、3后来升级脚本散落在代码里测试环境跑过生产环境没跑老用户设备上的数据库还是旧结构。SQLite 本身内置了user_version但它只是一个 32 位整数既不能表达语义化版本也不能记录迁移脚本的校验和历史。Rust 社区在处理“版本”这件事上有非常明确的工程化倾向Cargo 用语义化版本和锁文件保证可重现构建sqlx、diesel 等数据库工具把迁移脚本当作受信资产记录版本、校验和、执行顺序。这篇文章要讨论的正是把这种“Rust 风格”迁移到 SQLite 上不要求给 SQLite 增加什么内置语法而是在应用层设计一套版本化、可校验、可回滚的数据库 schema 迁移机制。1. SQLite 现有的版本机制到底缺什么1.1 SQLite 自己有哪些版本号SQLite 并不是没有版本概念。它自己维护了好几个版本号只是很多开发者只认识user_version。SQLITE_VERSION是 SQLite 库本身的版本例如 3.45.1它和数据库内容的 schema 升级没有直接关系。PRAGMA schema_version是 SQLite 内部使用的 schema 缓存版本号。每次建表、删表、加字段SQLite 都会自动更新这个值用来让连接缓存失效。这个值不应该由应用手工修改改坏了会导致缓存错乱甚至让数据库出现莫名异常。PRAGMA user_version是 SQLite 专门留给开发者使用的版本号默认值是 0可以在事务里读写。它存的是 32 位有符号整数适合记录一个简单的升级进度数字。PRAGMA application_id用来标识数据库文件属于哪个应用不参与 schema 版本管理。可以用下面几条 SQL 快速查看PRAGMA user_version; PRAGMA schema_version; PRAGMA application_id;user_version的优点是简单缺点是太简单。它只能记录一个整数不能记录“从哪个版本升到哪个版本”“执行了哪几个脚本”“脚本内容是否被改过”。当迁移文件超过 20 个、多个开发分支同时改表结构时单靠一个整数根本无法回答“当前数据库到底处于什么状态”这个问题。1.2 常见的迁移方式为什么不够用实际项目里常见做法可以归纳成三种。第一种是手写PRAGMA user_version然后在应用启动时用 if/else 判断PRAGMA user_version 0; -- 如果 user_version 1 ALTER TABLE user ADD COLUMN nickname TEXT; -- 然后 PRAGMA user_version 1;这种方式在小项目里很直接但分支多了以后判断逻辑会变成一长串 if/else漏掉一个版本号就可能让生产环境直接崩掉。而且它没有记录“哪些迁移脚本执行过”一旦重新创建数据库整个升级链路都要重跑。第二种是启动时直接执行CREATE TABLE IF NOT EXISTS完全不维护版本号CREATE TABLE IF NOT EXISTS user ( id INTEGER PRIMARY KEY, name TEXT NOT NULL );这种方式适合只加新表、不改造旧表的场景。一旦需要删除列、修改列类型、合并表IF NOT EXISTS就无能为力了因为 SQLite 对已存在的表不会做任何变更。第三种是使用 ORM 的自动迁移比如 Django、SQLAlchemy、Android Room。这种方案会把版本管理逻辑封装好但前提是整个团队都遵守同一个框架约定。对于 Rust SQLite 这种偏底层、偏嵌入式、多端部署的场景自动迁移未必够用。迁移方式适用场景主要问题user_version 手写脚本单机小项目无法记录历史分支合并容易冲突CREATE TABLE IF NOT EXISTS只新增表无法处理列变更和表重建ORM 自动迁移后端集中式应用依赖框架约定底层细节被隐藏自研迁移器多端、长期维护需要自己设计版本表、校验和并发处理这些方式共同缺少的是几个关键能力迁移历史无法完整追溯。脚本内容是否被修改过无法校验。升级过程不能保证原子性。多实例并发启动时缺少明确锁定策略。数据库版本没有语义化无法判断升级是否破坏兼容性。1.3 Rust 风格有哪些可借鉴的要素Rust 生态里和“版本”相关的思想可以拆成三块看。第一块是 Cargo 的语义化版本。Cargo 依赖MAJOR.MINOR.PATCH三段式版本号主版本号变化表示不兼容变更次版本号表示向后兼容的功能新增补丁版本号表示兼容的修复。这个规则让一个 crate 的版本可以被人和工具一眼判断是否安全升级。第二块是 Cargo.lock 锁文件。它把依赖树里每个包的精确版本固定下来保证在另外一台机器上构建时能复现出同样的结果。对应到 SQLite 升级上就是“迁移脚本一旦被应用过就不允许篡改”。第三块是数据库迁移工具比如 sqlx、diesel。它们会把迁移记录表、校验和、事务执行这些细节固化到工具链里。sqlx 的迁移器会记录每次执行的版本和 checksumdiesel 也维护一张 schema migrations 表。这些设计保证了同一个迁移脚本在开发环境、测试环境、生产环境表现一致。维度普通 SQLite 升级Rust 风格参考版本号user_version整数语义化版本或受管递进版本迁移历史不一定有迁移记录表脚本一致性靠人记checksum 校验执行原子性视脚本写法事务 失败回滚升级并发容易锁冲突BEGIN IMMEDIATE busy_timeout回滚能力手工备份up/down 脚本或备份恢复2. 一套 Rust 风格的 SQLite 版本机制应该长什么样2.1 用语义化版本替换单一整数这套机制的第一条原则是把“版本号”从整数升级为语义化字符串。可以规定MAJOR破坏性变更。删除列、重命名表、改变列语义。MINOR向后兼容的新增。新增表、新增可空列、新增普通索引。PATCH不影响 schema 语义的修整。调整索引名、优化触发器、补充注释。这样每次看到2.0.0和1.1.0就能判断升级是否会对旧客户端产生破坏性影响。user_version可以继续保留用来做快速判断“当前数据库是否已经初始化”但不再是权威版本来源。2.2 迁移目录和文件命名迁移脚本按版本目录存放一个版本对应一个 up 脚本和一个可选的 down 脚本。migrations/ 1.0.0__init.up.sql 1.0.0__init.down.sql 1.1.0__add_user_profile.up.sql 1.1.0__add_user_profile.down.sql 1.2.0__add_order_table.up.sql 2.0.0__rebuild_user_table.up.sql文件命名规则是version__description.up.sql version__description.down.sql版本号1.1.0右侧必须紧跟两个下划线再跟上用短横线或下划线描述的用途。这样扫描目录时可以用split_once(__)稳定解析出版本号。up 脚本负责从旧版本升级到新版本down 脚本负责撤销 up 脚本的操作。SQLite 的ALTER能力有限很多 down 脚本写起来很麻烦所以 down 脚本不要求每个版本都提供但破坏性较大的迁移最好补一份。2.3 迁移记录表数据库里必须有一张表记录已经执行过的迁移。字段设计可以这样CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, description TEXT NOT NULL, script_name TEXT NOT NULL, checksum TEXT NOT NULL, applied_at TEXT NOT NULL DEFAULT (datetime(now)) );字段含义字段说明version迁移版本号比如1.1.0作为主键防止重复执行description迁移用途的可读描述script_name对应的迁移文件名方便排查checksumup 脚本内容 SHA-256 摘要applied_at执行时间主键必须是version而不是自增 id。因为迁移脚本有一个对外的稳定版本号用版本号做主键可以天然避免同一个版本被执行两次。2.4 为什么必须记录校验和与执行事务校验和解决的是“历史迁移脚本被修改”的问题。很多事故是这样发生的开发环境跑完迁移后来发现初始脚本里少了某个字段于是直接改1.0.0__init.up.sql重新建库后一切正常。但生产环境早就跑过了1.0.0改脚本不会让生产环境的表结构变更。最终结果是开发环境、测试环境、生产环境数据表结构不一致。迁移执行时计算脚本 SHA-256并把值记录到schema_migrations.checksum。后续启动时如果发现某个已应用版本的 checksum 和迁移文件当前值不一致直接拒绝继续执行。这样能尽早暴露问题。事务保证的是“升级不会执行到一半留在中间状态”。SQLite 的 DDL 是支持事务的CREATE TABLE、ALTER TABLE、DROP TABLE都可以回滚。因此每个 up 脚本都应该放进一个事务里执行脚本中任何一条 SQL 报错整个事务回滚数据库恢复到迁移前状态。注意不要在同一份迁移脚本里夹杂VACUUM、PRAGMA wal_checkpoint这类不可回滚操作。事务里的失败回滚保护的是 schema 变更不是所有 SQLite 语句。3. 用 Rust 写一个最小迁移器3.1 项目依赖和目录空谈规则很难落地下面用 Rust 和 rusqlite 写一个最小迁移器。它不依赖 ORM能看清版本机制的核心逻辑。先准备一个 Cargo 项目[package] name sqlite_migrator_demo version 0.1.0 edition 2021 [dependencies] rusqlite { version 0.31, features [