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,
// ...
};
}
三个点:
getColumnIndex()返回 -1 代表列不存在。 按理说迁移跑完列一定在,但"按理说"不该出现在读数据这条路上。isColumnNull()得单独判断 —— 列在但值是 NULL 是另一回事。- 兜底值和迁移的默认值,语义必须对上。
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 条单元测试,覆盖的都是纯函数(统计、趋势、计算逻辑),迁移一条都没覆盖到。
所以我的验证方式是真机手工升级测试,步骤固定:
- 装上一个已发布版本,留一些真实数据
- 直接覆盖安装新版本(包名不变)
- 启动后逐项检查:老记录能不能正常打开、新功能读写新字段是否正常、列表和统计把老数据算对了没
- 再卸载、重装一次新版本,确认全新安装也正常
第 4 步是我特意加的 —— 前面说过,全新安装时 ALTER 会抛错,走的是完全不同的分支。只测升级不测新装,等于只测了一半。
另外测试数据我会故意造得脏一点:空字符串、超长文本、只有一半字段有值。迁移最容易暴露问题的就是这些边缘数据。
写在最后
三句话总结:
CREATE TABLE IF NOT EXISTS不会给已存在的表加列。 表结构变更必须写显式迁移,改了建表语句不等于改了老用户的库。- 版本号 + 幂等 ALTER + 读取侧兜底,三件套缺一不可;默认值按业务语义定,不是随手写 0。
- 全新安装和覆盖升级是两条不同的路径,都得测。
这套机制从我项目的 v3 撑到 v6,三次表结构变更(加标记列、加账本感受列、加标签列),覆盖安装和全新安装我都验证过,没有出现过数据丢失或者升级报错。
如果你的变更涉及破坏性操作(改类型、拆表、回填),那就要升级成严格的分步迁移。但只要还停留在"加列 / 加表",上面这套足够用,而且更好维护。
代码开源在 github.com/D-sp-wq/moo…(MIT 协议),迁移相关代码在 entry/src/main/ets/services/storage/DatabaseHelper.ets,欢迎交流。