应用迭代,给本地 RDB 数据库加个字段或者新建个表再正常不过。我见过一个极其惨烈的线上事故,某个版本的更新之后,大批用户反馈“我的收藏全没了”、“笔记全清空了”。

排查下来,发现开发同学在 onUpgrade 里为了图省事,直接写了 DROP TABLE,然后重新 CREATE TABLE。测试阶段因为都是干净的安装包,没人发现问题;一上线,存量用户一升级,数据直接灰飞烟灭。

关系型数据库(RDB)的版本管理,不是简单地改个 version 数字,它涉及存量数据的无损迁移。搞不好,就是生产事故。

识别 onUpgrade 的触发机制

在鸿蒙里操作 RDB,通常用到 relationalStore。初始化数据库时,你需要传入一个 StoreConfig,里面有个 version 属性。

typescript

import { relationalStore } from '@kit.ArkData';

const STORE_CONFIG: relationalStore.StoreConfig = {
  name: 'NoteBook.db',
  securityLevel: relationalStore.SecurityLevel.S1,
  version: 2 // 从 1 升到了 2
};

// 模拟初始化
async function initDb(context: Context) {
  const store = await relationalStore.getRdbStore(context, STORE_CONFIG);
  
  // 如果 store.version 从 1 变成了 2,系统会自动尝试触发升级逻辑
  if (store.version === 2) {
    await migrateV1ToV2(store);
  }
}

关键点在于 version。当用户的设备上已经存在 NoteBook.db,且版本是 1。你发版将代码里的 version 改成 2。用户覆盖安装打开应用时,getRdbStore 会发现版本不一致。

但这里有一个鸿蒙独有的坑:如果你在 StoreConfig 里配置了 version,系统会自动将数据库的版本号更新为你指定的值,但这只是改了版本号,它不会自动帮你执行表结构变更。如果你没有任何迁移代码,直接去查询新加的字段,立刻就会报 no such column 的错误。

用事务保证迁移的原子性

数据迁移最怕的是什么?迁移到一半,应用崩溃了,或者手机没电了。如果迁移逻辑没有事务保护,用户的数据库就会变成一个半新半旧的“缝合怪”,既不能用旧逻辑查,也不能用新逻辑查。

正确的做法,是把所有的表结构修改和数据搬运,全部包在 RDB 的事务里。要么全成功,要么全回滚。

typescript

async function migrateV1ToV2(store: relationalStore.RdbStore): Promise<void> {
  // 开启事务
  store.beginTransaction();
  try {
    // 1. 创建新表
    await store.executeSql(
      'CREATE TABLE IF NOT EXISTS NoteV2 (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, content TEXT, tags TEXT)'
    );
    
    // 2. 把旧表数据搬过来
    await store.executeSql(
      'INSERT INTO NoteV2 (title, content) SELECT title, content FROM Note'
    );
    
    // 3. 删除旧表
    await store.executeSql('DROP TABLE Note');
    
    // 4. 重命名新表
    await store.executeSql('ALTER TABLE NoteV2 RENAME TO Note');
    
    // 提交事务
    store.commit();
  } catch (e) {
    // 发生异常,回滚所有操作,保证旧数据完好无损
    store.rollBack();
    console.error(`迁移失败,数据已回滚: ${e.message}`);
  }
}

这段代码是这次升级的核心。beginTransaction 开启事务后,所有 executeSql 的变更都在内存和日志中记录。只有走到 store.commit(),系统才会真正落盘。一旦中间出现异常被 catch 捕获,调用 store.rollBack(),数据库就会瞬间回到升级前的状态,用户的数据绝不会丢。

常见错误是没写 rollBack(),或者把 commit() 写在了 try 块的中间,导致后面的 SQL 如果失败,前面已经提交的部分无法撤销。

降级与数据备份兜底

除了升级,还有一种极其隐蔽的崩溃场景:降级。有些用户可能通过三方渠道安装了你之前的旧版本。如果旧版本代码读取到了高版本留下的数据库(因为旧版本代码里没有新表结构),一跑就崩。

对于这种情况,在初始化数据库时,一定要判断版本号。

typescript

if (store.version > STORE_CONFIG.version) {
  // 说明发生了降级,只能把旧库删掉,重建一个空库兜底
  // 虽然丢数据,但总比一直崩溃强
  await relationalStore.deleteRdbStore(context, 'NoteBook.db');
  store = await relationalStore.getRdbStore(context, STORE_CONFIG);
}

同时,在执行高危迁移之前,最稳妥的方式是做一次文件级的备份。把 NoteBook.db 复制到一个临时目录,如果迁移彻底失败,还可以尝试引导用户恢复。

做本地存储开发,一定要有一个共识:用户的数据无价。不要相信“测试没问题线上就没问题”,存量数据往往包含了各种奇奇怪怪的脏数据。用 try-catch 包好你的迁移逻辑,用事务守住最后一道底线,这才是合格的数据守卫者。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐