HarmonyOS RDB 数据迁移实战:版本、表结构、回滚与校验

应用升级最怕数据库结构变化。开发机上重新安装一切正常,老用户覆盖升级后却出现表不存在、字段为空、列表打不开。RDB 迁移的核心不是写几条 ALTER TABLE,而是知道当前库在哪个版本、应该逐级执行哪些脚本、执行失败后如何阻止脏数据继续运行。本文把 HarmonyOS 关系型数据库迁移拆成版本计划、迁移脚本、事务执行、结构校验和失败恢复。

请添加图片描述

本文会解决:

  1. 数据库版本如何和表结构绑定。
  2. 为什么迁移要逐级执行,不能只写最新版 SQL。
  3. 迁移后如何确认表和字段符合预期。
  4. 失败时如何记录证据并给用户稳定兜底。

1. 数据库迁移先定版本计划

关系型数据适合保存可查询、可更新、有结构的数据,例如草稿、路线记录、离线任务、订单缓存。每次表结构变化都应该对应一个版本号和一组迁移动作。

请添加图片描述

版本 变化 风险
v1 创建基础表 首次安装
v2 增加 status 字段 老数据默认值
v3 增加索引 查询性能和重复索引
v4 拆分扩展表 数据搬迁和回滚

版本计划要写在代码里,也要写在发布说明里。否则过几个月再看迁移脚本,很难判断哪条 SQL 对应哪个需求。

2. RDB 资料边界和工程目录

HarmonyOS 提供关系型数据库能力,适合结构化数据持久化。工程上建议把表结构、迁移执行和校验分开。

资料入口 工程落点
关系型数据库概述 选择 RDB 存储结构化数据
RelationalStore API 参考 创建、执行 SQL、查询和事务能力
数据持久化方案选择 区分 RDB、Preferences、文件存储

建议目录:

entry/src/main/ets/
  common/db/SchemaPlan.ets
  common/db/MigrationRunner.ets
  common/db/SchemaVerifier.ets
  common/db/RollbackLog.ets
  common/db/RouteRecordRepository.ets

业务仓库只关心数据读写,迁移模块负责把库升级到可用状态。

3. SchemaPlan 固定目标结构

目标结构要明确到表、字段、索引。不要只把 SQL 写在迁移里,否则无法判断最终结构是否正确。

export interface ColumnSpec {
  name: string
  type: 'INTEGER' | 'TEXT' | 'REAL' | 'BLOB'
  required: boolean
}

export interface TableSpec {
  name: string
  columns: ColumnSpec[]
  indexes: string[]
}

export const RouteTableSpec: TableSpec = {
  name: 'route_record',
  columns: [
    { name: 'id', type: 'TEXT', required: true },
    { name: 'title', type: 'TEXT', required: true },
    { name: 'distance', type: 'REAL', required: true },
    { name: 'status', type: 'INTEGER', required: true },
    { name: 'updated_at', type: 'INTEGER', required: true }
  ],
  indexes: ['idx_route_updated_at', 'idx_route_status']
}

结构计划是迁移后的验收标准。脚本可以很多,但最终都要落到这个结构。

4. 迁移脚本要逐级执行

用户可能从 v1 升到 v4,也可能从 v3 升到 v4。迁移器必须根据当前版本逐级执行缺失脚本。

export interface MigrationStep {
  from: number
  to: number
  statements: string[]
}

export const RouteMigrations: MigrationStep[] = [
  {
    from: 1,
    to: 2,
    statements: ['ALTER TABLE route_record ADD COLUMN status INTEGER DEFAULT 0']
  },
  {
    from: 2,
    to: 3,
    statements: ['CREATE INDEX IF NOT EXISTS idx_route_updated_at ON route_record(updated_at)']
  },
  {
    from: 3,
    to: 4,
    statements: ['CREATE INDEX IF NOT EXISTS idx_route_status ON route_record(status)']
  }
]

逐级迁移的好处是路径清晰。不要写一个“如果没有字段就添加”的大杂烩,否则问题发生时很难还原用户升级路径。

5. MigrationRunner 执行脚本并记录版本

迁移执行要有目标版本、当前版本和失败记录。真实项目中应配合事务能力,确保部分 SQL 失败时不会留下半升级状态。

interface SqlExecutor {
  execute(sql: string): Promise<void>
  getVersion(): Promise<number>
  setVersion(version: number): Promise<void>
}

export class MigrationRunner {
  constructor(private readonly db: SqlExecutor) {}

  async migrate(targetVersion: number): Promise<void> {
    let current = await this.db.getVersion()
    while (current < targetVersion) {
      const step = RouteMigrations.find(item => item.from === current)
      if (!step) {
        throw new Error(`缺少迁移脚本:${current} -> ${current + 1}`)
      }
      for (const sql of step.statements) {
        await this.db.execute(sql)
      }
      await this.db.setVersion(step.to)
      current = step.to
    }
  }
}

这段代码把版本推进放在脚本执行之后。只有脚本执行成功,才更新版本号,避免下次启动误以为已经完成迁移。

6. SchemaVerifier 校验表结构

迁移完成后不能直接进入业务页。至少要确认关键表存在、关键字段存在、版本号正确。

请添加图片描述

export interface ExistingTableInfo {
  tableName: string
  columns: string[]
  indexes: string[]
}

export class SchemaVerifier {
  verify(spec: TableSpec, actual: ExistingTableInfo): string[] {
    const errors: string[] = []
    if (actual.tableName !== spec.name) {
      errors.push(`表名不匹配:${actual.tableName}`)
    }
    for (const column of spec.columns) {
      if (!actual.columns.includes(column.name)) {
        errors.push(`缺少字段:${column.name}`)
      }
    }
    for (const index of spec.indexes) {
      if (!actual.indexes.includes(index)) {
        errors.push(`缺少索引:${index}`)
      }
    }
    return errors
  }
}

结构校验不需要覆盖所有数据,但必须覆盖关键字段。否则业务页面第一次查询时才发现问题,用户体验会更差。

7. 失败恢复要阻止脏启动

迁移失败时,不要继续打开依赖数据库的页面。可以进入恢复页,提示用户稍后重试,同时记录失败版本、SQL 和错误信息。

export interface MigrationFailure {
  fromVersion: number
  targetVersion: number
  failedSql?: string
  message: string
  occurredAt: number
}

export class RollbackLog {
  private failures: MigrationFailure[] = []

  append(failure: Omit<MigrationFailure, 'occurredAt'>): void {
    this.failures.push({ ...failure, occurredAt: Date.now() })
  }

  latest(): MigrationFailure | undefined {
    return this.failures[this.failures.length - 1]
  }
}

失败记录是排查入口。没有它,线上用户只会看到“数据异常”,开发无法知道卡在哪个版本。

8. 仓库层只接收可用数据库

业务仓库不要自己判断迁移。应用启动时应先完成迁移和结构校验,再把可用数据库交给仓库层。

export interface RouteRecord {
  id: string
  title: string
  distance: number
  status: number
  updatedAt: number
}

export class RouteRecordRepository {
  constructor(private readonly db: SqlExecutor) {}

  async save(record: RouteRecord): Promise<void> {
    await this.db.execute(
      `INSERT OR REPLACE INTO route_record(id,title,distance,status,updated_at)
       VALUES('${record.id}','${record.title}',${record.distance},${record.status},${record.updatedAt})`
    )
  }
}

示例重点是分层:仓库层假设数据库已经可用,迁移问题不要扩散到每个业务方法。

9. RDB 迁移验收动作

场景 操作 预期结果
首次安装 新装应用 创建 v4 目标结构
v1 覆盖升级 准备旧库后升级 逐级执行 v1 到 v4
缺少脚本 删除某个迁移步骤 启动时报明确错误
字段缺失 手动破坏表结构 校验失败并进入恢复页
数据保留 旧路线记录升级后查询 核心字段仍可读取

可以加一个迁移结果断言:

export function assertMigrationReady(version: number, errors: string[]): void {
  if (version < 4) {
    throw new Error(`数据库版本未升级到目标版本:${version}`)
  }
  if (errors.length > 0) {
    throw new Error(`数据库结构异常:${errors.join(',')}`)
  }
}

这个断言适合放在启动阶段,防止错误结构进入业务页面。

10. 数据迁移异常排查表

数据库迁移问题要优先拿到旧版本真实库样本。只看新装数据库没有意义,因为新装路径不会经过历史脚本。排查时建议记录当前库版本、目标版本、执行到哪条脚本、结构校验错误和业务页面入口,这些信息能快速判断是脚本缺失、SQL 失败还是仓库层误用。

现象 优先查看 处理建议
老用户升级崩溃 当前版本和迁移脚本 确认是否逐级执行
字段不存在 目标结构和实际结构 增加结构校验和补充脚本
数据丢失 迁移 SQL 和事务边界 避免先删后迁,保留备份路径
查询变慢 索引是否创建 关键查询字段要有索引计划
重装正常升级异常 旧库样本 用旧版本真实数据做覆盖升级验证

线上遇到迁移失败时,不要让应用继续进入依赖该表的页面。先展示恢复页或降级页,再把失败证据上报,这比让用户在多个业务页面连续报错更容易定位。

RDB 迁移复现场景:给读者一组可执行核验

数据库迁移要准备旧版本数据。空库迁移通过不代表真实用户升级安全,必须验证字段新增、索引变化和失败回滚。

核验维度 读者需要准备的证据
输入 页面入口、用户动作、关键参数
过程 日志、状态变化、异常分支
输出 UI 表现、回调结果、持久化结果
回归 同场景重复执行后的结果
interface RdbReplayCase {
  fromVersion: any
  toVersion: any
  rowCountBefore: any
  rollbackReady: any
}

const replay80: RdbReplayCase = {
  fromVersion: 'sample',
  toVersion: 'sample',
  rowCountBefore: 'sample',
  rollbackReady: 'sample',
}

function assertReplay80(item: RdbReplayCase): void {
  if (item.fromVersion >= item.toVersion) throw new Error('迁移版本方向错误')
}

这组核验要求带旧版本数据升级,适合验证 RDB 表结构变化和失败回滚是否可靠。

数据库升级回放表:把文章方法变成可复现动作

RDB 迁移必须带旧数据验证。建议准备一个旧版本库、一个空库、一个损坏库,分别验证迁移、初始化和回滚路径,避免只在开发库上通过。

回放动作 核验方式
旧库迁移成功 准备输入、执行操作、记录结果、给出结论
空库初始化成功 准备输入、执行操作、记录结果、给出结论
损坏库回滚 准备输入、执行操作、记录结果、给出结论
迁移日志可追溯 准备输入、执行操作、记录结果、给出结论

RDB 迁移文章要强调真实升级路径。读者不能只用新安装空库验证迁移,而要准备上一个正式版本的数据库文件,带着真实行数、旧字段和旧索引升级。升级失败时要能回滚,升级成功后要核对关键表行数和业务查询结果。这样数据库迁移才不会在用户升级后暴露问题。

11. 小结:数据库升级要可追、可验、可恢复

RDB 迁移不是 SQL 拼接任务,而是版本治理。目标结构要清晰,迁移脚本要逐级,执行过程要推进版本,完成后要校验关键字段,失败时要记录证据并阻止脏启动。这样应用升级时,老用户的数据不会成为最不可控的风险点。

Logo

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

更多推荐