relationalStore 高级实战:事务、版本迁移与数据库加密
很多鸿蒙应用在数据层的用法停留在"建表、增删改查"这一层。功能能跑,但一旦遇到批量写入性能差、升级后表结构对不上、敏感数据明文落盘这三类问题,基础用法就不够了。本文聚焦 relationalStore 的三个高级能力:事务(Transaction) 、版本迁移(Migration) 和 数据库加密(Encrypt),给出可直接落地的封装方案与实测数据。
一、机制解析:relationalStore 的写入路径与 WAL
在讲事务之前,先弄清楚 relationalStore 每次写入到底发生了什么,否则很难理解为什么批量插入必须用事务。
relationalStore 底层基于 SQLite,默认开启 WAL(Write-Ahead Logging) 日志模式。一次 insert 调用的完整路径是:
- ArkTS 层参数序列化,跨到 Native 层;
- SQL 语句编译(或命中语句缓存);
- 写入 WAL 日志文件(顺序追加写);
- 事务提交点执行
fsync,确保日志落盘; - 后台 checkpoint 线程在合适时机把 WAL 内容合并回主库文件。
关键在第 4 步:每一个隐式事务都要付出一次 fsync 的代价 。如果你循环调用 1000 次 insert,就是 1000 个独立的隐式事务、1000 次 fsync。而 fsync 是整条链路里最慢的操作(闪存上通常毫秒级)。把 1000 次插入包进一个显式事务,fsync 就只发生一次------这就是事务带来数量级性能差异的根本原因,不是"玄学优化",是 I/O 模型决定的。
WAL 模式还有一个特性值得知道:读写不互斥。读操作走主库文件 + WAL 快照,写操作追加 WAL,因此 UI 线程的查询不会被后台批量写入卡住。但这不代表可以滥用并发写------写与写之间仍然是串行的,多个写事务会排队。
二、事务:批量写入的正确姿势
2.1 基础 API
relationalStore 提供 beginTransaction / commit / rollBack 三件套(API 12+ 推荐使用 createTransaction 获取独立事务对象,避免多线程下共享连接状态混乱):
typescript
import { relationalStore } from '@kit.ArkData';
async function batchInsert(store: relationalStore.RdbStore,
rows: relationalStore.ValuesBucket[]): Promise<void> {
store.beginTransaction();
try {
for (const row of rows) {
await store.insert('t_note', row);
}
store.commit();
} catch (err) {
store.rollBack(); // 任何一条失败,整批回滚
throw err;
}
}
2.2 API 12+ 的 Transaction 对象
新版事务对象把作用域收敛到对象本身,语义更清晰,也支持指定事务类型:
typescript
async function batchInsertV2(store: relationalStore.RdbStore,
rows: relationalStore.ValuesBucket[]): Promise<void> {
const txn = await store.createTransaction({
transactionType: relationalStore.TransactionType.IMMEDIATE
});
try {
for (const row of rows) {
await txn.insert('t_note', row);
}
await txn.commit();
} catch (err) {
await txn.rollback();
throw err;
}
}
三种事务类型的选型:
- DEFERRED(默认):拿到事务对象时不加锁,第一次读加读锁、第一次写升级写锁。适合"可能只读"的场景,但存在升级锁失败(BUSY)的可能。
- IMMEDIATE:创建时立即加写锁。适合明确要写的批量任务,避免中途升级失败。
- EXCLUSIVE:独占锁。WAL 模式下与 IMMEDIATE 行为基本一致,一般不需要。
批量写入统一用 IMMEDIATE,把锁冲突暴露在事务开始处,失败重试的代价最小。
2.3 实测数据
测试环境:真机,1000 条记录(每条 4 个字段),三种写法各跑 5 次取中位数:
| 写法 | 耗时 | 相对性能 |
|---|---|---|
| 循环裸 insert(1000 个隐式事务) | 4870ms | 1x |
| 显式事务包裹 1000 次 insert | 312ms | ~15.6x |
| 事务 + batchInsert 接口 | 89ms | ~54.7x |
结论很直接:批量场景必须用事务,能用 batchInsert 就不要循环单条插 。batchInsert 在 Native 层复用编译好的语句,省掉了 1000 次 ArkTS↔Native 跨语言开销,所以比"事务+循环"还要快数倍。
三、版本迁移:别让升级毁掉用户数据
relationalStore 的 getRdbStore 配置里有 version 字段,但它不像 Android Room 那样提供 onUpgrade 回调链,迁移逻辑需要自己管理。裸写很容易出现"跨版本升级漏执行中间迁移"的事故。
3.1 迁移脚本注册表
推荐把每一步迁移写成独立脚本,按版本号顺序执行:
typescript
type Migration = {
from: number;
to: number;
run: (store: relationalStore.RdbStore) => Promise<void>;
};
const MIGRATIONS: Migration[] = [
{
from: 1, to: 2,
run: async (s) => {
// v2:笔记表增加标签字段
await s.executeSql('ALTER TABLE t_note ADD COLUMN tag TEXT DEFAULT ""');
}
},
{
from: 2, to: 3,
run: async (s) => {
// v3:拆分归档表 + 建索引
await s.executeSql(
`CREATE TABLE IF NOT EXISTS t_note_archive (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
archived_at INTEGER NOT NULL)`);
await s.executeSql(
'CREATE INDEX IF NOT EXISTS idx_note_tag ON t_note(tag)');
}
}
];
3.2 迁移执行器:版本探测 + 事务保护
用 SQLite 自带的 PRAGMA user_version 存储当前结构版本,迁移全程包在事务里,任何一步失败整体回滚,保证数据库不会停在"半迁移"状态:
typescript
async function migrate(store: relationalStore.RdbStore, target: number): Promise<void> {
const rs = await store.querySql('PRAGMA user_version');
rs.goToFirstRow();
let current = rs.getLong(0);
rs.close();
if (current >= target) { return; }
store.beginTransaction();
try {
while (current < target) {
const step = MIGRATIONS.find(m => m.from === current);
if (!step) {
throw new Error(`missing migration from v${current}`);
}
await step.run(store);
current = step.to;
}
await store.executeSql(`PRAGMA user_version = ${target}`);
store.commit();
} catch (err) {
store.rollBack();
throw err;
}
}
这套注册表模式的好处:
- 跨版本升级自动串联:用户从 v1 直升 v3,会依次跑 1→2、2→3,不会漏;
- 迁移原子性:中途断电/崩溃,回滚到旧版本结构,下次启动重试;
- 可测试:每个 Migration 是纯函数式的独立单元,可以在测试里对着内存库逐个验证。
一个容易踩的坑:SQLite 的 ALTER TABLE 能力有限(不支持 DROP COLUMN 的旧引擎、不支持修改列类型)。需要"改列"时的标准做法是建新表 → 拷数据 → 删旧表 → 重命名,这四步同样要包在迁移事务里。
四、数据库加密:一行配置与它背后的代价
4.1 开启加密
relationalStore 的加密是配置级能力,创建时声明即可:
typescript
const STORE_CONFIG: relationalStore.StoreConfig = {
name: 'notes.db',
securityLevel: relationalStore.SecurityLevel.S3,
encrypt: true // 落盘加密
};
const store = await relationalStore.getRdbStore(context, STORE_CONFIG);
密钥由系统托管(结合 HUKS),应用层不接触密钥本体,也就不存在"密钥硬编码被逆向"的问题。这比自己用 SQLCipher 管密钥安全得多。
注意两点:
- encrypt 是创建时属性:已存在的明文库不能通过改配置变成加密库。存量数据需要走"建加密新库 → 事务内搬数据 → 删旧库"的一次性迁移,正好复用第三节的迁移执行器。
- securityLevel 与 encrypt 是两回事:securityLevel 决定数据的分级标签(影响分布式同步范围),encrypt 决定物理落盘是否加密,敏感数据两个都要配。
4.2 加密的性能代价实测
同样的 1000 条批量插入 + 全表查询,明文库 vs 加密库:
| 操作 | 明文库 | 加密库 | 损耗 |
|---|---|---|---|
| batchInsert 1000 条 | 89ms | 103ms | ~16% |
| 全表扫描查询 1000 条 | 21ms | 26ms | ~24% |
| 冷启动首次打开库 | 11ms | 19ms | ~73% |
结论:读写损耗在 20% 上下,对绝大多数业务无感;首次打开涉及密钥派生所以损耗比例高,但绝对值仍在 20ms 内。敏感数据(用户身份、聊天记录、健康数据)没有理由不开加密;纯缓存类数据可以不开,省下这点开销。
五、收尾:一个可复用的数据层骨架
把三个能力拼起来,数据层初始化的标准流程是:
typescript
export class Database {
private static store: relationalStore.RdbStore | null = null;
private static readonly VERSION = 3;
static async init(context: Context): Promise<relationalStore.RdbStore> {
if (Database.store) { return Database.store; }
const s = await relationalStore.getRdbStore(context, {
name: 'app.db',
securityLevel: relationalStore.SecurityLevel.S3,
encrypt: true
});
await s.executeSql(
`CREATE TABLE IF NOT EXISTS t_note (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT DEFAULT '',
tag TEXT DEFAULT '',
created_at INTEGER NOT NULL)`);
await migrate(s, Database.VERSION);
Database.store = s;
return s;
}
}
三条工程原则收个尾:
- 写操作默认走事务 ,批量优先
batchInsert;不确定要不要事务时,答案是"要"。 - 迁移脚本只增不改:已发版的 Migration 永远不动,新变更永远追加新版本------线上库的结构历史是不可变的。
- 加密在建库那一刻决定:新项目直接开;老项目排期做一次搬迁,越拖存量越大。
数据层是应用里最不允许"先跑起来再说"的模块。事务保性能与一致性、迁移保升级安全、加密保数据安全,三者都配齐,这一层才算真正可靠。