HarmonyOS 器材借还数据实战-03:结构迁移与失败恢复【共创稿事节】
新版器材簿想增加“存放区域”,需求只多了一个输入项,数据库却多出一条分叉:新安装的用户没有旧表,老用户的表里没有这个字段。直接把建表语句改成新版,再补一个 CREATE TABLE IF NOT EXISTS,老用户仍然得不到新列。更麻烦的是升级只执行了一半,版本号却已经变成新版;下一次启动看似不必升级,实际查询仍然失败。迁移的核心不是执行多少条语句,而是失败之后还能否准确判断当前结构。

一、把版本当成承诺,而不是标签
本篇仍然使用 API 14、GearLedger 工程和前两篇的 GearRepository。数据版本从一升级到二,新增 GEAR.AREA 文本字段,默认值为“器材室”;同时为借用流水增加按创建时间与编号降序排列的复合索引。旧借用记录、器材编号、总量和可借量都要保留。版本二表示这两个结构变化已经完成,而不是程序曾经尝试过升级。
数据库版本和应用版本应分开管理。界面改了颜色、提示词或图标,不一定需要新的数据版本;相反,一次很小的字段改动可能必须升级。每个数据版本要对应确定的结构状态,禁止同一版本号在不同分支里代表不同列集合。否则恢复备份、灰度发布或跨版本排查时,数字本身就失去判断价值。
第一篇的 SCHEMA_META 已经把版本放进同一数据库,这为原子升级创造了条件。迁移事务里先读取版本,再执行结构变化,最后更新版本记录并提交。只要其中一步失败,结构与版本一起回滚。不要先在 Preferences 写入“已经升级”,再去修改关系库;两个不同存储之间并不存在我们这段代码能保证的共同提交。
升级路径也应明确方向。本系列只允许从版本一进入版本二,发现高于二的版本时直接拒绝继续操作,不自动降级。旧应用无法理解新结构时,最危险的处理是删除文件重新建库,因为这会把兼容性问题转化为用户数据丢失。保留数据库并提示升级应用,通常比猜测性修复更容易恢复。
二、迁移函数只做一次完整的变化
下面方法加入前两篇的 data/GearRepository.ets,复用第二篇的 transaction()。再在第一篇 create() 中,将 await repository.initialize() 后面接上 await repository.migrateToV2(),然后才返回仓库。不要把迁移塞进初始化事务内部,也不要放在 initialize() 里可能被旧版本提前返回跳过的位置。create() 等待两个步骤完成,页面不会抢先查询新字段。
private migrateToV2(): Promise<void> {
return this.transaction(async (tx: relationalStore.Transaction) => {
const rs = await tx.querySql('SELECT VERSION FROM SCHEMA_META WHERE ID = 1');
let version = 0;
try {
if (!rs.goToNextRow()) { throw new Error('缺少数据库版本记录'); }
version = rs.getLong(0);
} finally { rs.close(); }
if (version === 2) { return; }
if (version !== 1) { throw new Error('数据库版本不受当前应用支持'); }
await tx.execute("ALTER TABLE GEAR ADD COLUMN AREA TEXT NOT NULL DEFAULT '器材室'");
await tx.execute('CREATE INDEX LOAN_TIME_IDX ON LOAN (CREATED_AT DESC, ID DESC)');
await tx.execute('UPDATE SCHEMA_META SET VERSION = 2 WHERE ID = 1');
});
}
schemaVersion(): Promise<number> {
return this.serial(async () => {
const rs = await this.store.querySql('SELECT VERSION FROM SCHEMA_META WHERE ID = 1');
try {
if (!rs.goToNextRow()) { throw new Error('缺少数据库版本记录'); }
return rs.getLong(0);
} finally { rs.close(); }
});
}
这里没有给新增索引加上 IF NOT EXISTS,因为版本一的受控结构本来不应该出现这个索引。若开发阶段有人手动改过数据库,重复索引错误应当暴露这份不一致,而不是把它静默视为正常。生产迁移如果确实存在多条历史路径,就应先识别结构并设计对应修复步骤,不能简单把每条语句都变成“有就算了”。
默认区域只是兼容旧记录的初始值,不代表真实存放位置已经完成盘点。页面可以展示默认值并允许后续编辑,但不能据此自动生成“所有器材已经核对”的结论。字段有值与业务信息准确是两个层次,迁移脚本能保证前者,后者通常需要业务流程补齐。

复合索引提前为下一篇的分页准备。它不是为了让迁移页看起来更完整,也不是任何查询都会自动受益。只有查询条件与排序方式相匹配,索引才可能减少扫描或排序工作;真实收益还与数据规模、查询计划和设备环境有关。本篇不输出耗时结论,只把结构与后续查询契约固定下来。
三、让页面等待结构就绪
下面页面是升级验证入口,替换 pages/Index.ets 即可沿用已有页面配置。它通过共享仓库打开数据库,读取正式版本之后再呈现结果。屏幕里的“新增区域默认:器材室”和“历史借还记录保留”描述本次迁移的处理内容,不替代逐条库存检查;测试时仍需执行后文的数据核对。
import { common } from '@kit.AbilityKit';
import { GearRepository } from '../data/GearRepository';
@Entry
@Component
struct Index {
@State version: number = 0;
@State ready: boolean = false;
@State busy: boolean = false;
@State status: string = '等待打开数据库';
aboutToAppear(): void { this.open(); }
async open(): Promise<void> {
if (this.busy) { return; }
this.busy = true;
this.ready = false;
this.status = '正在检查数据结构';
try {
const repository = await GearRepository.open(getContext(this) as common.UIAbilityContext);
this.version = await repository.schemaVersion();
this.ready = this.version === 2;
this.status = this.ready ? '数据结构已就绪' : '请更新应用后重试';
} catch (error) {
this.status = '升级未完成,请重试';
} finally { this.busy = false; }
}
build() {
Column({ space: 20 }) {
Text('数据升级').fontSize(28)
Text(this.status).fontColor('#475569')
if (this.ready) {
Text(`数据版本 ${this.version}`).fontSize(24)
Text('新增区域默认:器材室')
Text('历史借还记录保留')
Text('流水排序索引:LOAN_TIME_IDX')
}
Button('重新检查').enabled(!this.busy).onClick(() => { this.open(); })
}.padding(24).width('100%').height('100%').backgroundColor('#F7F5FA')
}
}
此处复用共享开库 Promise 很关键。假如首页、设置页和后台服务各自触发一次迁移,即使每段代码单独看起来正确,也可能在结构锁上互相竞争。统一初始化入口意味着“仓库可用”本身就包含“结构满足当前代码”的承诺,业务页面不需要到处判断版本二是否存在。
也不要把迁移放进列表构建函数或每次刷新回调。界面重新渲染可能发生很多次,数据结构却只应该在打开仓库时检查。把迁移和展示分开,还能让错误提示更准确:结构升级失败时停留在维护状态,普通查询失败时保留当前页面,不会所有异常都落成一句“加载失败”。

串行队列的使用仍然遵守第二篇约定:migrateToV2() 进入一次 transaction(),事务内部不再调用 schemaVersion(),而是通过 tx.querySql() 读取版本。后者如果被嵌套调用,会排到当前操作之后,当前操作却又在等待它,形成自己等待自己的死锁。这个错误与数据库锁不同,看日志时不要把两者混为一谈。
四、升级测试至少覆盖三种起点
第一种起点是全新安装。先执行第一篇的基础建表,再迁移到版本二;预期三类器材数量仍为四、三、二,区域默认值存在,版本为二。这里刻意保留从基础版本演进的路径,让新安装与老用户升级最终进入同一套结构定义,避免维护两份逐渐分叉的建表代码。
第二种起点是已有借还记录的版本一。先用第二篇借出一支麦克风,保留数据库安装升级版本。升级后应仍有那笔未归还流水,麦克风可借量仍为三,不应该重新变成四。不能通过卸载重装来验证这个场景,因为那样旧数据已经不存在,测试只会再次覆盖全新安装。
第三种起点是故意制造中途失败。开发环境中在新增字段之后临时抛出异常,使索引创建与版本更新无法执行。再次打开前应核对版本仍为一,旧查询仍可读取原记录;移除异常后重试,预期升级能够完整完成。若第二次提示字段已经存在,就需要检查结构变更是否真的执行在同一个事务里,而不是立刻加上忽略错误的补丁。
迁移测试还要区分进程退出与数据库损坏。普通异常回滚覆盖的是受控失败路径,突然结束进程覆盖的是持久化提交边界,两者都不等价于任意磁盘损坏。对于存储介质异常、空间不足或系统报错,应用应保留明确诊断并停止继续写入;不能承诺一段升级函数可以恢复所有物理损坏。
检查数据时不只比对行数。总量、可借量、借用人、请求编号和归还状态都可能在错误迁移中被改变,而总行数保持相同。可以挑选一笔未归还与一笔已归还的记录逐字段核对,再执行一次借出归还确认原有规则仍成立。数据升级的验收对象是业务能力连续性,不只是新字段能否查询。
对默认值的验证应包含已有行和后续新增行。前者说明旧数据能够被新查询读取,后者说明新增代码在未指定区域时仍满足非空约束。若未来要求用户必须主动选择区域,就需要另外调整录入校验,不能因为数据库提供了默认值而省略业务必填规则。数据库兼容策略与页面交互要求可以不同,但两者应明确配合。
测试数据也不宜只用整齐的中文名称。保留一个带空格的器材名称、一个含单引号的借用人,以及同一时刻创建的多笔流水,有助于发现错误的字符串拼接、数据清洗和排序假设。升级并不负责擅自规范化这些内容;如果确实要修正数据,应有独立规则、变更记录和可解释的影响范围。
五、发布之前想清楚回退方向
版本二只增加字段和索引,旧查询使用明确列名,因此读取层面通常更容易保持兼容,但这不意味着旧应用可以无限制地写新库。未来新增约束、改变字段含义或者拆分表时,旧代码可能写出新版本无法接受的数据。发布前应明确最低可读版本与最低可写版本,不能简单用“数据库能打开”作为兼容性证明。

迁移脚本一旦随版本发布,就应尽量保持不可变。后续发现问题时增加新的修复版本,保留旧版本到新版本的确定路径;不要修改同一段已发布迁移,再期待所有用户都得到相同结果。有些用户已经执行过旧逻辑,修改脚本不会自动让他们重新执行,反而会制造两个同号结构。
本次升级的收尾点很具体:数据库版本二意味着区域列与流水索引同时存在,原有借还数据保持,初始化失败可以重试,高版本数据库不会被静默降级。下一篇会真正使用这个复合索引组织流水分页,并解释为什么“按照时间排序”还不足以让翻页结果稳定。
更多推荐
所有评论(0)