[鸿蒙从零到一] relationalStore 高级实战:事务、版本迁移与数据库加密

relationalStore 高级实战:事务、版本迁移与数据库加密

很多鸿蒙应用在数据层的用法停留在"建表、增删改查"这一层。功能能跑,但一旦遇到批量写入性能差、升级后表结构对不上、敏感数据明文落盘这三类问题,基础用法就不够了。本文聚焦 relationalStore 的三个高级能力:事务(Transaction)版本迁移(Migration)数据库加密(Encrypt),给出可直接落地的封装方案与实测数据。

一、机制解析:relationalStore 的写入路径与 WAL

在讲事务之前,先弄清楚 relationalStore 每次写入到底发生了什么,否则很难理解为什么批量插入必须用事务。

relationalStore 底层基于 SQLite,默认开启 WAL(Write-Ahead Logging) 日志模式。一次 insert 调用的完整路径是:

  1. ArkTS 层参数序列化,跨到 Native 层;
  2. SQL 语句编译(或命中语句缓存);
  3. 写入 WAL 日志文件(顺序追加写);
  4. 事务提交点执行 fsync,确保日志落盘;
  5. 后台 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;
  }
}

这套注册表模式的好处:

  1. 跨版本升级自动串联:用户从 v1 直升 v3,会依次跑 1→2、2→3,不会漏;
  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;
  }
}

三条工程原则收个尾:

  1. 写操作默认走事务 ,批量优先 batchInsert;不确定要不要事务时,答案是"要"。
  2. 迁移脚本只增不改:已发版的 Migration 永远不动,新变更永远追加新版本------线上库的结构历史是不可变的。
  3. 加密在建库那一刻决定:新项目直接开;老项目排期做一次搬迁,越拖存量越大。

数据层是应用里最不允许"先跑起来再说"的模块。事务保性能与一致性、迁移保升级安全、加密保数据安全,三者都配齐,这一层才算真正可靠。

相关推荐
Coodor18 分钟前
在前端如何转换IC卡卡号格式
前端·javascript·nfc·卡号格式
invicinble25 分钟前
数字系统--c端数字环境入口和操作系统
前端
百慕大三角32 分钟前
给 SPA 加页面切换动画:View Transitions API 从入门到实战
前端·javascript
不可能片场33 分钟前
自动化也会丢:一次 WorkBuddy 自动化清空的复盘
前端·electron
求道於盲37 分钟前
异步编程中的 Future
前端
Z兽兽1 小时前
HBuilder打包web地址的apk,更新dist后如何自动清缓存
前端·android应用打包
Hilaku1 小时前
Bun 真的能取代 Node.js 吗?
前端·javascript·程序员
奔跑的卡卡1 小时前
线上Web异常发现为什么总是滞后?一套自研Web实时异常分析监控系统整体架构拆解
前端·web安全·架构
梦曦i2 小时前
create-uni-app v1.1.0:结构化生成,工程骨架更规范
前端·uni-app