在桌面应用里,数据库迁移不是普通的 SQL 脚本。一旦随应用版本发布,它就成为用户数据库历史的一部分。
开发阶段常见的流程是:写 0001_initial.sql、本地运行、发现字段不够、直接修改旧文件、删掉本地数据库重来。这个流程在开发机上看起来没问题,但用户机器上的数据库并没有被删掉。
1. SQLx 为什么会发现迁移文件被修改?
SQLx 会在 _sqlx_migrations 中记录已执行迁移的版本和 checksum。
假设 v1.0 已经执行过 0001_spaces.sql。后来开发者修改了同一个文件,即使文件名和版本号没变,内容 checksum 也会变化。新版本启动时发现已执行迁移与当前文件不一致,就会拒绝继续。
这不是工具在制造麻烦,而是在阻止程序悄悄接受一段无法解释的数据库历史。
已发布迁移的内容,是持久化兼容性契约。
2. 正确做法:只追加新版本
v1.0 已经发布,现在要增加字段,应该新增迁移,而不是编辑旧文件:
text
spaces/db/migrations/
├── 0001_spaces.sql
└── 0002_add_space_color.sql
示意 SQL:
sql
ALTER TABLE spaces
ADD COLUMN color TEXT NOT NULL DEFAULT 'blue';
DEFAULT 不只是为了让 SQL 通过。已有行在迁移完成时就应该满足新 schema 的不变量,而不是把"旧数据没有值"的特殊情况留给每个读取方处理。
Knovum 的 Spaces 使用嵌入式 SQLx migrator,在 Spaces pool 对外可用之前执行迁移。每个 feature-owned 数据库维护自己的迁移历史,而不是把所有能力塞进一个全局数据库服务。
3. SQLite 的复杂 schema 变更需要显式重建
SQLite 并不能原地修改所有约束。例如,改变已有列的 CHECK 约束或增加外键,可能需要 table rebuild。
简化后的迁移形态:
sql
BEGIN;
CREATE TABLE spaces_new (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
color TEXT NOT NULL,
created_at INTEGER NOT NULL
);
INSERT INTO spaces_new (id, title, color, created_at)
SELECT id, title, color, created_at FROM spaces;
DROP TABLE spaces;
ALTER TABLE spaces_new RENAME TO spaces;
-- 重建需要的索引和触发器
COMMIT;
这只是说明流程,不能直接复制到任意生产数据库。真实重建必须核对外键、索引、触发器、视图、依赖对象和数据转换。
可以把迁移策略总结为:
- 新表、索引和带合理默认值的列,优先增量添加;
- 新列需要从旧数据推导时,在同一迁移中 backfill;
- 约束不能原地修改时,使用经过审查的 table rebuild;
- 删除表或列意味着删除用户数据,必须作为产品决策审查,而不是顺手清理。
前向迁移可以增加结构、补齐数据,但不能偷偷重新解释用户已经保存的内容。
4. 两种迁移失败,不应该都被叫作"数据库坏了"
Checksum mismatch / VersionMismatch:数据库记录的已执行迁移,与当前程序携带的迁移文件内容不一致。通常这是开发流程错误,应该在 CI 或发布前发现,而不是让用户第一次遇到。
Unknown newer migration / VersionMissing:数据库记录了当前二进制不认识的迁移版本。它可能曾被更新版本的应用打开。旧版本不能假装自己理解新 schema,更不能自动降级或重置数据库。
两种情况都应该保留原始数据并明确报告问题,不能触发"删库重建"。
5. Fresh database test 不等于 upgrade test
下面的测试只证明当前 schema 可以从零创建:
rust
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await?;
MIGRATOR.run(&pool).await?;
它没有证明旧版本用户的数据能安全升级。
真正的升级测试应该从已发布的旧 schema 开始,插入有代表性的数据,再执行后续迁移,并断言新 schema 和旧数据:
rust
let pool = shipped(1).await;
// 插入旧版本真实存在的数据形状
sqlx::query(
"INSERT INTO spaces (id, kind, title, created_at, updated_at)
VALUES ('s1', 'space', 'My Space', 1, 1)"
)
.execute(&pool)
.await?;
MIGRATOR.run(&pool).await?;
// 验证迁移后数据仍然存在
let title: String = sqlx::query_scalar(
"SELECT title FROM spaces WHERE id = 's1'"
)
.fetch_one(&pool)
.await?;
assert_eq!(title, "My Space");
这是教学示意。真实测试还应检查约束、索引、触发器、数据回填,以及最终 schema 是否与全新安装一致。
Knovum 的 src-tauri/src/spaces/db/tests/upgrades.rs 就采用旧 schema 起步的测试方式:迁移到历史版本、插入代表性记录、执行升级,再检查 schema 和保留的数据。发布流程还要求验证上一版安装包实际创建的数据库可以升级;只测空数据库不算升级验收。
6. 为什么 WebView 不应该直接访问 SQLite?
迁移安全也是数据所有权问题。如果 Vue WebView 可以执行任意 SQL,查询、事务和领域规则就会分散到 UI 代码中。
Knovum 使用的边界是:
text
Vue 3 WebView
|
| typed Tauri commands
v
Rust feature owner
|
| SQLx queries / transactions
v
Feature-owned SQLite database
WebView 没有 SQLite capability,也没有 SQL 插件依赖。前端表达"更新 Space 标题"这样的领域意图,Rust 负责 SQL、事务和不变量。
如果一个业务不变量跨越多条 SQL,就应该在一个 Rust SQLx transaction 中完成。不要把事务拆成多个 IPC 请求,再寄希望于 UI 按正确顺序调用。
7. 连接参数也是存储设计的一部分
Knovum 当前共享 SQLite 连接策略包括:
foreign_keys(true);- WAL journal mode;
synchronous=NORMAL;- 5 秒 busy timeout;
- 有界连接池;
- cache、mmap 和 memory temp store;
- 迁移后执行
PRAGMA optimize。
WAL + synchronous=NORMAL 是一种取舍,不是"零数据丢失"承诺。它通常避免每次提交都等待完整 fsync,同时保持对普通进程崩溃的保护;突然断电仍可能丢失最近提交的事务。因此要结合备份和恢复策略评估风险。
这些是项目当前采用的策略,不是所有 SQLite 应用都必须照搬的默认值。
8. 迁移合并前的检查清单
- 是否修改、删除或重命名了已经发布的迁移?
- 是否通过新的递增版本迁移实现 schema 变化?
- 旧数据是否在迁移完成时满足新约束?
- 是否检查外键、索引、触发器和视图?
- 是否有从上一个已发布 schema 开始的升级测试?
- 测试是否验证数据仍存在且语义正确?
- 旧二进制遇到新 schema 时,是否拒绝写入而不重置数据?
- 发布流程是否验证真实安装包创建的数据库可以升级?
结语
数据库迁移不是一次性的部署脚本,而是应用与用户之间长期存在的数据兼容性承诺。
当桌面应用开始积累用户的工作区、文档、对话和知识库,"删库重建"就不再是合理的开发捷径。
冻结已发布迁移、只做前向演进、从真实旧 schema 测试升级,并把 SQL 和事务留在明确的数据所有者内,这些规则能让数据库演进更可预测,也避免用户为开发流程失误买单。
这些实践来自我在开发 Knovum 时整理的数据库所有权与迁移约束。项目代码和工程文档:github.com/madebyzan/k...。
说明:文中的 SQL 和 Rust 片段是简化示意,不保证与仓库当前源码逐字一致;具体实现以仓库代码和文档为准。