在这里插入图片描述
在这里插入图片描述

一、前置思考

1.1 数据库升级是最容易翻车的发布

App 迭代必然改表:加字段、改字段类型、拆表、合表。数据库升级翻车案例比比皆是:

翻车1: v1 直接 ALTER TABLE 加 NOT NULL 字段, 老用户升级后所有历史行报错
翻车2: 升级脚本顺序写错, v2→v3 的脚本依赖 v3 的字段
翻车3: 升级到一半崩溃, 数据库损坏, 用户数据全丢
翻车4: 老版本 App 打开新版本数据库 → 直接崩溃 (向后兼容缺失)

1.2 版本管理的本质

数据库迁移的本质是:在不同 Schema 版本之间,无损失、可回滚、并发安全地转换数据。核心挑战:

挑战 说明
版本演进 每个版本对应一个 Schema 快照
增量迁移 vN → vN+1 只跑增量脚本
向前兼容 新版本 App 能读老数据
向后兼容 老版本 App 能读新库(降级)
无感知 升级不阻塞、不丢数据

1.3 本文路线

给出版本登记表、增量迁移脚本框架、兼容性策略、无感知升级与回滚完整方案。

二、核心原理

2.1 Schema 版本演进模型

v1: CREATE TABLE user(id, name)
    │ 迁移脚本 M1: ALTER TABLE user ADD COLUMN age INTEGER
    ▼
v2: CREATE TABLE user(id, name, age)
    │ 迁移脚本 M2: CREATE TABLE order(...)
    ▼
v3: CREATE TABLE user(id, name, age) + order 表

关键设计:数据库文件里持久化版本号(PRAGMA user_version 或自建 meta 表),启动时对比目标版本与当前版本,顺序执行中间缺失的迁移脚本。

2.2 增量迁移脚本框架

迁移脚本 = 有序数组, 下标即目标版本

MIGRATIONS = [
  { to: 2, sql: 'ALTER TABLE user ADD COLUMN age INTEGER DEFAULT 0' },
  { to: 3, sql: 'CREATE TABLE IF NOT EXISTS order(...)' },
  { to: 4, sql: 'ALTER TABLE user ADD COLUMN vip INTEGER DEFAULT 0' },
]

升级流程:
  current = 1 (库内版本)
  target = 4 (代码版本)
  for v in (current+1 .. target):
    执行 MIGRATIONS[v-2].sql   (按序)
  最后更新库内版本 = target

2.3 为什么不能只做"从1升到N"

  • 用户可能从 v1、v2、v3 任意版本升级(老版本一直没更新的用户);
  • 只写"最终形态"脚本,中间版本用户无法迁移;
  • 增量脚本必须可重放、幂等(IF NOT EXISTS / 防御式写法)。

2.4 向前/向后兼容

向前兼容 (新 App 读旧库):
  新 App 启动 → 检测到库版本旧 → 自动执行迁移 → 升级到新版本
  → 必须保证迁移脚本正确, 否则老用户升级后崩溃

向后兼容 (旧 App 读新库):
  用户降级/回滚到旧 App → 旧代码不认识新字段
  → 防御: 新字段带 DEFAULT / 可空; 查询用列名而非 SELECT *
  → 理想: 新旧版本共享同一份兼容 Schema 子集

三、源码/API 深度解析

3.1 鸿蒙 RelationalStore 版本迁移

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

interface Migration {
  toVersion: number;
  sqls: string[];
}

// 版本迁移表 (有序)
const MIGRATIONS: Migration[] = [
  { toVersion: 2, sqls: ['ALTER TABLE user ADD COLUMN age INTEGER DEFAULT 0'] },
  { toVersion: 3, sqls: ['CREATE TABLE IF NOT EXISTS "order"(' +
    'id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, amount REAL)'] },
  { toVersion: 4, sqls: ['ALTER TABLE user ADD COLUMN vip INTEGER DEFAULT 0',
    'CREATE INDEX IF NOT EXISTS idx_user_vip ON user(vip)'] }
];

const CURRENT_VERSION = 4;

async function openWithMigration(context: common.Context): Promise<relationalStore.RdbStore> {
  const config: relationalStore.StoreConfig = {
    name: 'app.db',
    securityLevel: relationalStore.SecurityLevel.S1,
    // 关键: 声明当前版本号, 系统在打开时触发版本变化
  };
  const rdb = await relationalStore.getRdbStore(context, config);
  await rdb.executeSql('PRAGMA user_version = 1');  // 首次建库标记版本

  // 手动迁移框架 (示例: 通过 meta 表记录版本)
  const current = await getDbVersion(rdb);
  for (const m of MIGRATIONS) {
    if (m.toVersion > current && m.toVersion <= CURRENT_VERSION) {
      rdb.beginTransaction();
      try {
        for (const sql of m.sqls) { await rdb.executeSql(sql); }
        await setDbVersion(rdb, m.toVersion);   // 每个增量单独提交, 防半途崩溃
        rdb.commit();
      } catch (e) {
        rdb.rollBack();
        throw e;
      }
    }
  }
  return rdb;
}

async function getDbVersion(rdb: relationalStore.RdbStore): Promise<number> {
  const rs = await rdb.querySql('PRAGMA user_version');
  let v = 1;
  if (rs.goToFirstRow()) { v = rs.getLong(0); }
  rs.close();
  return v;
}

async function setDbVersion(rdb: relationalStore.RdbStore, v: number): Promise<void> {
  await rdb.executeSql(`PRAGMA user_version = ${v}`);
}

3.2 防御式迁移写法

// 幂等: 重复执行不报错
const SAFE_SQLS = [
  'ALTER TABLE user ADD COLUMN age INTEGER DEFAULT 0',   // 若列已存在会报错
  // 防御写法: 先查列是否存在
];

async function addColumnIfNotExists(rdb: relationalStore.RdbStore,
  table: string, column: string, ddl: string): Promise<void> {
  const rs = await rdb.querySql(`PRAGMA table_info(${table})`);
  let exists = false;
  while (rs.goToNextRow()) {
    if (rs.getString(rs.getColumnIndex('name')) === column) { exists = true; break; }
  }
  rs.close();
  if (!exists) { await rdb.executeSql(ddl); }
}

3.3 无感知升级(启动时 + 后台)

// 策略: 冷启动时快速迁移小表; 大表数据改造放后台 TaskPool
async function upgradeSmart(context: common.Context): Promise<void> {
  const rdb = await relationalStore.getRdbStore(context, {
    name: 'app.db', securityLevel: relationalStore.SecurityLevel.S1
  });

  // 阶段1: 快速 Schema 迁移 (毫秒级, 主线程可接受)
  // 阶段2: 数据搬运/清洗 (秒级, 后台线程)
  // 阶段3: 版本号更新 + 标记完成
  await migrateSchema(rdb);       // DDL 为主
  startBackgroundDataTransform(); // 异步数据改造
}

四、企业级实战落地

4.1 迁移流水线架构

发布流程:
  代码升级 MIGRATIONS 数组 → 灰度发布 → 老用户启动触发迁移
  → 迁移失败自动回滚上一版本 → 上报错误 → 修复后重发

启动流程:
  open 数据库
    ├─ 版本 == 目标 → 正常使用
    ├─ 版本 < 目标 → 顺序执行增量迁移 (每步单独事务)
    │                 失败 → 回滚到迁移前版本 → 保留现场
    └─ 版本 > 目标 (降级) → 只读兼容模式 或 提示更新

4.2 数据改造迁移(非 DDL)

// 示例: v3→v4 把 user 表拆出 profile 表
async function migrateSplitTable(rdb: relationalStore.RdbStore): Promise<void> {
  // 1. 建新表
  await rdb.executeSql('CREATE TABLE IF NOT EXISTS user_profile(' +
    'user_id INTEGER PRIMARY KEY, avatar TEXT, bio TEXT)');
  // 2. 旧数据搬迁 (分批避免锁长事务)
  const rs = await rdb.querySql('SELECT id, avatar, bio FROM user');
  const batch: relationalStore.ValuesBucket[] = [];
  while (rs.goToNextRow()) {
    batch.push({
      user_id: rs.getLong(rs.getColumnIndex('id')),
      avatar: rs.getString(rs.getColumnIndex('avatar')),
      bio: rs.getString(rs.getColumnIndex('bio'))
    } as relationalStore.ValuesBucket);
    if (batch.length >= 500) {
      await rdb.batchInsert('user_profile', batch);
      batch.length = 0;
    }
  }
  if (batch.length > 0) { await rdb.batchInsert('user_profile', batch); }
  rs.close();
}

4.3 向后兼容防御

// 旧版本 App 读新库: 避免 SELECT * 依赖列顺序
// 正确: 显式列名 + 容错处理
const rs = await rdb.querySql('SELECT id, name, age FROM user');
// 新字段 age 在旧 App 中不存在 → 旧 App 不会 select age, 不受影响
// 但旧 App 的 INSERT 不带新字段 → 新字段必须有 DEFAULT

// 新表创建用 IF NOT EXISTS, 列定义全部带默认值

4.4 迁移监控与回滚

阶段 监控点 失败动作
Schema 迁移 每步耗时/失败率 回滚上一步 + 上报
数据改造 进度/一致性 断点续做 (幂等)
版本更新 成功/失败 失败不改版本号
升级后验证 关键查询回归 异常 → 修复重发

五、问题排查与性能优化

现象 原因 解决
老用户升级崩溃 打开就崩 迁移脚本有 bug 单步事务 + 回滚
NOT NULL 报错 加列失败 老数据无值 带 DEFAULT
升级一半中断 库版本错乱 多步无事务 每步单独提交
重复执行报错 迁移重放失败 脚本不幂等 IF NOT EXISTS
大表迁移卡死 升级卡 10 秒 长事务锁 分批迁移
降级崩溃 旧 App 打不开 新字段无默认值 全字段 DEFAULT
数据丢失 迁移后少了 搬迁逻辑漏 迁移前后 count 校验

5.1 迁移前备份

// 大版本迁移前备份数据库文件, 失败可恢复
import { fileIo as fs } from '@kit.CoreFileKit';

function backupDb(dbPath: string): string {
  const bakPath = dbPath + '.bak_' + Date.now();
  fs.copyFileSync(dbPath, bakPath);
  return bakPath;   // 迁移失败时用备份恢复
}

5.2 灰度与开关

功能开关 (Feature Flag):
  新迁移代码默认关闭 → 白名单用户开启 → 验证稳定后全量
  失败 → 关开关 → 用户走旧路径

5.3 版本兼容矩阵测试

场景 测试
v1 → v4 老老用户直升
v2 → v4 中间版本
v3 → v4 相邻版本
v4 → v4 同版本重开
v5 库 + v4 代码 降级兼容
迁移中断恢复 模拟崩溃重试

六、高阶总结与最佳实践

  1. 版本登记是地基:数据库文件持久化版本号,代码维护有序增量脚本数组。
  2. 增量而非全量:每个版本一个脚本,从任意旧版本都能顺迁移。
  3. 每步一个事务:单步失败只回滚当前步,不破坏已完成的迁移。
  4. 防御式写法:IF NOT EXISTS、DEFAULT 值、列存在性检查,保证幂等。
  5. 无感知升级:DDL 前台快迁、数据改造后台分批、失败自动回滚+监控上报。

一句话记住:版本号登记在库、增量脚本有序重放、每步独立事务、字段全部带默认值、大表分批后台迁——升级无感知,失败能回滚。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐