SQLite + SQLx 实战:为什么已经发布的数据库迁移文件,绝对不能再修改?

在桌面应用里,数据库迁移不是普通的 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 片段是简化示意,不保证与仓库当前源码逐字一致;具体实现以仓库代码和文档为准。

相关推荐
微步AICRM14 分钟前
一物一码验真的并发链路设计:码段管理、四级关联与异常判定
后端·架构
陈随易28 分钟前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员
IT_陈寒35 分钟前
Vue的这个响应式陷阱,差点让我加班到天亮
前端·人工智能·后端
我叫黑大帅41 分钟前
PHP 常驻 Worker 中 MySQL 连接中断问题报告
后端·面试·php
这个DBA有点耶1 小时前
数据同步的三个工程底线:DDL同步、断点续传、一致性校验——开源CDC的边界与KFS的路径
数据库·dba·代码规范
写码跑山的老艾1 小时前
优惠券为何能反复使用?一次算价与提单不一致的资损复盘
后端
RAG学徒loading2 小时前
我把 RAG 召回从 57.6% 做到 78.8%,然后发现 75% 的真实问题它答不了
后端
用户42569399996202 小时前
我的 HashMap 查找全废了,但 36 个测试全绿
后端
小番茄程序猿2 小时前
Redis 编码压测 59 轮:HSET 快 23 倍是假象,SMEMBERS 慢 30 倍是真的
redis·后端