ArkTS 如何给 SQLite 做版本迁移:3 次迭代不丢数据的做法

1 阅读10分钟

ArkTS 如何给 SQLite 做版本迁移:3 次迭代不丢数据的做法

我的鸿蒙应用「心情手记」在华为应用市场上架之后,前后改过三次表结构。这篇文章不讲概念,就讲我当时具体怎么写的、为什么这么写、以及哪儿差点出事。

如果你也在做鸿蒙应用、也用了 RDB(SQLite),那这套东西你迟早要用上 —— 因为应用一旦发布,数据库就不是你想改就能改的了。


一、我最开始踩的那个坑

给「每日记录」加字段的时候,我干了这么一件事:把新字段直接写进建表语句里。

CREATE TABLE IF NOT EXISTS daily_logs (
  id TEXT PRIMARY KEY,
  date TEXT NOT NULL UNIQUE,
  mood INTEGER NOT NULL,
  journal TEXT,
  plan TEXT
);

然后我发现不对劲:新装的用户一切正常,但覆盖安装旧版本之后,那个新字段永远是空的。

盯了半天才反应过来 —— CREATE TABLE IF NOT EXISTS 的意思是「表已存在就什么都不做」。对老用户来说,我改的那句建表语句等于没写,新字段压根没被创建。

最坑的地方是它不报错。 建表成功、启动成功、日志干干净净,只有在真正读写那个字段的时候才出问题。不是崩溃,是"悄悄不对"——这种 bug 最难查。

所以结论就一条:表结构变更必须靠显式的迁移语句,改建表语句没用。


二、我的做法:一个版本号 + 一段补列的代码

整个机制其实就两块。

1. 一个常量,代表"这份代码期望的库长什么样"

export class DatabaseHelper {
  /** 数据库结构版本。每次改表结构都要 +1,并在 migrate() 里补上对应语句。 */
  private static readonly DB_VERSION: number = 6;
  // ...
}

那句注释是写给我自己看的。半年后我肯定会忘记"加个字段还要改这个数",注释就是防这个。

2. 启动时:先建表,再迁移

async init(context: common.Context): Promise<void> {
  if (this.store) {
    return;
  }
  const config: relationalStore.StoreConfig = {
    name: Constants.DB_NAME,
    securityLevel: relationalStore.SecurityLevel.S1
  };
  this.store = await relationalStore.getRdbStore(context, config);
  await this.createTables();   // 全新安装:一步到位建出最新结构
  await this.migrate();        // 老用户升级:把缺的列补上
  // ...
}

这里是我踩坑之后想明白的最重要一点:有两条完全不同的路径,都得照顾到。

用户实际发生了什么
全新安装createTables() 直接建出最新结构(已经含所有新列);migrate() 里的 ALTER 会因为"列已存在"而抛错,吞掉就行
覆盖升级表早就存在,createTables() 什么也没干;全靠 migrate() 把缺的列补上

我踩的那个坑,本质就是只照顾了第一条路。反过来只写迁移、忘了更新建表语句,新用户那边又会缺字段。两条路都得测,缺一条就是隐患。


三、三次真实的表结构变更

store.version 是 RDB 自带的版本号。判断和写回是这样:

private async migrate(): Promise<void> {
  const store = this.getStore();
  if (store.version >= DatabaseHelper.DB_VERSION) {
    return;   // 已经是最新结构,直接返回
  }

  // …… 各个版本的 ALTER ……

  store.version = DatabaseHelper.DB_VERSION;   // 全部成功后再写回
}

下面是这三次变更的实际语句,以及我当时为什么这么定。

第 1 次:v3 → v4,给记录加一个"是不是真记录"的标记

背景:我做了个「给未来某天安排要做的事」的功能。实现上会给那一天建一条记录,但这条记录只有计划,没有心情和正文。

问题来了:心情字段是非空枚举、有默认值。所以「只写了计划的存根」和「用户认真写的记录」,光看内容分不出来。结果就是这些存根会被算进"连续记录天数"、还会在日历上染色 —— 数据就假了。

唯一的办法是显式标记:

try {
  // 老数据全都是正常记录,所以默认 1;只有"只写了计划的存根"才是 0
  await store.executeSql('ALTER TABLE daily_logs ADD COLUMN has_entry INTEGER DEFAULT 1');
} catch (err) {
  // 列已存在,无需处理
}

这里 DEFAULT 1 是我整个迁移里最当心的一个字符。

因为老数据在语义上全都是"真记录",默认值必须是 1。如果我偷懒不写默认值,老数据这一列就是 NULL,读取的时候还得靠代码兜 —— 又凭空多一处可能出错的地方。能在 DDL 里把语义表达清楚,就别留给运行时代码。

第 2 次:v4 → v5,给账本加"这笔花得值不值"

背景:账本里每笔支出可以标「值得 / 一般 / 后悔」,用来算一个月的「值得指数」。

try {
  // 账本"感受":老账目一律未标记
  await store.executeSql('ALTER TABLE ledger_records ADD COLUMN feeling INTEGER DEFAULT 0');
} catch (err) {
  // 表或列不存在(例如还未记账过),无需处理
}

注意这次默认值是 0 = 未标记,跟上一个正好相反。

默认值该写什么,是看业务语义定的,不是随手写 0:

新增列老数据应该被当成什么默认值
has_entry老记录都是真记录1
feeling老账目都没标过感受0(未标记)

而且这个默认值会直接影响统计结果 —— 我的「值得指数」计算会跳过未标记的账目。如果默认值给错(比如给成"值得"),老用户一升级,指数立刻被一堆假数据拉歪。加列的时候一定要顺手想一遍:这列会不会参与统计?

第 3 次:v5 → v6,给记录加标签

标签我用 JSON 字符串存一列,跟项目里已有的"照片列表"是同一套做法:

try {
  // 标签(JSON 字符串):老数据为 NULL,读取时兜底为空数组
  await store.executeSql('ALTER TABLE daily_logs ADD COLUMN tags TEXT');
} catch (err) {
  // 列已存在,无需处理
}

这次我故意没写 DEFAULT '[]',让它保持 NULL,在读取的时候兜成空数组。

原因:标签是"集合"语义,空集合用 [] 还是 NULL 表达都说得通;但我写入的时候本来就会把它序列化成空串(跟照片列表保持一致),所以读取侧统一兜底更不容易分叉。

每次 ALTER 我都套了 try/catch,并注释"列已存在" —— 这不是偷懒。因为全新安装的建表语句里已经有这一列了,那条 ALTER 必然抛错,这是预期行为。


四、光迁移还不够:读取侧必须兜底

迁移只解决了"列在不在",没解决"列里的值对不对"。读取的时候两种情况都得防:列不存在(索引返回 -1)、值为 NULL。

这是我实际的映射代码:

private mapDailyLog(rs: relationalStore.ResultSet): DailyLog {
  // custom_mood / photo_uris / has_entry 都是后加的列:
  // 老数据里为 NULL,列不存在时索引为 -1,都要兜底
  const customIndex: number = rs.getColumnIndex('custom_mood');
  const photoIndex: number = rs.getColumnIndex('photo_uris');
  const entryIndex: number = rs.getColumnIndex('has_entry');

  return {
    id: rs.getString(rs.getColumnIndex('id')),
    date: rs.getString(rs.getColumnIndex('date')),
    customMood: (customIndex < 0 || rs.isColumnNull(customIndex))
      ? '' : rs.getString(customIndex),
    photoUris: (photoIndex < 0 || rs.isColumnNull(photoIndex))
      ? [] : parsePhotoUris(rs.getString(photoIndex)),
    // 迁移前的老记录都是正常记录
    hasEntry: (entryIndex < 0 || rs.isColumnNull(entryIndex))
      ? true : rs.getLong(entryIndex) === 1,
    // ...
  };
}

三个点:

  1. getColumnIndex() 返回 -1 代表列不存在。 按理说迁移跑完列一定在,但"按理说"不该出现在读数据这条路上。
  2. isColumnNull() 得单独判断 —— 列在但值是 NULL 是另一回事。
  3. 兜底值和迁移的默认值,语义必须对上。 has_entry 查不到时我给 true,跟 DEFAULT 1 是同一个意思。这两处要是对不上,就会出现"迁移前的老数据算一套、迁移后算另一套"的怪现象。

五、四个我觉得最容易踩的坑

坑 1:把迁移写成"严格逐级升版本"

我的 migrate() 其实是**"把所有已知的 ALTER 都跑一遍,失败的忽略"**,而不是严格的 if (version == 3) ... else if (version == 4) ...。

这么写简单、幂等,少一个判断就少一个 bug。代价是:它只能应付"加列"这种可以重复执行的变更。

如果你要做的是破坏性变更(改列类型、拆表、数据回填、改名),就必须写成严格的分步迁移,保证每步只跑一次。而且 SQLite 本身对 ALTER COLUMN 支持很有限,很多时候得走"建新表 → 拷数据 → 删旧表 → 改名"。

先看清自己的变更属于哪一类,再决定用哪种写法。

坑 2:store.version 什么时候写回

我写在所有 ALTER 之后:

store.version = DatabaseHelper.DB_VERSION;

这样如果中途真有哪条 ALTER 失败了(不是"列已存在",是真失败),版本号不会被写回,下次启动会重跑一遍。

我宁可让它靠重复执行收敛,也不要留一个"记着做完了、实际没做完"的状态。

坑 3:新增"表"其实不用写迁移

这一点我一开始想复杂了。看建表那里的注释:

// 账本:与每日记录完全独立。新增"表"不需要迁移语句,
// 因为建表用的是 IF NOT EXISTS,每次启动都会补齐。

CREATE TABLE IF NOT EXISTS 对已存在的表确实什么都不做,但对不存在的表是会正常创建的。所以新增一张表,只要加进建表列表就行,老用户下次启动自然就有了。

这大概是 IF NOT EXISTS 唯一的用武之地 —— 它救不了"加列",但"加表"免费帮你搞定。

坑 4:别在迁移里做耗时操作

migrate() 是在应用启动路径上的。加列本身很快(SQLite 的 ADD COLUMN 基本只是改元数据),但如果你顺手在里面做数据回填(比如"给所有历史记录补一个默认标签"),几万条数据就会把启动卡住。

结构变更和数据回填要分开:结构在启动时同步做完,回填丢到后台任务或者做成惰性的。


六、怎么验证迁移真的没问题

说句实话:这类代码很难用单元测试覆盖,因为它依赖真实的 RdbStore。我项目里有 276 条单元测试,覆盖的都是纯函数(统计、趋势、计算逻辑),迁移一条都没覆盖到。

所以我的验证方式是真机手工升级测试,步骤固定:

  1. 装上一个已发布版本,留一些真实数据
  2. 直接覆盖安装新版本(包名不变)
  3. 启动后逐项检查:老记录能不能正常打开、新功能读写新字段是否正常、列表和统计把老数据算对了没
  4. 再卸载、重装一次新版本,确认全新安装也正常

第 4 步是我特意加的 —— 前面说过,全新安装时 ALTER 会抛错,走的是完全不同的分支。只测升级不测新装,等于只测了一半。

另外测试数据我会故意造得脏一点:空字符串、超长文本、只有一半字段有值。迁移最容易暴露问题的就是这些边缘数据。


写在最后

三句话总结:

  1. CREATE TABLE IF NOT EXISTS 不会给已存在的表加列。 表结构变更必须写显式迁移,改了建表语句不等于改了老用户的库。
  2. 版本号 + 幂等 ALTER + 读取侧兜底,三件套缺一不可;默认值按业务语义定,不是随手写 0。
  3. 全新安装和覆盖升级是两条不同的路径,都得测。

这套机制从我项目的 v3 撑到 v6,三次表结构变更(加标记列、加账本感受列、加标签列),覆盖安装和全新安装我都验证过,没有出现过数据丢失或者升级报错。

如果你的变更涉及破坏性操作(改类型、拆表、回填),那就要升级成严格的分步迁移。但只要还停留在"加列 / 加表",上面这套足够用,而且更好维护。


代码开源在 github.com/D-sp-wq/moo…(MIT 协议),迁移相关代码在 entry/src/main/ets/services/storage/DatabaseHelper.ets,欢迎交流。