我那个数据管家第一版上线的时候,库里只有三张表。后来加了来源字段、加了去重标记、加了任务运行记录,一口气到第五版 schema。整个过程我自己机器上从来没出过问题 —— 因为我每次都是删了库重建的。

真正的问题出现在一个从旧版本升上来的用户身上:应用启动,转两秒,弹一个红框,里面一行 SqliteError: no such column: source。数据库里的数据一条都没丢,但应用打不开了。

现象:新版本启动报 no such column

这个报错的意思很直白:代码里 SELECT 了 source 这一列,表里没有。

我第一反应是「我明明改了建表语句」。是的,改了。问题在于改的那句 CREATE TABLE 是给「新建的库」用的。老用户那边库文件早就存在了,那句 SQL 从头到尾没被执行过。

建表语句只在新建库时生效

很多人写初始化是这样的:

db.exec(`
  CREATE TABLE IF NOT EXISTS records (
    id     INTEGER PRIMARY KEY,
    path   TEXT NOT NULL,
    size   INTEGER,
    mtime  INTEGER,
    source TEXT NOT NULL DEFAULT ''
  );
`);

IF NOT EXISTS 的字面意思就是「表已经在了就什么都别做」。它保证的是不报错,不是让表变成最新的样子。你往里面加一列、改一个类型,对已存在的表完全没有作用。

所以这条路走不通,只能显式地迁。

用一个数字记住版本

SQLite 有个现成的地方存这个数字:PRAGMA user_version。它就是数据库文件头里的一个 4 字节整数,默认 0,读写几乎不花时间,也不需要额外建一张表。

启动流程是三句话:读版本、跟代码里的目标版本比、小了就跑迁移。

const TARGET = 5;

function migrate(db) {
  let cur = db.pragma('user_version', { simple: true });

  if (cur > TARGET) {
    throw new Error(`数据库版本 ${cur} 比应用支持的 ${TARGET} 新,请升级应用`);
  }

  while (cur < TARGET) {
    const next = cur + 1;
    db.transaction(() => {
      MIGRATIONS[next](db);
      db.pragma(`user_version = ${next}`);
    })();
    cur = next;
  }
}

三个地方值得说:

1. cur > TARGET 这个判断一定要写。用户装了新版本、用了一阵、又退回旧版本,旧版本读到的是 v5 的库。这时候按 v2 的 SQL 去读,轻则缺列报错,重则读到语义已经变了的字段,写回去就把数据搞坏了。宁可拒绝启动,让用户升级回去。

2. user_version 的更新放在事务里。它是数据文件头的一部分,受事务保护,迁移函数抛异常时版本号会跟着回滚。不会出现「结构没改成、版本号已经跳到 5」这种状态。

3. 一次只走一步。 while 循环里每次只执行 next = cur + 1 那一步,从 0 升到 5 就是把 1、2、3、4、5 依次跑一遍。这种情况在真实升级里很常见 —— 老用户可能跳过了三个版本。

迁移要按顺序走,不要写探测式

另一个流派是探测式:连上库,PRAGMA table_info(records) 看有没有 source 列,没有就加;再看有没有 dedup_key,没有就加。听起来很聪明,能自动适配任何旧版本。

我一开始写的就是这个,后来改掉了,原因有三个:

1. 有些迁移不是「加一列」,是「把数据搬一遍」。比如把 path 拆成 dir 和 name、把毫秒时间戳换成秒。这类迁移没法用「有没有这一列」判断做没做过,一旦重复执行就会把数据算错两遍。

2. 分支开发的时候两个分支各加一列,合并之后探测式代码的顺序不确定,在别人机器上和在你机器上跑出来的库结构可能不一样。

3. 出了问题没法回答「这个库到底是第几版」。用户发个日志过来,你只能猜。

顺序迁移的写法笨一点,但每一步都是不可变的、有编号的、能回放的。跟你用 git 提交历史管代码是一个道理。

还有一条:迁移函数里只写 SQL,别调应用层的代码。

不要 new 你的 Model,不要调你的工具函数,不要依赖 types.ts 里的枚举。因为这段迁移要在三年后的老机器上跑,那时候这些代码早就改了三轮,甚至删了。迁移的唯一输入是数据库本身,唯一输出也是数据库本身。

加列能做的和不能做的

ALTER TABLE ... ADD COLUMN 是 SQLite 支持的,但它只做一件事:在表末尾加一列。不能改列的类型、不能改约束、不能指定位置。

加列有几个硬限制,踩了会直接语法错误:

1. 带 NOT NULL 的新列必须给默认值。老数据那些行得有个值填进去,SQLite 不会帮你猜。

2. 默认值必须是常量。DEFAULT CURRENT_TIMESTAMP、DEFAULT (datetime('now')) 这种不行,会报 Cannot add a column with non-constant default。

我的习惯是分两步,比一步加带默认值的列好控制:

-- 1. 先加一个可空的列
ALTER TABLE records ADD COLUMN source TEXT;

-- 2. 把老数据填上
UPDATE records SET source = 'manual' WHERE source IS NULL;

想给老数据填的值如果比较复杂(比如按扩展名判断来源),第二步用 CASE WHEN 写就行,反正只跑一次,性能无所谓。

如果新列必须 NOT NULL,就在同一个迁移里加完、填完,再重建一次表加上约束。别指望 ADD COLUMN 能改约束。

改类型、删列、改主键:只能重建表

SQLite 3.35 之后有了 DROP COLUMN,但限制很多:被索引引用的列删不掉、被视图或触发器引用的删不掉、被外键引用的删不掉、PRIMARY KEY 列删不掉、带 UNIQUE 的删不掉。报错是 error in index ... after drop column 这类,看着不太直观。

所以只要改动稍微大一点,老老实实重建表。顺序不能乱,这是官方 ALTER TABLE 文档里的步骤,我按自己踩过的坑排了一遍:

PRAGMA foreign_keys = OFF;      -- 必须在事务外面
BEGIN;

CREATE TABLE records_new (
  id        INTEGER PRIMARY KEY,
  dir       TEXT NOT NULL,
  name      TEXT NOT NULL,
  size      INTEGER,
  mtime     INTEGER,
  source    TEXT NOT NULL DEFAULT ''
);

INSERT INTO records_new (id, dir, name, size, mtime, source)
SELECT id, dirname(path), basename(path), size, mtime,
       COALESCE(source, 'manual')
FROM records;

DROP TABLE records;
ALTER TABLE records_new RENAME TO records;

CREATE INDEX idx_records_dir ON records(dir);
CREATE UNIQUE INDEX idx_records_dedup ON records(dir, name, size, mtime);

COMMIT;

PRAGMA foreign_key_check;       -- 确认没有悬空引用
PRAGMA foreign_keys = ON;

几个点解释一下:

PRAGMA foreign_keys = OFF 必须在 BEGIN 之前。 放在事务里是空操作,SQLite 文档写得很清楚:这个 pragma 在有未完成事务时不生效。我第一次写的时候就在事务里,结果 DROP TABLE records 触发了级联删除,子表里几千行记录跟着没了。那次是从备份恢复的。

索引、触发器、视图不会跟着表重建。 CREATE TABLE records_new 只带表本身的约束,原来 records 上的索引在 DROP TABLE 的时候就一起没了。忘了重建索引,迁移本身不报错,表现是迁移之后查询突然变慢 —— 这个 bug 用户不会报,因为「慢」不是坏掉。

ALTER TABLE ... RENAME TO 会自动更新引用。 SQLite 3.25 之后,重命名表会把其他表的外键、视图、触发器里对这个表名的引用一起改掉。这正好是重建表想要的:子表的外键原本指向 records,records 被删、records_new 改名成 records 之后,引用自然还是对的。

但如果你的项目里有 PRAGMA legacy_alter_table = ON(老版本的 ORM 会设这个),这个自动更新就没了,得手动处理。一般不设这个参数就行。

列顺序也变了。 重建之后列顺序按 records_new 的定义来,跟原来不一定一样。SQLite 里 SELECT * 是按列顺序返回的,如果你有代码靠下标取值(row[3]),迁移之后就开始取错列。这是重建表最阴的一个副作用,用 SELECT * 加下标的代码必须改成按列名取。

备份:直接复制文件是不够的

迁移之前要备份,这个不用讨论。但「备份」的具体做法有个坑:WAL 模式下不能直接 cp 那个 .db 文件。

开了 journal_mode=WAL 之后,新写入的数据先在 -wal 文件里,定期 checkpoint 才合并进主文件。你在迁移前一刻复制 .db,复制到的是「上次 checkpoint 时的状态」,最近一段时间的写入还在 -wal 里没进来。恢复出来就是少一截数据 —— 而且你不会立刻发现,因为它是一份看起来能打开的旧库。

正确做法是用 SQLite 自己的备份能力,一条 SQL 就够:

VACUUM INTO 'records-20261030-141200.db';

VACUUM INTO 需要 SQLite 3.27 以上,它会生成一个完整、自洽、单文件的副本,WAL 里的内容也在里面。顺带的好处是整理碎片,产出的文件通常比原库小一圈。

备份放哪、留几份:我用 app.getPath('userData')/backups,文件名带日期,只留最近三份。迁移成功之后不删这份备份 —— 磁盘上多几十兆换一个「随时能退回去」,很划算。用户报问题的时候第一句话就是「把最近那个备份发我」。

迁到一半失败怎么办

整个迁移在事务里,出错自动回滚,库还是迁移前的样子。这是 SQLite 比很多数据库省事的地方:DDL 也是事务性的,CREATE TABLE、DROP TABLE、ALTER TABLE 都能回滚。(MySQL 那边 DDL 是隐式提交,做不到这一点。)

回滚之后要做的是:把备份留在原地,告诉用户「数据没丢,请升级到最新版本」,然后退出。

不要做的事:

1. 不要自动重试。同一个迁移在同一个库上再跑一次,会在同一个地方再失败一次,只是多浪费几秒。

2. 不要 catch 了继续往下跑。跳过失败的迁移,后面的迁移会基于错误的结构执行,产出一个「结构介于两版之间」的库。这种东西比旧库难修得多,因为谁也不知道它到底在哪一版。

3. 不要自动删库重建。那是开发机上的做法,用户那边的数据是唯一的。

还有一种失败不是报错,是太慢。整表重建一个事务握着写锁,十万行以内通常几百毫秒,用户感知不到;百万行会到几秒,窗口就白屏了。这种量级要把迁移放到后台,界面上给一个进度,并且明确告诉用户别关应用。我现在的量级还在一万行以下,所以这条只是留了个钩子,没真做过 —— 如果你要做,记得把「迁移中」这个状态写进库或者写进一个标记文件,因为迁移期间崩溃的话,下次启动要知道自己没迁完。

怎么测这条迁移链

迁移这种东西平时不跑,只有在用户升级那一次跑。所以它必须被测试覆盖,而且测法跟普通业务逻辑不一样。

我的做法是造化石:把 v1 的建表 SQL 原样抄一份进测试 fixture,之后再也不改它。

// fixtures/schema-v1.sql —— 这个文件是化石,永远不要改
CREATE TABLE records (
  id    INTEGER PRIMARY KEY,
  path  TEXT NOT NULL,
  size  INTEGER,
  mtime INTEGER
);
INSERT INTO records VALUES (1, 'C:\\a.txt', 100, 1700000000);

测试流程:用这份 SQL 建一个空库、插几条数据、跑完整的 migrate()、然后断言四件事 ——

1. user_version 等于 TARGET。

2. 每一列都在(PRAGMA table_info 拿列名列表,跟期望的集合比)。

3. 老数据还在,而且内容被正确搬过去了(path 拆成了 dir + name,拆得对不对)。

4. 索引都在(PRAGMA index_list)。

这份 fixture 的价值全在「不改」上。哪天你顺手把它更新成了最新结构,这条测试就变成「最新建最新」,什么都测不出来,而且看不出来它已经失效了。

再加一条负向测试:拿一个 user_version 比 TARGET 大的库进去,断言它会抛错而不是继续跑。

最后说一句我觉得最容易被低估的地方:schema 版本是应用的一部分,跟 package.json 里的版本号一样该被认真对待。它不是数据库的内部细节,是「这个文件能不能被这个版本的应用读」的那个契约。