在这里插入图片描述

每日一句正能量

河流遇到礁石会改变流向,却也因此激荡出更美丽的浪花。
阻碍可以成为风景。礁石没有消灭河流,只是让它改变了形态——产生了声音、形状和生命力。一帆风顺的河流是平静的,甚至乏味的。生命的美感往往来自与阻力的碰撞。

导读

本文以一个从旧版本升级到 HarmonyOS 6.1 的本地关系型数据库改造为背景,完整记录 Schema 变更、迁移脚本、降级兼容、数据一致性校验与灰度发布方案。示例使用 ArkTS 风格伪代码与 SQL,具体接口签名请以项目所使用的 HarmonyOS SDK 与 ArkData 官方文档为准。

一、为什么数据库升级比“加一列”复杂得多

在课堂项目或早期原型中,我们通常直接修改建表语句,然后重新安装应用验证功能。可一旦应用已经发布,用户设备中保存的是不同历史版本的真实数据库:有人从 V1 升级,有人从 V2 升级,也有人首次安装 V3。此时,数据库升级不再是一次简单的 DDL 修改,而是一条必须覆盖多种入口状态的迁移链。

我在一次 HarmonyOS 6.1 适配中遇到的典型问题是:新版本需要给用户表增加状态字段,同时把较大的个人资料字段拆分到独立表。开发环境清库后运行正常,但灰度用户出现启动失败、旧数据丢失、部分记录重复等问题。复盘后发现,问题集中在四个方面:

  1. 只维护“最新建表 SQL”,没有维护 V1→V2、V2→V3 的增量脚本。
  2. Schema 变更与数据回填没有放在同一个事务中。
  3. 新代码立即依赖新字段,导致迁移未完成时就开始查询。
  4. 发布策略只有“全量或撤回安装包”,没有数据库层面的止损开关。

数据库升级的核心目标不是“脚本跑完”,而是做到四件事:数据不丢、过程可重入、失败可回滚、版本可观测

二、案例背景:从 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 类数据库对“修改字段约束”的支持通常有限,直接把可空字段改成非空字段可能需要重建表,因此线上升级更适合采用“先扩展、后收缩”的策略:

  1. 先增加可空字段。
  2. 新旧代码同时兼容字段为空。
  3. 后台或迁移脚本完成回填。
  4. 观察一个发布周期。
  5. 再考虑收紧约束或重建表。

六、坑四:旧代码无法读取新结构,降级后直接崩溃

移动端应用被覆盖安装后,数据库通常不会自动恢复到旧版本。即使应用商店撤回新包,已经升级数据库的用户也可能重新安装旧包。旧代码若只认识 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;
}

九、灰度升级:数据库变更必须配发布开关

数据库脚本在测试机上通过,不等于能覆盖真实世界的旧数据。建议采用四阶段灰度:

  1. 离线样本测试:保存 V1、V2 的脱敏数据库样本,自动执行所有升级路径。
  2. 内部与小流量测试:先覆盖内部账号和约 1% 用户。
  3. 扩大灰度:观察 24 小时后逐步扩大到 10%、30%。
  4. 全量发布:保持远程开关和只读降级能力,持续观察至少一个版本周期。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

下载灰度升级与数据一致性校验图

建议关注的指标

  • 数据库打开失败率。
  • 单次迁移成功率与 P50/P95 耗时。
  • 升级后首次冷启动耗时。
  • 数据缺失率、重复率、孤儿记录数。
  • 与数据库相关的崩溃率和异常码分布。
  • 迁移完成后业务关键指标是否突变。

止损开关怎么设计

数据库迁移本身通常在本地执行,无法完全依赖服务端,但可以通过远程配置控制高风险功能:

interface DatabaseFeatureFlags {
  enableProfileTableRead: boolean;
  enableProfileDualWrite: boolean;
  enableBackgroundBackfill: boolean;
}

推荐发布顺序:

  1. 先发布“建表 + 双写”,读取仍走旧字段。
  2. 确认迁移成功率后,灰度开启新表读取。
  3. 出现异常时关闭新表读取,继续保留双写。
  4. 稳定后停止旧字段写入,最后才考虑删除旧字段。

这就是典型的“扩展—迁移—切流—收缩”模型,比一次性修改可靠得多。

十、性能优化:避免升级时卡住首屏

数据库升级往往发生在应用首次打开阶段。如果一次迁移数十万条记录,用户会感知明显卡顿,甚至触发系统无响应。可以按数据规模选择策略:

小数据量:一次事务完成

记录少、字段简单时,一次事务最容易保证原子性。

中等数据量:分批迁移并记录游标

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
欢迎 👍点赞✍评论⭐收藏,欢迎指正

Logo

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

更多推荐