本地数据库迁移最危险的成功,是“新装应用一切正常”。全新数据库只验证了最新建表语句,无法证明一位从三年前版本升级的用户还能保留数据。可靠迁移需要把数据库版本、每一步变换、约束校验和失败恢复组合成一份发布契约。

版本是有向路径,不是一个整数判断

SQLite 的 PRAGMA user_version 提供一个由应用自由使用的整数,SQLite 自身不会解释它。可以把它作为已完成的 schema 版本,但不要误用内部的 schema_version:后者由 SQLite 跟踪 schema 变化,不是应用的迁移编号。

迁移器应从当前版本逐步走到目标版本,而不是只处理“不是最新版”:

v3 --migrate_3_to_4--> v4 --migrate_4_to_5--> v5

每条边都应是代码中可定位、测试中可单独执行的函数。数据库版本高于客户端认识的版本时必须拒绝写入并给出可诊断错误;把一个未来 schema 当作旧库打开,可能比直接失败更危险。

在打开连接时固定不变量

外键不能依赖运行环境默认值。SQLite 官方文档要求应用在每个数据库连接上显式设置 PRAGMA foreign_keys = ON,而且在多语句事务中途切换不会生效。连接建立后立即启用,再读取结果确认值为 1。

迁移应在普通业务连接并发访问之前完成。使用 BEGIN IMMEDIATE 可以尽早取得写事务;若此时返回 SQLITE_BUSY,应用可按有限退避策略重试,而不是执行一半后才发现写锁冲突。事务模式、超时和重试次数都应成为可测试配置。

升级前还要核对运行时 SQLite 版本是否支持迁移 SQL。开发机支持 DROP COLUMN,不代表旧手机内置的 SQLite 也支持。兼容性要求应取所有受支持设备的下限,或者改用重建表流程。

简单变更也要有前后条件

新增可空列通常可以直接完成:

BEGIN IMMEDIATE;

ALTER TABLE notes ADD COLUMN archived_at TEXT;
CREATE INDEX notes_archived_at_idx ON notes(archived_at);

PRAGMA user_version = 4;
COMMIT;

但 ADD COLUMN 并非任意定义都合法。例如新增 NOT NULL 列需要非空默认值,部分约束会检查已有行。迁移函数应在执行前断言来源版本,并在提交前确认新列、索引和关键数据数量。不要用大量 IF NOT EXISTS 掩盖未知的半迁移状态;预期状态不成立时应停止并保留证据。

user_version 必须与 schema 和数据变换在同一事务中写入,并且只在所有步骤成功后推进。进程被杀或磁盘写入失败时,事务回滚使下次启动仍从旧版本重试,而不是留下“版本已更新、数据未完成”的谎言。

复杂变更采用新表搬运

修改约束、主键或列类型时,不要直接编辑 sqlite_schema。SQLite 官方给出的通用路径是创建新表、复制数据、删除旧表并把新表改回正式名称。关键顺序如下:

PRAGMA foreign_keys = OFF;
BEGIN IMMEDIATE;

CREATE TABLE new_tasks (
  id INTEGER PRIMARY KEY,
  title TEXT NOT NULL,
  state TEXT NOT NULL CHECK (state IN ('open', 'done'))
);

INSERT INTO new_tasks (id, title, state)
SELECT id, trim(title),
       CASE WHEN completed = 1 THEN 'done' ELSE 'open' END
FROM tasks;

DROP TABLE tasks;
ALTER TABLE new_tasks RENAME TO tasks;
CREATE INDEX tasks_state_idx ON tasks(state);

PRAGMA foreign_key_check;
PRAGMA user_version = 5;
COMMIT;
PRAGMA foreign_keys = ON;

关闭外键必须发生在事务之前;在事务中途设置没有效果。迁移还要保存并重建受影响的索引、触发器和视图,复制语句则应显式列出源与目标列。SELECT * 会把列顺序当成隐形协议,下一次修改很容易错位。

示例仅表达顺序。生产实现必须读取 foreign_key_check 的结果,任何返回行都视为失败并回滚。重新启用外键后也要查询其值,不能假设 PRAGMA 拼写正确就已经生效。

完整性检查不是一个命令

提交前可运行 PRAGMA integrity_check,它检查页、记录、索引以及 UNIQUE、CHECK、NOT NULL 等一致性;返回单行 ok 才算通过。但它不检查外键,因此还必须单独执行 PRAGMA foreign_key_check。

业务不变量也不在这两个命令的职责内。例如迁移前后任务数量应一致、非空标题不能减少、旧状态必须全部映射到新枚举。把这些断言写成 SQL,并让任何失败终止事务。结构合法不等于业务数据正确。

用生产形状的旧库建立测试矩阵

为每个仍支持的来源版本保存一个脱敏 fixture,而不是在测试中用最新建表语句临时伪造“旧库”。fixture 应覆盖空库、典型数据、最大规模、可空边界、Unicode、已删除关系和历史 bug 留下的合法异常。

每份 fixture 都执行完整应用升级,然后验证目标版本、表和索引、业务断言、外键与完整性检查。还应注入失败:复制到一半抛错、磁盘空间不足、锁竞争或进程终止,再次打开时数据库必须保持旧版本或完整新版本,不能处于两者之间。

迁移测试还要覆盖真实链路,例如 v2→v3→v4→v5,而不仅是 v4→v5。CI 可以比较全新 v5 与从最老版本升级到 v5 的规范化 schema,及时发现某条历史路径漏建索引。

应用回退不等于事务回滚

迁移事务尚未提交时可以回滚;新版本已经提交并写入新格式后,把应用二进制换回旧版本通常并不安全。发布前必须选择策略:让旧客户端只读兼容新 schema、提供经过测试的向下迁移,或者明确禁止降级并依靠备份恢复。绝不能简单调低 user_version,因为数据形状不会随整数自动还原。

可靠的 SQLite 升级最终依赖四条边界:版本图明确每个允许路径;单个事务让 schema、数据与版本共同提交;SQLite 校验和业务断言共同守住正确性;真实旧库和故障注入证明失败不会损坏用户数据。新装成功只是起点,升级证据才是发布资格。