【听见课堂 HarmonyOS NEXT 实战系列 14】HarmonyOS 数据库升级实战:从 Schema v1 迁移到 v2
第一次安装时建表很容易,真正困难的是已经有用户数据后再改表。直接把CREATE TABLE增加两个字段,只对新数据库有效;旧设备上的表结构不会自动变化。如果应用读取不存在的列,轻则页面报错,重则用户无法进入应用。
听见课堂当前把 RelationalStore schema 提升到 v2:课程新增color_token,任务新增due_at_ms,并将旧任务的自然语言截止时间回填为时间戳。本文按照migrateAndSeed()和migrateV1ToV2()的真实实现,拆解版本检测、事务、回填和测试矩阵。
一、为什么需要 v2
schema v1 已经能保存课程、字幕、板书和任务,但后续出现两个新需求。
1. 课程需要稳定的颜色语义
如果页面只根据列表位置分配颜色,课程排序变化后同一课程会变色。v2 在courses增加:
color_tokenTEXTNOTNULLDEFAULT'ocean_blue'存的是主题 token,而不是固定色值,方便暗色和高对比模式在 UI 层映射。
2. 任务中心需要真实时间排序
v1 只有due_text,例如“今晚”“明天 20:00 前”“本周五前”。每次打开页面重新解析会让“明天”不断向后移动,也无法稳定判断是否过期。v2 增加:
due_at_msINTEGERNOTNULLDEFAULT0迁移时以同一个基准时刻解析旧文本,之后排序、日期分组和过期判断都读取固定时间戳。
二、版本号必须是代码常量和数据库记录的组合
当前代码声明:
constSCHEMA_VERSION:number=2;constMETA_SCHEMA_VERSION:string='schema_version';代码常量表示“当前应用能够理解的最高版本”,schema_meta中的值表示“这个数据库已经迁移到哪个版本”。启动时比较二者,才能决定首次建库、升级、正常打开还是拒绝降级读取。
只修改常量不写迁移,旧表不会变化;只改表不更新元数据,迁移可能每次启动重复执行。
三、migrateAndSeed()的完整执行顺序
当前初始化流程可以归纳为:
beginTransaction -> 创建 schema_meta -> 读取旧版本 -> 版本过新则拒绝 -> CREATE TABLE IF NOT EXISTS 当前结构 -> version == 1 时执行 v1 -> v2 -> 首次需要时写入种子 -> 写入 schema_version = 2 commit 任何异常 -> rollBack -> 初始化失败 -> 上层切换内存降级对应的核心代码:
privateasyncmigrateAndSeed():Promise<void>{conststore:relationalStore.RdbStore=this.getStore();store.beginTransaction();try{awaitstore.executeSql(CREATE_SCHEMA_META_SQL);constversion:number=awaitthis.readSchemaVersion();if(version>SCHEMA_VERSION){thrownewError('Database schema is newer than this application.');}awaitstore.executeSql(CREATE_COURSES_SQL);awaitstore.executeSql(CREATE_TRANSCRIPT_SQL);awaitstore.executeSql(CREATE_SCANS_SQL);awaitstore.executeSql(CREATE_TASKS_SQL);if(version===1){awaitthis.migrateV1ToV2();}// seed 与 meta 写入store.commit();}catch(error){store.rollBack();thrownewError('Failed to migrate classroom relational store.');}}版本号只在所有步骤成功后更新为 2,避免迁移做到一半却被标记为完成。
四、为什么先执行CREATE TABLE IF NOT EXISTS
对全新数据库,schema_meta中没有版本,读取结果为 0。当前CREATE_*SQL 已经包含 v2 字段,因此直接创建最新结构,不需要从 v1 绕一圈。
对 v1 数据库,表已经存在,CREATE TABLE IF NOT EXISTS不会改变旧表,然后由migrateV1ToV2()增加字段。
这形成两条路径:
| 数据库状态 | version | 动作 |
|---|---|---|
| 全新安装 | 0 | 直接创建 v2 表并写种子 |
| 已有 v1 | 1 | 保留数据并执行 ALTER/回填 |
| 已有 v2 | 2 | 跳过迁移,正常读取 |
| 来自未来版本 | >2 | 拒绝打开,避免旧应用破坏新结构 |
五、v1 到 v2 的两个ALTER TABLE
迁移方法先增加字段:
awaitstore.executeSql(`ALTER TABLE courses ADD COLUMN color_token TEXT NOT NULL DEFAULT 'ocean_blue'`);awaitstore.executeSql(`ALTER TABLE tasks ADD COLUMN due_at_ms INTEGER NOT NULL DEFAULT 0`);两个字段都提供NOT NULL DEFAULT,这样已有行会获得可读值。没有默认值时,为含数据的旧表新增非空列往往会失败。
color_token使用统一默认值可以保证页面立即可渲染;后续若要按旧课程特征分配不同 token,应另写确定性回填规则。
六、为什么旧任务必须回填due_at_ms
仅增加默认值 0 虽然能完成建表,但所有旧任务都会变成“未指定日期”,任务中心无法判断过期和本周分组。因此迁移读取旧 ID 与due_text:
constresultSet=awaitstore.querySql(`SELECT id, due_text FROM tasks ORDER BY sort_order`);consttaskIds:Array<string>=[];constdueTexts:Array<string>=[];try{while(resultSet.goToNextRow()){taskIds.push(this.getText(resultSet,'id'));dueTexts.push(this.getText(resultSet,'due_text'));}}finally{resultSet.close();}先把结果复制到普通数组并关闭 ResultSet,再逐条更新,资源边界更清晰。
七、所有旧任务必须共享同一个迁移时刻
迁移代码只调用一次Date.now():
constmigrationNowMillis:number=Date.now();for(letindex:number=0;index<taskIds.length;index++){awaitthis.updateById(TABLE_TASKS,taskIds[index],{due_at_ms:TaskDateResolver.resolveDueText(dueTexts[index],migrationNowMillis)});}如果每条任务各取一次当前时间,迁移跨过午夜时,“今天”和“明天”可能落到不同基准日。统一基准时刻保证同批回填一致。
八、自然语言回填不是无损转换
TaskDateResolver当前支持“今晚/今天”“明天”“周一至周日”“已过期/昨天/上周”等有限表达,不支持任意中文日期。
因此:
- 可识别文本得到本地时间戳;
- 不可识别文本返回 0;
- “本周五”在不同迁移日期可能落在过去或未来;
- 没有年份、时区和原始创建时间时,语义无法完全还原。
这不是迁移代码可以凭空解决的问题。更完善的 v1 设计应同时保存任务创建时间或原始解析基准;当前文章必须把due_at_ms=0视为有效降级,而不是伪造日期。
九、为什么要拒绝“数据库版本过新”
用户可能先安装新版产生 schema v3,之后回退到只支持 v2 的旧应用。旧代码不了解新字段、新约束和新状态,继续写入可能破坏数据。
当前保护是:
if(version>SCHEMA_VERSION){thrownewError('Database schema is newer than this application.');}异常向上传递后,应用切到可见的内存降级。更理想的页面提示是“当前版本无法读取已有数据,请升级应用”,而不是自动删库。
十、事务能保护什么,不能保护什么
事务目标是让建表、ALTER、回填、种子和版本更新作为一个整体提交。失败时执行rollBack(),上层不应继续把数据库当作 v2 使用。
但工程上仍需在目标 HarmonyOS 版本验证 DDL 在事务中的真实回滚行为,不能只根据代码结构假设所有设备完全一致。尤其要测试:
- 第一个 ALTER 成功、第二个失败;
- ALTER 成功、回填中断;
- 回填成功、写 meta 前进程结束;
- 再次启动是否能安全恢复。
如果目标环境对部分 DDL 回滚有限制,就需要增加“列是否存在”探测或分阶段迁移状态,避免重复ADD COLUMN。
十一、四类数据库状态必须分开测试
1. 首次安装
没有数据库文件和 meta。应直接得到 v2 表、默认课程颜色、任务时间戳和一次性种子。
2. 真实 v1 升级
先用 v1 schema 创建数据库并写入自定义课程、字幕、板书和任务,再安装 v2。验证原记录数量、正文和确认状态不变,新字段完成回填。
3. 空库
表存在但没有业务数据。迁移不能因为查询结果为空而失败,也不能在用户明确清空后每次启动重新注入种子。
4. 脏数据
至少覆盖空截止文本、无法解析日期、重复 ID、缺少 meta、版本过新和部分字段异常。结果可以降级或阻断,但不能静默删除用户内容。
十二、迁移后的验证不能只查版本号
schema_version=2只是一个信号。迁移完成后还应检查:
courses.color_token和tasks.due_at_ms列存在;- 旧课程、字幕、扫描和任务行数保持;
- 已确认/已完成状态未改变;
- 可识别的截止文本得到合理时间戳;
- 不可识别文本保持
due_at_ms=0并显示“未指定”; - P09 的排序、过期和日历分组符合预期;
- 删除课程和清空全部数据事务仍有效;
- 强停重启后仍读取 v2,不重复迁移。
可以把验证拆成结构、数据、业务三层,而不是只执行一条SELECT schema_version。
十三、上下文文档与源码版本漂移怎么处理
项目早期架构文档可能仍写“当前 schema version 为 1”,而当前源码常量已经是 2。写技术文章或交付报告时,应明确采用当前源码和本轮验证结果,历史文档只作为当时基线。
正确做法是同步更新上下文或记录漂移,不应为了让材料一致而把源码事实写回 v1。可审计项目允许历史存在,但必须标注时间和适用版本。
十四、后续 v3 应怎样扩展
当 v3 到来时,不建议继续堆一个大函数。可以按版本逐级迁移:
letversion:number=awaitreadSchemaVersion();if(version<2){awaitmigrateV1ToV2();version=2;}if(version<3){awaitmigrateV2ToV3();version=3;}每一步只理解相邻版本,配套独立夹具和后置条件。版本更新仍要在该步成功后发生。若数据量增大,还要考虑批量更新、进度提示、超时和可恢复中断。
十五、迁移验收清单
- 当前 schema 常量与目标结构一致;
- 全新安装直接创建最新结构;
- v1 数据库通过 ALTER 和回填升级;
- 新增非空列有合理默认值;
- 所有旧任务共享同一迁移基准时刻;
- ResultSet 在
finally中关闭; - 版本过新时拒绝写入而不是删库;
- 迁移失败切换为可见降级;
- 首装、升级、空库、脏数据分别测试;
- 验证字段、数据和页面业务,而不只检查版本号;
- 没有把“代码存在迁移”写成“目标设备迁移已验证”。
十六、总结
数据库升级的本质是保护已有用户事实。听见课堂的 v1→v2 迁移在一个初始化事务中完成版本检测、字段新增、截止时间回填、种子控制和版本写入,并对未来版本设置拒绝保护。
这套实现已经具备清晰主链,但仍需要用真实 v1 数据库夹具验证 DDL 中断、脏数据和设备差异。只有迁移脚本存在、目标环境执行通过、迁移后业务页面正确三者同时成立,才能把数据库升级标记为passed。