鸿蒙数据库高级迁移与版本兼容:Schema版本管理/增量迁移脚本/向前向后兼容/无感知升级方案
·


一、前置思考
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 代码 | 降级兼容 |
| 迁移中断恢复 | 模拟崩溃重试 |
六、高阶总结与最佳实践
- 版本登记是地基:数据库文件持久化版本号,代码维护有序增量脚本数组。
- 增量而非全量:每个版本一个脚本,从任意旧版本都能顺迁移。
- 每步一个事务:单步失败只回滚当前步,不破坏已完成的迁移。
- 防御式写法:IF NOT EXISTS、DEFAULT 值、列存在性检查,保证幂等。
- 无感知升级:DDL 前台快迁、数据改造后台分批、失败自动回滚+监控上报。
一句话记住:版本号登记在库、增量脚本有序重放、每步独立事务、字段全部带默认值、大表分批后台迁——升级无感知,失败能回滚。
更多推荐



所有评论(0)