[鸿蒙从零到一] relationalStore 高级实践:事务、迁移与敏感数据保护

鸿蒙从零到一 relationalStore 高级实践:事务、迁移与敏感数据保护

在 HarmonyOS 应用中,relationalStore 适合承载结构化业务数据,但真正进入生产环境后,难点通常不在于创建一张表,而在于如何保证批量写入的一致性、版本升级的可控性,以及敏感字段不会以明文形式长期落盘。本文围绕一个订单本地缓存场景,拆解事务、数据库迁移和字段保护的工程做法。

先划清数据边界

本地数据库中可以保存用于离线展示、检索和同步的数据,例如订单摘要、同步游标和业务状态。访问令牌、支付凭据和完整身份证明等高敏感信息不应直接作为普通列写入数据库。数据库加密只能降低设备文件被直接读取的风险,不能替代密钥生命周期管理和访问控制。

一个可维护的表结构应区分业务主键、同步元数据和展示字段:

ts 复制代码
import relationalStore from '@ohos.data.relationalStore'

const STORE_CONFIG: relationalStore.StoreConfig = {
  name: 'orders.db',
  securityLevel: relationalStore.SecurityLevel.S3,
  encrypt: true,
  autoCleanDirtyData: true
}

const CREATE_ORDER_TABLE = `
  CREATE TABLE IF NOT EXISTS orders (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    status INTEGER NOT NULL,
    amountCents INTEGER NOT NULL,
    updatedAt INTEGER NOT NULL,
    syncState INTEGER NOT NULL DEFAULT 0,
    version INTEGER NOT NULL DEFAULT 1
  )`

amountCents 使用整数保存金额,避免浮点误差;updatedAt 和 version 为后续增量同步、冲突判断留下依据;syncState 则让本地变更和服务端同步解耦。表结构不应把"当前 UI 需要什么"直接等同于"数据库应该保存什么"。

事务保证一组写入同时成功

订单同步往往不是只写一行:需要更新订单、写入同步游标,并清理已经确认的待同步记录。如果中途某条 SQL 失败而前面的写入已经提交,下一次同步就很难判断本地处于什么状态。事务的核心价值是让这组操作具备原子性。

ts 复制代码
async function applySyncResult(
  store: relationalStore.RdbStore,
  orders: Array<OrderRow>,
  cursor: string
): Promise<void> {
  store.beginTransaction()
  try {
    for (const order of orders) {
      const predicates = new relationalStore.RdbPredicates('orders')
      predicates.equalTo('id', order.id)
      const values = {
        id: order.id,
        title: order.title,
        status: order.status,
        amountCents: order.amountCents,
        updatedAt: order.updatedAt,
        syncState: 0,
        version: order.version
      }
      await store.upsert('orders', values)
    }

    const cursorValues = { key: 'orders_cursor', value: cursor }
    await store.upsert('sync_meta', cursorValues)
    store.commit()
  } catch (error) {
    store.rollBack()
    throw error
  }
}

事务边界应覆盖同一个业务动作,而不是覆盖整个同步流程。网络请求、用户交互和长时间计算不要放在事务内部,否则会长时间占用数据库资源。正确的顺序是先在事务外完成网络请求和数据校验,再用一个短事务提交已经准备好的结果。

在并发场景下,还需要限制同一数据库的写入入口。多个页面同时触发同步时,建议由仓储层维护串行写队列,或者用任务调度器合并请求。事务解决的是一组 SQL 的原子性,不会自动解决业务层重复同步和请求顺序问题。

迁移要可重复、可回滚

数据库版本升级不能只修改 CREATE TABLE 字符串,因为已存在的数据库不会重新执行它。应用启动时应读取当前版本,根据版本差异依次执行迁移,并在每个版本迁移中保持幂等。

ts 复制代码
const DB_VERSION = 3

async function migrate(store: relationalStore.RdbStore, oldVersion: number): Promise<void> {
  store.beginTransaction()
  try {
    if (oldVersion < 2) {
      await store.executeSql('ALTER TABLE orders ADD COLUMN version INTEGER NOT NULL DEFAULT 1')
    }
    if (oldVersion < 3) {
      await store.executeSql(
        'CREATE INDEX IF NOT EXISTS idx_orders_updated_at ON orders(updatedAt)'
      )
      await store.executeSql(`
        CREATE TABLE IF NOT EXISTS sync_meta (
          key TEXT PRIMARY KEY,
          value TEXT NOT NULL
        )`)
    }
    store.commit()
  } catch (error) {
    store.rollBack()
    throw error
  }
}

真实项目中,迁移回调的具体签名取决于使用的 API 版本和封装方式,但原则不变:每个版本只负责从一个已知状态前进到下一个状态;迁移操作要有明确顺序;新增列提供默认值;索引和辅助表使用 IF NOT EXISTS;任意一步失败都不能把版本标记为已完成。

发布前应准备至少三类测试数据库:全新安装、从上一个正式版本升级、跨多个历史版本升级。还要验证迁移执行两次不会报错,以及迁移失败后再次启动仍能继续处理,而不是跳过失败步骤。

不要把参数拼进 SQL

查询条件和用户输入必须通过参数绑定传递,不能用字符串拼接。除了 SQL 注入风险,拼接还会导致引号、空值和类型转换问题。

ts 复制代码
async function findOrders(
  store: relationalStore.RdbStore,
  keyword: string
): Promise<Array<OrderRow>> {
  const predicates = new relationalStore.RdbPredicates('orders')
  predicates.like('title', `%${keyword}%`)
  predicates.orderByDesc('updatedAt')
  const result = await store.query(predicates, [
    'id', 'title', 'status', 'amountCents', 'updatedAt', 'version'
  ])
  return rowsFromResultSet(result)
}

查询结果集使用完毕后应及时释放。大表查询要限制列和返回数量,分页优先使用稳定排序字段,避免只依赖不断变化的偏移量。对于同步和列表展示这类高频路径,应该根据真实查询条件建立索引,并通过性能工具验证索引是否被使用,而不是看到"有索引"就认为查询一定快。

敏感字段采用分层保护

数据库配置中的 encrypt 和安全等级用于提高数据库文件的保护能力,但应用层仍需考虑字段暴露、日志泄漏和密钥存储。推荐分三层处理:

  • 普通业务数据按数据库访问权限和数据库加密保护。
  • 可恢复但敏感的字段在写入前使用平台密钥服务生成的密钥加密,密钥本身不写入数据库。
  • 不需要恢复的秘密信息只保存不可逆摘要或短期引用,避免保存原文。
ts 复制代码
async function saveProtectedNote(
  store: relationalStore.RdbStore,
  orderId: string,
  plainText: string
): Promise<void> {
  const cipherText = await cryptoService.encryptWithAppKey(plainText)
  const values = { orderId, noteCipherText: cipherText }
  const predicates = new relationalStore.RdbPredicates('order_private')
  predicates.equalTo('orderId', orderId)
  await store.update(values, predicates)
}

示例中的 cryptoService 应封装密钥生成、密钥别名、轮换和解密失败处理。不要把密钥硬编码在 ArkTS 文件、偏好设置或数据库中,也不要把明文放入 console.info、异常对象和埋点参数。密文列需要预留版本信息,以便未来轮换算法或密钥时能够识别旧数据并迁移。

处理同步冲突和删除

本地缓存不仅会被服务端覆盖,还可能存在离线编辑。简单的"最后写入者获胜"需要可信的时间或版本条件,否则设备时间偏差会导致新数据被旧数据覆盖。更稳妥的做法是使用服务端版本号或单调递增修订号,并在更新时带上上一次已知版本。

软删除也要有明确策略。直接删除本地行会让离线同步无法知道该记录曾经存在;可以保留 deletedAt 或删除标记,待服务端确认后再清理。清理任务应在事务内完成,并保留足够的恢复窗口,不能因为一次启动就批量删除所有异常状态数据。

故障恢复与可观测性

数据库初始化失败、迁移失败、磁盘空间不足和损坏恢复应分别统计。错误日志只记录数据库版本、操作类型和错误分类,不记录 SQL 中的敏感参数。遇到迁移失败时,优先保留原数据库并导出诊断信息,避免直接删除数据库导致用户数据不可恢复。

建议为仓储层补充以下测试:

  • 批量写入中途失败后,订单和同步游标都保持旧状态。
  • 迁移从每个历史版本升级到当前版本,字段和索引符合预期。
  • 重复执行迁移不会改变数据或产生异常。
  • 并发同步只允许一个提交顺序,旧版本不能覆盖新版本。
  • 敏感字段不出现在日志、异常文本和调试导出中。
  • 数据库重建或升级失败时,应用能够进入可提示、可恢复的状态。

总结

relationalStore 的生产级使用可以归结为四个边界:用事务划定一次业务提交,用版本迁移管理结构演进,用参数绑定保证查询安全,用平台密钥与数据库加密共同保护敏感数据。再配合串行写入、冲突版本和故障可观测性,本地数据库才能从"能存数据"升级为可维护的离线数据层。

数据库设计越早明确数据边界,后续的同步、升级和安全治理成本越低。先把事务和迁移测试补齐,再根据真实查询和数据敏感等级选择索引与加密策略,通常比上线后被动修复数据问题更可靠。

相关推荐
lcarusZZZ2 小时前
前端转型,自建新闻转播客工作流
前端
vipxieliang2 小时前
JavaScript 事件循环与任务队列:从 setTimeout 到 Promise
前端·javascript
ZoeLandia2 小时前
TypeScript 05:交叉类型、索引签名、元组与 enum
前端·typescript
anyup_前端梦工厂2 小时前
uni-app 模板 Starter 一键清理 Skill 发布
前端·人工智能·uni-app
行者全栈架构师2 小时前
【鸿蒙心迹】冷启动从 2.8s 优化到 0.9s——耗时拆解与数据对比实战(HarmonyOS 7.x)
前端·算法·架构
临期冰淇淋3 小时前
MTK平台 安卓16 GMS报错修改
android·java·前端
Misnearch3 小时前
React中的回调
前端·javascript·react.js
Profile排查笔记3 小时前
浏览器多开怎么设置独立 IP?Profile、代理与核验流程
前端·人工智能·后端·自动化
anyup_前端梦工厂4 小时前
将uni-app工程迁移uni-app x,AI 能帮我们做什么?
前端·人工智能·uni-app