“数据库版本”这件事,在 SQLite 项目里经常是被后置的。不少人上午刚往表里加了字段,下午同事那边的本地库还没 rebuild,启动直接报no such column。更麻烦的是,生产环境某个旧版本库还在跑,你既要兼容旧数据,又想把 schema 往前推进,而代码里可能没有一个地方能说清楚“这库现在到底停在哪个版本”。
SQLite 本身不是没有版本概念。PRAGMA user_version就是官方提供的一个整数版本位。但只用它,连“这张表是哪一次迁移加的”都说不清楚。反过来看 Rust 的版本体系:Cargo.toml声明依赖、Cargo.lock锁定校验、semver 自动解析、edition 管理演进,整套机制在“可预期、可复现、可回滚”上做得非常扎实。SQLite 能借鉴这套思路吗?能。而且 Rust 数据访问生态里,refinery、sqlx、diesel 已经做了大量类似实现。
这篇文章不讲空概念。我会拆解 SQLite 现有版本机制的痛点,提炼 Rust 版本管理里可以复用到数据库 schema 升级的思路,再给出一套可以直接落地的迁移代码和工程建议。适合写客户端、做本地缓存、跑 AI Agent 记忆存储、搞私有化部署的同学收藏阅读。
1. SQLite 版本机制现状
1.1 PRAGMA user_version 的局限
SQLite 官方提供了一个最简单的版本位:PRAGMA user_version。它本质上是数据库文件头部里的一个整数,允许应用自行解释。
-- 读取当前版本 PRAGMA user_version; -- 手动设置版本 PRAGMA user_version = 2;看起来很直接,但作为项目级版本机制,它有三个硬伤。
第一,它只有“当前版本”,没有“从哪里来”。数据库是从 0 升到 2,还是从 1 升到 2,user_version不记录过程。代码里一旦有人漏了某个分支,不同开发机的数据库会直接出现 schema 不一致。这种不一致在本地可能几天都发现不了,等部署到生产环境才集中爆发。
第二,它不保证迁移的执行顺序。开发者可以手动执行PRAGMA user_version = 3,但忘了执行ALTER TABLE,数据库的版本号和实际表结构就对不上了。对不上之后,后续所有迁移都建立在错误假设上,越往后越难修复。
第三,它不带任何校验信息。版本从 1 升到 2 之后,团队没有办法知道当初用在 V2 里的 SQL 是不是现在代码里那个 V2。同一个版本号,在不同分支里可能对应完全不同的表结构。这是多人协作里最隐蔽的坑:代码能跑,但每个环境里的库结构可能各有各的“微调”。
1.2 schema_migrations 表方式
社区里更常见的做法,是用一张迁移记录表来跟踪已执行过的迁移。这个模式最早出自 Rails 的schema_migrations,后来的 Flyway、Diesel 等工具也沿用了类似思想。
CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')) );每次执行迁移时,先把迁移文件的版本号插入这张表,再执行 DDL。程序启动时,对比表里已有的版本与代码目录里的版本,只执行缺失的部分。
这种方式解决了“从哪里来”的问题,也能让多个迁移文件按时间顺序排列。但它仍然没有解决校验问题:迁移文件在历史中被改写、被删除、被合并,这类事情在schema_migrations表里是看不出来的。只要有人改过历史脚本,整个迁移链的可信度就打了折扣。
1.3 Android onUpgrade 的模板模式
移动端生态里,SQLite 的版本管理被 Android 的SQLiteOpenHelper固化成了一套模板。下面这段代码非常典型:
public class AppDbHelper extends SQLiteOpenHelper { private static final int DATABASE_VERSION = 3; @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < 2) { db.execSQL("ALTER TABLE users ADD COLUMN email TEXT"); } if (oldVersion < 3) { db.execSQL("CREATE TABLE projects (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"); } } }这个模式在很长一段时间里是 Android 开发者的标准操作。缺点是:每个客户端升级时都要执行从老版本到新版本的增量路径,任何中间的ALTER TABLE在历史版本里被改过,老客户端升级就会出现难以复现的崩溃。用户设备上跑的 SQLite 版本可能各不相同,操作系统自带库的行为也会有差异。
这些问题的本质不是 SQLite 引擎不行,而是每个项目在“版本管理”上停留的层级不同。大多数人停留在“能升到最新版”这个层级,很少有项目把“迁移可验证、可回滚、不可篡改”当成工程目标。而 Rust 的版本体系,恰好把这几个点全做了。
2. Rust 的版本管理到底强在哪
如果把 SQLite 模式当作一个“依赖包”来管理,Rust 的版本机制几乎就是教科书级的参考。拆开看有四块核心能力。
2.1 Cargo.toml 声明式管理
Rust 项目用Cargo.toml声明依赖和版本约束,依赖之间的兼容性交给语义化版本规则解决。
[dependencies] rusqlite = { version = "0.31", features = ["bundled"] } serde = { version = "1", features = ["derive"] }对 SQLite 的启示是:一个数据库文件,应该像Cargo.toml一样有一个声明式入口,明确写清楚“当前 schema 的目标版本是多少”“迁移清单有哪些”,而不是靠每个开发者脑子里记住“上次 upgrade 执行到第几版”。
2.2 Cargo.lock 与确定性
Cargo.lock锁定所有依赖的精确版本和校验信息,保证同一份代码在任何机器上构建出相同结果。对 SQLite 迁移来说,同样需要一份“锁文件”:每个迁移脚本的顺序、哈希、依赖关系都被固定住,启动时校验当前库是否等于某个已知状态。
这是user_version做不到的。user_version只能表达“我升级到了第几版”,无法表达“这个版本是否由这些脚本构成”。而 Rust 风格的版本机制会让版本号携带内容哈希,或者用独立 manifest 记录每一个已执行脚本的哈希,从而侦测出历史迁移被篡改的情况。
2.3 Edition 与平滑演进
Rust 用 edition 管理语言层面的破坏性变更:同一个项目可以指定不同 edition,编译器按规则处理老写法与新写法共存。Rust 2015、2018、2021、2024 就是几个常见的 edition。
类比到 SQLite 上,可以把 major version 理解为 schema 的一次 edition 切换:不向后兼容的变更必须显式切换版本,并且在同一个迁移周期里保留兼容层。次要变更保持完全向后兼容,比如增加一张表、增加一个索引,都不应该让旧 schema 直接不可读。
2.4 cargo fix 与自动迁移
cargo fix能自动修复可机械迁移的代码。Rust 的工具链把“升级”当成一等公民:你有旧版本,工具帮你分析差异,生成新版本,然后校验。
SQLite 对应的“cargo fix”就是迁移执行器。启动时扫描迁移目录,对比当前状态,在事务里执行增量 DDL,做完之后把版本号推进。整个流程应该自动、可重复、可回滚,而不是靠人工在终端里敲。
3. 设计草案:给 SQLite 一套 Rust 风格的版本机制
把这套思路落到 SQLite 上,我建议设计成四层结构:清单文件、语义化版本、校验和锁定、原子幂等迁移。
3.1 schema.toml:清单文件
项目里放一个声明式清单,标记当前 schema 版本和迁移路径。
# schema.toml —— 概念示意 [package] name = "app_schema" version = "1.3.0" [[migration]] version = "1.0.0" file = "migrations/V1__init.sql" checksum = "sha256:..." [[migration]] version = "1.1.0" file = "migrations/V1_1__add_users_email.sql" checksum = "sha256:..." [[migration]] version = "1.2.0" file = "migrations/V1_2__create_projects.sql" checksum = "sha256:..."这个文件充当迁移目录的“权威声明”。工具启动时,先读取schema.toml,再读取实际的 SQLite 文件,计算差异,决定从哪个版本开始执行。
3.2 语义化版本编码
PRAGMA user_version只能存整数。一个可行的做法是把1.3.0编码成整数:
1.3.0 -> 10300也可以把它纯粹当作 major 标记,而把完整的语义化版本写到单独的_meta表里:
CREATE TABLE _meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL );INSERT INTO _meta (key, value) VALUES ('schema_version', '1.3.0');需要强调的是,整数编码只是一种习惯,SQLite 不会替你解释任何语义。真正决定兼容规则的是迁移脚本本身。
3.3 migrations.lock:校验和比对
在migrations.lock中记录每个脚本的哈希和顺序。
# migrations.lock —— 概念示意 version = "1.3.0" file = "migrations/V1__init.sql" checksum = "a1b2c3d4..." file = "migrations/V1_1__add_users_email.sql" checksum = "e5f6a7b8..." file = "migrations/V1_2__create_projects.sql" checksum = "c9d0e1f2..."启动时计算本地迁移脚本的哈希,和锁文件对比。不匹配就报错,不允许静默执行已经被改写过的迁移脚本。这样团队里如果有人改了历史迁移文件,下一台机器启动时就会收到明确警告。
3.4 原子迁移与幂等
Rust 风格的迁移执行,需要在单个事务里完成“执行脚本 + 升级版本号”。SQLite 对事务内 DDL 的支持比较友好,CREATE TABLE、ALTER TABLE都可以回滚,这一点比很多数据库要强。
迁移脚本本身还要幂等。即使执行两次,也不应该产生重复表或重复数据。常见做法是使用IF NOT EXISTS,数据回填时则用 upsert。
INSERT INTO users (id, name, email) SELECT id, name, email FROM legacy_users ON CONFLICT(id) DO UPDATE SET email = excluded.email;这条语句对应“存在就更新,不存在就新增”的场景。在迁移脚本里,它是把老数据合并进新表结构的高频写法。
4. Rust 生态中的 SQLite 迁移落地
设计归设计,工程上还是要落到代码。Rust 生态里至少已经有四个成熟方向可以拿来用。
4.1 rusqlite 手动迁移
如果项目足够小,没有引入 ORM,用rusqlite自己写迁移也非常直接。重点是用事务包住 DDL 和版本号的更新。
use rusqlite::{Connection, Result}; const APP_SCHEMA_VERSION: i32 = 3; fn main() -> Result<()> { let mut conn = Connection::open("app.db")?; // 查询当前 user_version let current: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?; println!("当前 schema 版本: {}", current); if current < APP_SCHEMA_VERSION { upgrade(&mut conn, current)?; } Ok(()) } fn upgrade(conn: &mut Connection, current: i32) -> Result<()> { let tx = conn.transaction()?; // 增量迁移: 每个 if 分支对应一个明确版本 if current < 2 { tx.execute_batch( r#" CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT ); "#, )?; } if current < 3 { tx.execute_batch( r#" ALTER TABLE users ADD COLUMN created_at TEXT NOT NULL DEFAULT (datetime('now')); "#, )?; } // 更新版本号, 与 DDL 在同一事务中提交 tx.pragma_update(None, "user_version", APP_SCHEMA_VERSION)?; tx.commit()?; println!("数据库已升级到 v{}", APP_SCHEMA_VERSION); Ok(()) }这个写法的关键是tx.pragma_update必须在事务内。DDL 和