【共创季稿事节】HarmonyOS 6.1 数据库升级踩坑:数据迁移与版本兼容性处理
文章目录

每日一句正能量
河流遇到礁石会改变流向,却也因此激荡出更美丽的浪花。
阻碍可以成为风景。礁石没有消灭河流,只是让它改变了形态——产生了声音、形状和生命力。一帆风顺的河流是平静的,甚至乏味的。生命的美感往往来自与阻力的碰撞。
导读
本文以一个从旧版本升级到 HarmonyOS 6.1 的本地关系型数据库改造为背景,完整记录 Schema 变更、迁移脚本、降级兼容、数据一致性校验与灰度发布方案。示例使用 ArkTS 风格伪代码与 SQL,具体接口签名请以项目所使用的 HarmonyOS SDK 与 ArkData 官方文档为准。
一、为什么数据库升级比“加一列”复杂得多
在课堂项目或早期原型中,我们通常直接修改建表语句,然后重新安装应用验证功能。可一旦应用已经发布,用户设备中保存的是不同历史版本的真实数据库:有人从 V1 升级,有人从 V2 升级,也有人首次安装 V3。此时,数据库升级不再是一次简单的 DDL 修改,而是一条必须覆盖多种入口状态的迁移链。
我在一次 HarmonyOS 6.1 适配中遇到的典型问题是:新版本需要给用户表增加状态字段,同时把较大的个人资料字段拆分到独立表。开发环境清库后运行正常,但灰度用户出现启动失败、旧数据丢失、部分记录重复等问题。复盘后发现,问题集中在四个方面:
- 只维护“最新建表 SQL”,没有维护 V1→V2、V2→V3 的增量脚本。
- Schema 变更与数据回填没有放在同一个事务中。
- 新代码立即依赖新字段,导致迁移未完成时就开始查询。
- 发布策略只有“全量或撤回安装包”,没有数据库层面的止损开关。
数据库升级的核心目标不是“脚本跑完”,而是做到四件事:数据不丢、过程可重入、失败可回滚、版本可观测。
二、案例背景:从 V1 演进到 V3
本文示例数据库名为 app_data.db。
1. V1:单表保存用户资料
CREATE TABLE IF NOT EXISTS user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
profile_json TEXT,
created_at INTEGER NOT NULL
);
2. V2:增加用户状态与索引
业务新增“正常、停用、待完善”三种状态,需要增加 status 字段,并为列表筛选增加索引。
ALTER TABLE user ADD COLUMN status INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_user_status ON user(status);
3. V3:拆分个人资料表
由于 profile_json 体积不断增大,用户列表查询不应再扫描大字段,因此把资料拆到 user_profile 表。
CREATE TABLE IF NOT EXISTS user_profile (
user_id INTEGER PRIMARY KEY,
profile_json TEXT NOT NULL DEFAULT '{}',
migrated_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES user(id)
);
数据库版本图如下:

下载数据库版本图
这里有一个重要原则:首次安装应直接创建 V3 的最终结构;历史用户升级则按 V1→V2→V3 顺序执行。 不要让首次安装也重复执行全部迁移脚本,否则会增加复杂度和测试成本。
三、坑一:只判断目标版本,跨版本用户直接失败
很多代码只写成下面这样:
if (oldVersion < 3) {
await migrateToV3(store);
}
看似覆盖了旧版本,实际隐藏了一个问题:migrateToV3() 如果默认 status 字段已经存在,那么 V1 用户直接调用时就会失败。可靠做法是把升级拆成连续步骤,并明确每一步的前置版本。
async function upgradeDatabase(
store: relationalStore.RdbStore,
oldVersion: number,
newVersion: number
): Promise<void> {
let current = oldVersion;
if (current < 2 && newVersion >= 2) {
await migrateV1ToV2(store);
current = 2;
}
if (current < 3 && newVersion >= 3) {
await migrateV2ToV3(store);
current = 3;
}
if (current !== newVersion) {
throw new Error(`数据库升级路径不完整:${oldVersion} -> ${newVersion}`);
}
}
这种写法有三个优点:
- 每个脚本职责单一,便于单元测试。
- 可验证所有历史路径,而不是只测“上一个版本”。
- 将来新增 V4 时,只需增加 V3→V4,不必修改旧脚本。
四、坑二:DDL 成功了,数据回填却失败了
V2→V3 的迁移包含“建表”和“复制数据”。如果建表成功后应用被杀死,而复制尚未完成,下次启动可能发现新表已存在,于是误判迁移成功。解决方法是把 Schema 变更、数据回填、校验和迁移日志写入同一事务。
async function migrateV2ToV3(
store: relationalStore.RdbStore
): Promise<void> {
await store.beginTransaction();
try {
await store.executeSql(`
CREATE TABLE IF NOT EXISTS user_profile (
user_id INTEGER PRIMARY KEY,
profile_json TEXT NOT NULL DEFAULT '{}',
migrated_at INTEGER NOT NULL
)
`);
const now = Date.now();
await store.executeSql(`
INSERT OR IGNORE INTO user_profile(user_id, profile_json, migrated_at)
SELECT id, COALESCE(profile_json, '{}'), ${now}
FROM user
`);
await verifyV3Migration(store);
await store.executeSql(`
INSERT OR REPLACE INTO migration_log(
version, status, finished_at
) VALUES (3, 'SUCCESS', ${now})
`);
await store.commit();
} catch (error) {
await store.rollBack();
throw new Error(`V2→V3 迁移失败:${String(error)}`);
}
}
迁移流程如下:

下载迁移脚本执行流程图
为什么使用 INSERT OR IGNORE
迁移脚本应尽量具备幂等性。若应用在提交前异常退出,事务会回滚;若某些厂商环境或业务补偿流程导致脚本再次执行,INSERT OR IGNORE 可以避免主键重复。幂等不代表忽略所有错误,而是让“重复执行同一安全步骤”不会破坏数据。
五、坑三:默认值设计不当,旧数据语义被改变
新增非空字段时,最常见做法是:
ALTER TABLE user ADD COLUMN status INTEGER NOT NULL DEFAULT 0;
但必须确认 0 的业务含义。假设新系统中 0 表示“待完善”,那么所有历史用户都会被错误标记。更稳妥的方式是分两步:
ALTER TABLE user ADD COLUMN status INTEGER;
UPDATE user SET status = 1 WHERE status IS NULL;
待所有历史数据回填完成后,再由业务层保证新写入不为空。SQLite 类数据库对“修改字段约束”的支持通常有限,直接把可空字段改成非空字段可能需要重建表,因此线上升级更适合采用“先扩展、后收缩”的策略:
- 先增加可空字段。
- 新旧代码同时兼容字段为空。
- 后台或迁移脚本完成回填。
- 观察一个发布周期。
- 再考虑收紧约束或重建表。
六、坑四:旧代码无法读取新结构,降级后直接崩溃
移动端应用被覆盖安装后,数据库通常不会自动恢复到旧版本。即使应用商店撤回新包,已经升级数据库的用户也可能重新安装旧包。旧代码若只认识 V2,而数据库已经是 V3,就会出现“代码降级、数据不降级”的不对称状态。
方案一:优先做向前兼容,而不是执行破坏性降级
V3 建立 user_profile 表后,暂时保留 user.profile_json 字段,不立即删除。新代码写入时同时更新新表和旧字段:
async function saveProfile(
store: relationalStore.RdbStore,
userId: number,
profileJson: string
): Promise<void> {
await store.beginTransaction();
try {
await store.executeSql(
`INSERT OR REPLACE INTO user_profile(user_id, profile_json, migrated_at)
VALUES (?, ?, ?)`,
[userId, profileJson, Date.now()]
);
// 兼容旧版本应用读取。
await store.executeSql(
`UPDATE user SET profile_json = ? WHERE id = ?`,
[profileJson, userId]
);
await store.commit();
} catch (error) {
await store.rollBack();
throw error;
}
}
双写会增加成本,但在灰度阶段很有价值。等新版本覆盖率足够高、回滚窗口关闭后,再通过后续版本停止双写。
方案二:增加兼容视图
如果旧查询只依赖固定字段,可以建立视图,向旧逻辑提供稳定结构:
CREATE VIEW IF NOT EXISTS user_compat AS
SELECT
u.id,
u.name,
COALESCE(p.profile_json, u.profile_json, '{}') AS profile_json,
u.created_at,
u.status
FROM user u
LEFT JOIN user_profile p ON p.user_id = u.id;
方案三:拒绝危险降级
当检测到数据库版本高于代码支持版本时,不要擅自删表或清库。应进入受控状态,例如提示用户升级应用、仅开放只读功能,并上报版本信息。
function assertDatabaseCompatible(dbVersion: number): void {
const maxSupportedVersion = 3;
if (dbVersion > maxSupportedVersion) {
throw new Error(
`当前应用最高支持数据库 V${maxSupportedVersion},实际为 V${dbVersion}`
);
}
}
七、建立迁移日志:让线上问题可定位
建议在 V1 就预置迁移日志表,或在首次需要迁移时补建。
CREATE TABLE IF NOT EXISTS migration_log (
version INTEGER PRIMARY KEY,
status TEXT NOT NULL,
started_at INTEGER,
finished_at INTEGER,
error_code TEXT,
app_version TEXT
);
每次迁移至少记录:
- 起始数据库版本与目标版本。
- 应用版本、系统版本、设备类型。
- 迁移开始时间与结束时间。
- 执行结果、错误码、失败步骤。
- 迁移前后记录数摘要。
日志中不要写入姓名、手机号、Token、完整业务内容等敏感信息。需要排查具体记录时,应使用匿名化 ID 或哈希摘要。
八、数据一致性校验:不要只检查“SQL 没报错”
迁移成功必须有可计算的验收标准。V2→V3 至少应检查以下内容。
1. 行数校验
SELECT COUNT(*) AS user_count FROM user;
SELECT COUNT(*) AS profile_count FROM user_profile;
若每个用户都必须有资料记录,则两个数量应一致;如果允许空资料,则应明确差异范围。
2. 缺失记录校验
SELECT COUNT(*) AS missing_count
FROM user u
LEFT JOIN user_profile p ON p.user_id = u.id
WHERE p.user_id IS NULL;
3. 重复与孤儿记录校验
SELECT user_id, COUNT(*) AS c
FROM user_profile
GROUP BY user_id
HAVING c > 1;
SELECT COUNT(*) AS orphan_count
FROM user_profile p
LEFT JOIN user u ON u.id = p.user_id
WHERE u.id IS NULL;
4. 内容摘要校验
对大字段逐条比较成本较高,可以计算业务摘要。例如统计空对象数量、资料平均长度,或抽样比较哈希。不要只比较文件大小,因为数据库页复用、索引和日志都可能影响文件体积。
interface MigrationCheckResult {
userCount: number;
profileCount: number;
missingCount: number;
orphanCount: number;
invalidStatusCount: number;
}
function isMigrationValid(r: MigrationCheckResult): boolean {
return r.userCount === r.profileCount
&& r.missingCount === 0
&& r.orphanCount === 0
&& r.invalidStatusCount === 0;
}
九、灰度升级:数据库变更必须配发布开关
数据库脚本在测试机上通过,不等于能覆盖真实世界的旧数据。建议采用四阶段灰度:
- 离线样本测试:保存 V1、V2 的脱敏数据库样本,自动执行所有升级路径。
- 内部与小流量测试:先覆盖内部账号和约 1% 用户。
- 扩大灰度:观察 24 小时后逐步扩大到 10%、30%。
- 全量发布:保持远程开关和只读降级能力,持续观察至少一个版本周期。

下载灰度升级与数据一致性校验图
建议关注的指标
- 数据库打开失败率。
- 单次迁移成功率与 P50/P95 耗时。
- 升级后首次冷启动耗时。
- 数据缺失率、重复率、孤儿记录数。
- 与数据库相关的崩溃率和异常码分布。
- 迁移完成后业务关键指标是否突变。
止损开关怎么设计
数据库迁移本身通常在本地执行,无法完全依赖服务端,但可以通过远程配置控制高风险功能:
interface DatabaseFeatureFlags {
enableProfileTableRead: boolean;
enableProfileDualWrite: boolean;
enableBackgroundBackfill: boolean;
}
推荐发布顺序:
- 先发布“建表 + 双写”,读取仍走旧字段。
- 确认迁移成功率后,灰度开启新表读取。
- 出现异常时关闭新表读取,继续保留双写。
- 稳定后停止旧字段写入,最后才考虑删除旧字段。
这就是典型的“扩展—迁移—切流—收缩”模型,比一次性修改可靠得多。
十、性能优化:避免升级时卡住首屏
数据库升级往往发生在应用首次打开阶段。如果一次迁移数十万条记录,用户会感知明显卡顿,甚至触发系统无响应。可以按数据规模选择策略:
小数据量:一次事务完成
记录少、字段简单时,一次事务最容易保证原子性。
中等数据量:分批迁移并记录游标
CREATE TABLE IF NOT EXISTS migration_checkpoint (
task_name TEXT PRIMARY KEY,
last_id INTEGER NOT NULL,
status TEXT NOT NULL
);
每次处理 500~2000 条,提交后更新 last_id。下次启动从检查点继续。批大小应通过真机测试确定,不应照搬固定数值。
大数据量:前台完成最小升级,后台渐进回填
前台只创建新表、索引和兼容结构,确保应用可用;大字段复制放到后台任务中。读取时采用“新表优先、旧字段兜底”,直到回填完成。
async function loadProfile(
store: relationalStore.RdbStore,
userId: number
): Promise<string> {
const newValue = await queryProfileTable(store, userId);
if (newValue !== undefined) {
return newValue;
}
return await queryLegacyProfile(store, userId) ?? '{}';
}
十一、测试矩阵:至少覆盖这些场景
建议在 CI 或回归测试中维护以下矩阵:
| 场景 | 初始状态 | 预期结果 |
|---|---|---|
| 首次安装 | 无数据库 | 直接创建 V3 |
| 常规升级 | V2 完整数据库 | 成功迁移到 V3 |
| 跨版本升级 | V1 完整数据库 | 顺序执行 V1→V2→V3 |
| 空数据库 | V1 结构、无数据 | 正常迁移 |
| 大数据量 | V2,十万级记录 | 耗时可控、无 ANR |
| 异常中断 | 回填中强制退出 | 下次可恢复或事务回滚 |
| 磁盘空间不足 | 可用空间极低 | 明确报错、不破坏旧数据 |
| 脏数据 | 非法状态、空字段 | 校验失败并记录原因 |
| 应用降级 | V3 数据库 + V2 代码 | 进入兼容或受控拒绝状态 |
| 重复执行 | 已完成 V3 后再触发 | 结果不变、无重复数据 |
测试时不要只构造“完美旧库”。线上最难处理的往往是历史 Bug 留下的脏数据,例如空字符串、重复业务键、半完成记录和异常时间戳。
十二、可复用的迁移管理器
下面给出一个适合项目化封装的骨架:
type Migration = {
from: number;
to: number;
run: (store: relationalStore.RdbStore) => Promise<void>;
};
class DatabaseMigrationManager {
private readonly migrations: Migration[] = [
{ from: 1, to: 2, run: migrateV1ToV2 },
{ from: 2, to: 3, run: migrateV2ToV3 }
];
async migrate(
store: relationalStore.RdbStore,
oldVersion: number,
targetVersion: number
): Promise<void> {
if (oldVersion > targetVersion) {
throw new Error('检测到数据库降级,禁止自动执行破坏性操作');
}
let current = oldVersion;
while (current < targetVersion) {
const step = this.migrations.find(item => item.from === current);
if (!step) {
throw new Error(`缺少从 V${current} 开始的迁移脚本`);
}
await step.run(store);
current = step.to;
}
if (current !== targetVersion) {
throw new Error(`迁移终点异常:V${current}`);
}
}
}
实际项目还应补充锁机制,避免多个 Ability 或并发任务同时启动迁移;同时对错误进行分类,例如 SQL 语法错误、磁盘不足、数据校验失败和版本路径缺失。只有明确分类,线上告警才有行动价值。
十三、校企合作课堂中的教学建议
数据库迁移非常适合作为综合实训题,因为它同时考查 ArkTS、SQL、事务、异常处理、测试设计和发布工程。课堂中可以给学生一份 V1 数据库和业务需求,让小组完成 V3 迁移,并设置以下验收项:
- 不允许清库重建。
- 必须支持 V1、V2 两种升级入口。
- 必须提供失败回滚与迁移日志。
- 必须编写数据一致性校验。
- 必须提交灰度方案和回滚说明。
- 必须在真机上记录迁移耗时。
这种训练比单纯实现增删改查更接近企业研发,也能帮助毕业生在面试中讲清楚“如何保证线上数据安全”。
十四、结语
HarmonyOS 6.1 适配中的数据库升级,真正困难的不是某条 SQL,而是如何管理版本状态和失败边界。一个可靠方案应坚持以下原则:
- Schema 变更按连续版本拆分,不跨级猜测。
- DDL、数据回填、校验和日志尽量处于同一事务。
- 迁移脚本具备幂等性和明确的前置条件。
- 优先采用向前兼容,谨慎处理应用降级。
- 用数据指标验证迁移,而不是只看“没有报错”。
- 通过灰度、双写、读取开关和兼容视图降低发布风险。
- 旧字段的删除属于最后一步,而不是第一步。
数据库承载的是用户长期积累的数据。对开发者而言,升级脚本只运行几秒;对用户而言,那可能是几年记录能否被安全保留。把迁移做成工程能力,而不是一次性补丁,才是 HarmonyOS 应用从“能运行”走向“可长期维护”的关键。
转载自:https://blog.csdn.net/u014727709/article/details/162993987
欢迎 👍点赞✍评论⭐收藏,欢迎指正
更多推荐

所有评论(0)