友友们,早上好。在 HarmonyOS 应用迭代过程中,随着业务需求变化,本地数据库表结构调整(如新增字段、修改索引、拆分表)成为常态。然而,数据库迁移是一把 "双刃剑"—— 操作不当可能导致用户数据丢失,直接影响用户信任与应用口碑。本文基于 HarmonyOS RDB(关系型数据库)特性,从迁移原则、实现方案到失败处理,完整拆解一套安全可靠的数据库迁移体系,助力开发者平稳应对应用版本迭代中的数据结构变更。

一、RDB 数据库迁移的核心挑战与设计原则

首先,我们在动手编写迁移代码前,需要先明确数据库迁移的核心目标:数据不丢失、应用不崩溃、用户无感知。围绕这一目标,需遵循四大核心原则:

1. 版本控制:给数据库一个 "身份标识"

数据库版本是迁移的基础 —— 每次表结构变更时,将数据库版本号递增(如从 v1→v2→v3),通过 "旧版本→新版本" 的差值,精准执行对应迁移步骤。避免无版本管理导致的 "不知道该执行哪些变更" 问题。

HarmonyOS RDB 通过StoreConfigversion字段定义当前数据库版本,当应用启动时,若本地数据库版本低于当前版本,自动触发MigrationCallback迁移回调。

2. 增量迁移:拒绝 "一刀切" 的全量重建

全量重建数据库(删除旧库→创建新库)虽简单,但会直接丢失用户数据,绝对不可取。正确的做法是增量迁移:针对每个版本间的差异,编写独立迁移脚本(如 v1→v2 新增字段、v2→v3 修改索引),仅执行必要的结构变更,最大程度保留数据。

例如从 v1 到 v3 的迁移,需先执行 v1→v2 的脚本,再执行 v2→v3 的脚本,而非直接编写 v1→v3 的跨版本脚本 —— 后者会导致从 v2 升级到 v3 时遗漏关键步骤。

3. 事务保护:迁移要么全成,要么全退

数据库迁移是 "原子操作":若中间步骤失败(如新增字段时磁盘空间不足),必须回滚到迁移前状态,避免出现 "表结构残缺" 的半损坏状态。HarmonyOS RDB 支持事务机制,可将迁移操作包裹在事务中,通过beginTransaction()commit()rollback()实现原子性保障。

4. 备份优先:给数据加一道 "安全锁"

即使有事务保护,极端场景下仍可能出现迁移失败(如数据库文件损坏)。因此,迁移前必须备份关键数据—— 可将核心表复制到临时备份表,或导出为 SQL 文件存储在应用私有目录,为迁移失败后的恢复提供基础。

二、实战:HarmonyOS RDB 迁移完整实现方案

下面以一个实际场景为例:应用从 v1 迭代到 v3,需完成 "v1→v2 新增 user 表 age 字段"、"v2→v3 为 user 表 name 字段添加索引" 的迁移需求,完整实现从版本管理到失败回滚的全流程。

1. 基础准备:初始化 RDB 与版本配置

首先定义数据库基本信息(名称、版本),并通过MigrationCallback注册迁移回调,确保版本升级时自动触发迁移逻辑。

import relationalStore from '@ohos.data.relationalStore';
import { Context } from '@ohos.ability.uiAbility';

// 数据库核心配置
const DB_CONFIG = {
  NAME: 'user_center.db',       // 数据库名称
  CURRENT_VERSION: 3,           // 当前应用使用的数据库版本(v3)
  SECURITY_LEVEL: relationalStore.SecurityLevel.S1  // 安全级别(根据需求调整)
};

/**
 * 初始化RDB并自动执行迁移
 * @param context 应用上下文
 */
export async function initRDBWithMigration(context: Context): Promise<relationalStore.RdbStore | null> {
  try {
    const storeConfig: relationalStore.StoreConfig = {
      name: DB_CONFIG.NAME,
      securityLevel: DB_CONFIG.SECURITY_LEVEL,
      version: DB_CONFIG.CURRENT_VERSION,
      // 迁移回调:旧版本<当前版本时触发
      migration: {
        onUpgrade: async (db, oldVersion, newVersion) => {
          console.log(`[迁移启动] 从v${oldVersion}升级到v${newVersion}`);
          // 执行增量迁移逻辑
          await executeIncrementalMigration(db, oldVersion, newVersion);
        }
      }
    };

    // 获取RDB实例(若版本不匹配,自动触发迁移)
    const rdbStore = await relationalStore.getRdbStore(context, storeConfig);
    console.log('[迁移结果] 数据库初始化与迁移成功');
    return rdbStore;
  } catch (error) {
    console.error('[迁移失败] 数据库初始化异常:', error);
    // 提示用户迁移失败(如弹窗告知)
    showMigrationErrorAlert();
    // 极端情况:删除损坏数据库(谨慎使用,需先提示用户备份)
    // await relationalStore.deleteRdbStore(context, DB_CONFIG.NAME);
    return null;
  }
}

2. 核心实现:增量迁移脚本与事务保护

增量迁移的关键是 "按版本差执行步骤"—— 每个版本变更独立封装,确保从任意旧版本升级到最新版本时,都能完整执行所有必要变更。同时,所有操作必须在事务中执行,保障原子性。

步骤 1:增量迁移入口(按版本差分发任务)

/**
 * 增量迁移:按版本差执行对应迁移脚本
 * @param db RDB实例
 * @param oldVersion 旧数据库版本
 * @param newVersion 新数据库版本
 */
async function executeIncrementalMigration(
  db: relationalStore.RdbStore,
  oldVersion: number,
  newVersion: number
): Promise<void> {
  // 版本1→2:新增user表age字段
  if (oldVersion < 2) {
    await migrateV1ToV2(db);
  }
  // 版本2→3:为user表name字段添加索引
  if (oldVersion < 3) {
    await migrateV2ToV3(db);
  }
  // 后续版本迭代:新增if (oldVersion < X) 分支即可
}

步骤 2:v1→v2 迁移(新增字段)

新增字段时需注意:必须设置默认值或允许为 NULL,否则旧版本应用写入数据时会因 "字段缺失" 报错。同时,迁移前先备份表数据,失败时可恢复。

/**
 * v1→v2迁移:为user表新增age字段(默认值0,避免NULL)
 * @param db RDB实例
 */
async function migrateV1ToV2(db: relationalStore.RdbStore): Promise<void> {
  let backupTableName: string | null = null; // 备份表名

  // 开启事务:所有操作要么全成,要么全退
  await db.beginTransaction();
  try {
    // 1. 备份user表(迁移失败时用于恢复)
    backupTableName = `user_backup_${Date.now()}`;
    await db.executeSql(`CREATE TABLE ${backupTableName} AS SELECT * FROM user`);
    console.log(`[v1→v2] 已备份user表至${backupTableName}`);

    // 2. 执行迁移:新增age字段(默认值0)
    const alterSql = `ALTER TABLE user ADD COLUMN age INTEGER DEFAULT 0`;
    await db.executeSql(alterSql);
    console.log(`[v1→v2] 已为user表新增age字段`);

    // 3. 验证迁移结果(确保字段创建成功)
    await verifyV1ToV2Migration(db);

    // 4. 迁移成功:提交事务+删除备份表
    await db.commit();
    await db.executeSql(`DROP TABLE ${backupTableName}`);
    console.log(`[v1→v2] 迁移成功`);
  } catch (error) {
    // 迁移失败:回滚事务+从备份恢复数据
    await db.rollback();
    console.error(`[v1→v2] 迁移失败,开始恢复备份:`, error);
    
    if (backupTableName) {
      await restoreFromBackup(db, 'user', backupTableName);
    }
    throw error; // 抛出错误,终止后续迁移
  }
}

/**
 * 验证v1→v2迁移结果:检查age字段是否存在
 * @param db RDB实例
 */
async function verifyV1ToV2Migration(db: relationalStore.RdbStore): Promise<void> {
  // PRAGMA table_info(表名):查询表结构(返回字段ID、名称、类型等)
  const result = await db.querySql(`PRAGMA table_info(user)`);
  const columns = result.getAllRows();

  // 检查是否存在age字段(字段名在第二列,索引为1)
  const hasAgeColumn = columns.some(row => row[1] === 'age');
  if (!hasAgeColumn) {
    throw new Error(`[v1→v2] 迁移验证失败:age字段未创建`);
  }
}

步骤 3:v2→v3 迁移(修改索引)

修改索引时需注意 "幂等性"—— 先删除旧索引(若存在),再创建新索引,避免重复执行导致 "索引已存在" 错误。

/**
 * v2→v3迁移:为user表name字段添加新索引(删除旧索引)
 * @param db RDB实例
 */
async function migrateV2ToV3(db: relationalStore.RdbStore): Promise<void> {
  await db.beginTransaction();
  try {
    // 1. 先删除旧索引(若存在),确保幂等性
    await db.executeSql(`DROP INDEX IF EXISTS idx_user_name_old`);
    console.log(`[v2→v3] 已删除旧索引idx_user_name_old`);

    // 2. 创建新索引(优化name字段查询性能)
    const createIndexSql = `CREATE INDEX idx_user_name_new ON user(name)`;
    await db.executeSql(createIndexSql);
    console.log(`[v2→v3] 已创建新索引idx_user_name_new`);

    // 3. 验证索引是否存在
    await verifyV2ToV3Migration(db);

    // 4. 提交事务
    await db.commit();
    console.log(`[v2→v3] 迁移成功`);
  } catch (error) {
    await db.rollback();
    console.error(`[v2→v3] 迁移失败:`, error);
    throw error;
  }
}

/**
 * 验证v2→v3迁移结果:检查新索引是否存在
 * @param db RDB实例
 */
async function verifyV2ToV3Migration(db: relationalStore.RdbStore): Promise<void> {
  // PRAGMA index_list(表名):查询表的所有索引
  const result = await db.querySql(`PRAGMA index_list(user)`);
  const indexes = result.getAllRows();

  // 检查是否存在新索引(索引名在第二列,索引为1)
  const hasNewIndex = indexes.some(row => row[1] === 'idx_user_name_new');
  if (!hasNewIndex) {
    throw new Error(`[v2→v3] 迁移验证失败:新索引未创建`);
  }
}

3. 安全保障:备份与恢复工具函数

迁移前的备份是 "最后一道防线",需封装通用的备份与恢复逻辑,确保在迁移失败时能快速恢复数据。

/**
 * 备份表数据:创建临时备份表
 * @param db RDB实例
 * @param sourceTable 源表名
 * @returns 备份表名
 */
async function backupTable(
  db: relationalStore.RdbStore,
  sourceTable: string
): Promise<string> {
  const backupTableName = `${sourceTable}_backup_${Date.now()}`;
  // CREATE TABLE 备份表 AS SELECT * FROM 源表:复制表结构与数据
  await db.executeSql(`CREATE TABLE ${backupTableName} AS SELECT * FROM ${sourceTable}`);
  return backupTableName;
}

/**
 * 从备份恢复数据:覆盖源表
 * @param db RDB实例
 * @param sourceTable 源表名
 * @param backupTableName 备份表名
 */
async function restoreFromBackup(
  db: relationalStore.RdbStore,
  sourceTable: string,
  backupTableName: string
): Promise<void> {
  await db.beginTransaction();
  try {
    // 1. 清空源表数据
    await db.executeSql(`DELETE FROM ${sourceTable}`);
    // 2. 从备份表复制数据到源表
    await db.executeSql(`INSERT INTO ${sourceTable} SELECT * FROM ${backupTableName}`);
    // 3. 删除备份表
    await db.executeSql(`DROP TABLE ${backupTableName}`);
    await db.commit();
    console.log(`[恢复成功] 已从${backupTableName}恢复${sourceTable}表数据`);
  } catch (error) {
    await db.rollback();
    console.error(`[恢复失败] 从${backupTableName}恢复${sourceTable}表数据异常:`, error);
    throw error;
  }
}

4. 异常处理:迁移失败的用户告知

当迁移失败且无法自动恢复时,需通过弹窗等方式告知用户,提供 "手动导出数据" 或 "联系客服" 等备选方案,避免用户因数据问题投诉。

/**
 * 显示迁移失败弹窗
 */
function showMigrationErrorAlert(): void {
  // 此处使用HarmonyOS UI组件实现弹窗(如AlertDialog)
  // 示例代码(需结合具体UI框架):
  // AlertDialog.show({
  //   title: '数据库升级失败',
  //   message: '您的本地数据可能无法正常访问,建议导出数据后重启应用',
  //   buttons: [
  //     { text: '导出数据', action: () => exportUserData() },
  //     { text: '稍后处理', action: () => {} }
  //   ]
  // });
}

/**
 * 导出用户数据(备选方案)
 */
async function exportUserData(): Promise<void> {
  // 实现数据导出逻辑(如将关键表数据导出为JSON文件,存储到应用沙箱目录)
  console.log('开始导出用户数据...');
}

三、进阶优化:应对复杂场景的最佳实践

1. 大表迁移:避免阻塞主线程

对于数据量超过 10 万条的大表,CREATE TABLE AS SELECT会导致主线程阻塞,影响应用响应。此时需分批次迁移:

  • 先创建新表结构;
  • 通过LIMIT + OFFSET分批次查询旧表数据,插入新表;
  • 迁移完成后删除旧表,重命名新表。
// 大表分批次迁移示例
async function migrateLargeTable(db: relationalStore.RdbStore) {
  const batchSize = 1000; // 每批次迁移1000条数据
  let offset = 0;

  // 1. 创建新表结构
  await db.executeSql(`CREATE TABLE user_new (id INTEGER PRIMARY KEY, name TEXT, age INTEGER DEFAULT 0)`);

  while (true) {
    // 2. 分批次查询旧表数据
    const result = await db.querySql(`SELECT * FROM user_old LIMIT ${batchSize} OFFSET ${offset}`);
    const rows = result.getAllRows();

    if (rows.length === 0) break; // 无数据,迁移完成

    // 3. 批量插入新表
    const values = rows.map(row => `(${row[0]}, '${row[1]}', ${row[2]})`).join(',');
    await db.executeSql(`INSERT INTO user_new (id, name, age) VALUES ${values}`);

    offset += batchSize;
    console.log(`已迁移${offset}条数据`);
  }

  // 4. 替换旧表
  await db.executeSql(`DROP TABLE user_old`);
  await db.executeSql(`ALTER TABLE user_new RENAME TO user`);
}

2. 版本兼容:新旧应用共存的过渡方案

若应用支持 "多版本同时运行"(如用户未升级,但后台同步使用新版本数据),需确保表结构变更兼容旧版本:

  • 新增字段:必须设置默认值或允许为 NULL;
  • 删除字段:需先确保旧版本应用不再读取该字段;
  • 修改字段类型:优先使用 "兼容类型"(如 INT→BIGINT,兼容旧数据)。

3. 测试覆盖:模拟所有迁移路径

迁移代码必须经过充分测试,覆盖以下场景:

  • 从所有历史版本升级到最新版本(如 v1→v3、v2→v3);
  • 迁移过程中中断(如应用崩溃、设备断电);
  • 磁盘空间不足、权限不足等异常场景。

四、总结

HarmonyOS RDB 数据库迁移的核心并非 "编写 SQL 语句",而是构建一套 "安全可控的变更体系"—— 通过版本控制精准定位变更范围,用事务保障原子性,靠备份抵御极端风险。我们作为开发者在实际迭代中需牢记:数据安全高于一切,任何迁移操作前必须经过充分测试,避免因小失大导致用户数据丢失。经过测试这个策略,可确保应用在迭代过程中,既能快速响应业务需求调整表结构,又能保障用户数据零丢失,为应用的长期稳定运营奠定基础。好了,本期分享就到这里,希望对大家有所帮助。我们下期见!

 

Logo

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

更多推荐