ITPub博客

首页 > IT基础架构 > 网络安全 > 技术解读:ArkTS 如何给 SQLite 做版本迁移:3 次迭代不丢数据的做法

技术解读:ArkTS 如何给 SQLite 做版本迁移:3 次迭代不丢数据的做法

网络安全 作者:编码王者 时间:2026-10-19 09:42:49 0 删除 编辑

我的鸿蒙应用「心情手记」在华为应用市场上架之后,前后改过三次表结构。这篇文章不讲概念,就讲我当时具体怎么写的、为什么这么写、以及哪儿差点出事。

如果你也在做鸿蒙应用、也用了 RDB(SQLite),那这套东西你迟早要用上 —— 因为 应用一旦发布,数据库就不是你想改就能改的了 。

一、我最开始踩的那个坑

给「每日记录」加字段的时候,我干了这么一件事:把新字段直接写进建表语句里。

CREATE
TABLE
IF
NOT
EXISTS
daily_logs (
  id TEXT
PRIMARY
KEY,
date
TEXT
NOT
NULL
UNIQUE
,
  mood
INTEGER
NOT
NULL
,
  journal TEXT,
  plan TEXT
);

然后我发现不对劲: 新装的用户一切正常,但覆盖安装旧版本之后,那个新字段永远是空的。

盯了半天才反应过来 —— CREATE TABLE IF NOT EXISTS 的意思是「 表已存在就什么都不做 」。对老用户来说,我改的那句建表语句等于没写,新字段压根没被创建。

最坑的地方是它不报错。 建表成功、启动成功、日志干干净净,只有在真正读写那个字段的时候才出问题。不是崩溃,是"悄悄不对"——这种 bug 最难查。

所以结论就一条: 表结构变更必须靠显式的迁移语句,改建表语句没用。

二、我的做法:一个版本号 + 一段补列的代码

1. 一个常量,代表"这份代码期望的库长什么样"

export
class
DatabaseHelper
{
private
static
readonly
DB_VERSION
:
number
=
6
;
}

那句注释是写给我自己看的。半年后我肯定会忘记"加个字段还要改这个数",注释就是防这个。

2. 启动时:先建表,再迁移

async
init
(
context
: common.
Context
):
Promise
<
void
> {
if
(
this
.
store
) {
return
;
  }
const
config
: relationalStore.
StoreConfig
= {
name
:
Constants
.
DB_NAME
,
securityLevel
: relationalStore.
SecurityLevel
.
S1
};
this
.
store
=
await
relationalStore.
getRdbStore
(context, config);
await
this
.
createTables
();
await
this
.
migrate
();
}

这里是我踩坑之后想明白的最重要一点:有两条完全不同的路径,都得照顾到。

| 用户 | 实际发生了什么 |
| --- | --- |
| 全新安装 | createTables() 直接建出 最新 结构(已经含所有新列); migrate() 里的 ALTER 会因为"列已存在"而抛错,吞掉就行 |
| 覆盖升级 | 表早就存在, createTables() 什么也没干;全靠 migrate() 把缺的列补上 |

我踩的那个坑,本质就是只照顾了第一条路。反过来只写迁移、忘了更新建表语句,新用户那边又会缺字段。 两条路都得测,缺一条就是隐患。

三、三次真实的表结构变更

store.version 是 RDB 自带的版本号。判断和写回是这样:

private
async
migrate
():
Promise
<
void
> {
const
store =
this
.
getStore
();
if
(store.
version
>=
DatabaseHelper
.
DB_VERSION
) {
return
;
}
store.
version
=
DatabaseHelper
.
DB_VERSION
;
}

下面是这三次变更的实际语句,以及我当时为什么这么定。

第 1 次:v3 → v4,给记录加一个"是不是真记录"的标记

背景 :我做了个「给未来某天安排要做的事」的功能。实现上会给那一天建一条记录,但这条记录 只有计划,没有心情和正文 。

问题来了 :心情字段是非空枚举、有默认值。所以「只写了计划的存根」和「用户认真写的记录」, 光看内容分不出来 。结果就是这些存根会被算进"连续记录天数"、还会在日历上染色 —— 数据就假了。

try
{
await
store.
executeSql
(
'ALTER TABLE daily_logs ADD COLUMN has_entry INTEGER DEFAULT 1'
);
}
catch
(err) {
}

这里 DEFAULT 1 是我整个迁移里最当心的一个字符。

因为老数据在语义上全都是"真记录",默认值必须是 1。如果我偷懒不写默认值,老数据这一列就是 NULL,读取的时候还得靠代码兜 —— 又凭空多一处可能出错的地方。 能在 DDL 里把语义表达清楚,就别留给运行时代码。

第 2 次:v4 → v5,给账本加"这笔花得值不值"

背景 :账本里每笔支出可以标「值得 / 一般 / 后悔」,用来算一个月的「值得指数」。

try
{
await
store.
executeSql
(
'ALTER TABLE ledger_records ADD COLUMN feeling INTEGER DEFAULT 0'
);
}
catch
(err) {
}

注意这次默认值是 0 = 未标记 ,跟上一个正好相反。

默认值该写什么,是看业务语义定的,不是随手写 0:

| 新增列 | 老数据应该被当成什么 | 默认值 |
| --- | --- | --- |
| has_entry | 老记录都是真记录 | 1 |
| feeling | 老账目都没标过感受 | 0 (未标记) |

而且这个默认值会 直接影响统计结果 —— 我的「值得指数」计算会跳过未标记的账目。如果默认值给错(比如给成"值得"),老用户一升级,指数立刻被一堆假数据拉歪。 加列的时候一定要顺手想一遍:这列会不会参与统计?

第 3 次:v5 → v6,给记录加标签

标签我用 JSON 字符串存一列,跟项目里已有的"照片列表"是同一套做法:

try
{
await
store.
executeSql
(
'ALTER TABLE daily_logs ADD COLUMN tags TEXT'
);
}
catch
(err) {
}

这次我 故意没写 DEFAULT '[]' ,让它保持 NULL,在读取的时候兜成空数组。

原因:标签是"集合"语义,空集合用 [] 还是 NULL 表达都说得通;但我写入的时候本来就会把它序列化成空串(跟照片列表保持一致),所以读取侧统一兜底更不容易分叉。

每次 ALTER 我都套了 try/catch ,并注释"列已存在" —— 这不是偷懒。因为全新安装的建表语句里已经有这一列了,那条 ALTER 必然抛错,这是预期行为。

四、光迁移还不够:读取侧必须兜底

迁移只解决了"列在不在",没解决"列里的值对不对"。 读取的时候两种情况都得防 :列不存在(索引返回 -1)、值为 NULL。

private
mapDailyLog
(
rs
: relationalStore.
ResultSet
):
DailyLog
{
const
customIndex
:
number
= rs.
getColumnIndex
(
'custom_mood'
);
const
photoIndex
:
number
= rs.
getColumnIndex
(
'photo_uris'
);
const
entryIndex
:
number
= rs.
getColumnIndex
(
'has_entry'
);
return
{
id
: rs.
getString
(rs.
getColumnIndex
(
'id'
)),
date
: rs.
getString
(rs.
getColumnIndex
(
'date'
)),
customMood
: (customIndex <
0
|| rs.
isColumnNull
(customIndex))
      ?
''
: rs.
getString
(customIndex),
photoUris
: (photoIndex <
0
|| rs.
isColumnNull
(photoIndex))
      ? [] :
parsePhotoUris
(rs.
getString
(photoIndex)),
hasEntry
: (entryIndex <
0
|| rs.
isColumnNull
(entryIndex))
      ?
true
: rs.
getLong
(entryIndex) ===
1
,
};
}
  • getColumnIndex() 返回 -1 代表列不存在。 按理说迁移跑完列一定在,但"按理说"不该出现在读数据这条路上。

  • isColumnNull() 得单独判断 —— 列在但值是 NULL 是另一回事。

  • 兜底值和迁移的默认值,语义必须对上。 has_entry 查不到时我给 true ,跟 DEFAULT 1 是同一个意思。这两处要是对不上,就会出现"迁移前的老数据算一套、迁移后算另一套"的怪现象。

五、四个我觉得最容易踩的坑

坑 1:把迁移写成"严格逐级升版本"

我的 migrate() 其实是**"把所有已知的 ALTER 都跑一遍,失败的忽略"**,而不是严格的 if (version == 3) ... else if (version == 4) ... 。

这么写简单、幂等,少一个判断就少一个 bug。 代价是:它只能应付"加列"这种可以重复执行的变更。

如果你要做的是破坏性变更(改列类型、拆表、数据回填、改名),就必须写成严格的分步迁移,保证每步只跑一次。而且 SQLite 本身对 ALTER COLUMN 支持很有限,很多时候得走"建新表 → 拷数据 → 删旧表 → 改名"。

先看清自己的变更属于哪一类,再决定用哪种写法。

坑 2: store.version 什么时候写回

store.
version
=
DatabaseHelper
.
DB_VERSION
;

这样如果中途真有哪条 ALTER 失败了(不是"列已存在",是真失败),版本号不会被写回,下次启动会重跑一遍。

我宁可让它靠重复执行收敛,也不要留一个"记着做完了、实际没做完"的状态。

坑 3:新增"表"其实不用写迁移

这一点我一开始想复杂了。看建表那里的注释:

CREATE TABLE IF NOT EXISTS 对 已存在的表 确实什么都不做,但对 不存在的表 是会正常创建的。所以新增一张表,只要加进建表列表就行,老用户下次启动自然就有了。

这大概是 IF NOT EXISTS 唯一的用武之地 —— 它救不了"加列",但"加表"免费帮你搞定。

坑 4:别在迁移里做耗时操作

migrate() 是在应用启动路径上的。加列本身很快(SQLite 的 ADD COLUMN 基本只是改元数据),但如果你顺手在里面做数据回填(比如"给所有历史记录补一个默认标签"),几万条数据就会把启动卡住。

结构变更和数据回填要分开 :结构在启动时同步做完,回填丢到后台任务或者做成惰性的。

六、怎么验证迁移真的没问题

说句实话: 这类代码很难用单元测试覆盖 ,因为它依赖真实的 RdbStore。我项目里有 276 条单元测试,覆盖的都是纯函数(统计、趋势、计算逻辑), 迁移一条都没覆盖到 。

所以我的验证方式是 真机手工升级测试 ,步骤固定:

  • 装 上一个已发布版本 ,留一些真实数据

  • 直接覆盖安装 新版本 (包名不变)

  • 启动后逐项检查:老记录能不能正常打开、新功能读写新字段是否正常、列表和统计把老数据算对了没

  • 再卸载、重装一次新版本 ,确认全新安装也正常

第 4 步是我特意加的 —— 前面说过,全新安装时 ALTER 会抛错,走的是完全不同的分支。 只测升级不测新装,等于只测了一半。

另外测试数据我会 故意造得脏一点 :空字符串、超长文本、只有一半字段有值。迁移最容易暴露问题的就是这些边缘数据。

写在最后

  • CREATE TABLE IF NOT EXISTS 不会给已存在的表加列。 表结构变更必须写显式迁移,改了建表语句不等于改了老用户的库。

  • 版本号 + 幂等 ALTER + 读取侧兜底 ,三件套缺一不可;默认值按业务语义定,不是随手写 0。

  • 全新安装和覆盖升级是两条不同的路径,都得测。

这套机制从我项目的 v3 撑到 v6,三次表结构变更(加标记列、加账本感受列、加标签列),覆盖安装和全新安装我都验证过,没有出现过数据丢失或者升级报错。

如果你的变更涉及破坏性操作(改类型、拆表、回填),那就要升级成严格的分步迁移。但只要还停留在"加列 / 加表",上面这套足够用,而且更好维护。

代码开源在 github.com/D-sp-wq/moo… (MIT 协议),迁移相关代码在 entry/src/main/ets/services/storage/DatabaseHelper.ets ,欢迎交流。


转载声明:本文为转载整理,原文版权归原作者及原站点所有。

原文来源:Juejin HarmonyOS

原创地址:https://juejin.cn/post/7696326603062067250

原文作者:道安;如有侵权,请联系管理员删除。

上一篇: 没有了~
下一篇: 没有了~
请登录后发表评论 登录
全部评论

注册时间:2019-06-20

  • 博文量
    1
  • 访问量
    728