巡检员在没有网络的机房里连续录入五条记录,点击“保存本组”。如果前四条写入成功、第五条因为字段问题失败,页面却弹出“本组完成”,数据库里就留下了一批看似完整、实际上少一条的记录。麻烦还不止于此:用户可能连点两次按钮,后台自动保存也可能同时触发。如果两条写入链路共用同一个 RdbStore 并且交错开启事务,批量边界就更难推断。

前两篇已经给离线巡检建立了 DraftRepository,将 inspection_draft.db 从旧四列升级到五列:id 自增主键,title、body、updated_at 保持既有语义,priority 是默认值为 0 的整数。本阶段不引入网络同步,不改历史主键,也不假装事务能替代去重机制。只处理一件很具体的事:一组本地草稿要么一起写入,要么一起撤销,并且同一仓储实例内的写入顺序要确定。

离线巡检批量事务与写入边界技术封面

“批量”有三层含义,不能只看插入速度

业务层说的批量,首先是一个用户动作:本次巡检包含一组相互关联的条目。存储层说的批量,是把若干行一起提交;并发控制说的批量,则要求另一个写入请求不能在中间插队。三层含义常常被一个 for 循环掩盖:逐条 await db.insert() 虽然也是多条插入,但没有事务时,每成功一条就可能留下永久痕迹。反过来,batchInsert 提供批量插入能力,却不能替业务决定哪些记录必须归为一个原子单元,也不能阻止用户重复提交整组内容。

此处先约定一个最小合同:DraftInput { title, body, priority } 代表待写入的数据,DraftRecord 继续包含数据库主键和更新时间。输入全部经过 trim 和优先级范围检查;只有整组校验完成,才向数据库申请写入。空数组不是保存异常,而是无操作,返回 0;任意一项为空则整组拒绝,避免用户误以为“剩余四条已经保存”。页面可据此分别显示“没有需要保存的记录”“校验失败”“已写入 N 条”,不要把它们统一成“失败,请重试”。

输入校验、事务边界与提交回滚流程图

迁移期间也要守住入口。与第二篇相同,数据库仍由 ready(context) 等待 getRdbStore() 和 Schema 升级完成后才对外提供;版本高于当前支持范围时拒绝写入。如果业务层绕过仓储直接拿到一个刚创建但尚未迁移完成的连接,就可能在字段不存在时启动批量插入。通过仓储管理初始化与写入,不仅代码组织更清楚,更重要的是建立一个稳定的数据库可写条件。

在现有仓储上增加有序的提交队列

为了与系列保持连贯,下列代码给出 entry/src/main/ets/model/DraftRepository.ets 的完整可替换版本,包含上一阶段的 ready()、migrate()、all() 和既有数据库配置。它不改变既有字段与主键含义,仍只在低版本数据库上执行迁移 SQL。关键是让所有写操作都经过同一个 writeSerial(),否则只有批量方法排队、原来的单条 add() 不排队,仍然存在并发交错。这里的顺序队列属于同一仓储实例,不宣称它能约束跨进程、多个数据库实例或其他绕过仓储的代码。

import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

export interface DraftRecord {
  id: number;
  title: string;
  body: string;
  updatedAt: number;
  priority: number;
}

export interface DraftInput {
  title: string;
  body: string;
  priority: number;
}

const CONFIG: relationalStore.StoreConfig = {
  name: 'inspection_draft.db',
  securityLevel: relationalStore.SecurityLevel.S1
};
const CREATE_V1: string =
  'CREATE TABLE IF NOT EXISTS inspection_draft (' +
  'id INTEGER PRIMARY KEY AUTOINCREMENT, ' +
  'title TEXT NOT NULL, body TEXT NOT NULL, updated_at INTEGER NOT NULL)';
const ADD_PRIORITY: string =
  'ALTER TABLE inspection_draft ADD COLUMN priority INTEGER NOT NULL DEFAULT 0';

export class DraftRepository {
  private store: relationalStore.RdbStore | null = null;
  private opening: Promise<relationalStore.RdbStore> | null = null;
  private writeTail: Promise<void> = Promise.resolve();
  private writeBlocked: boolean = false;

  private async migrate(db: relationalStore.RdbStore): Promise<void> {
    if (db.version > 2) throw new Error('数据库版本高于当前应用支持范围');
    if (db.version === 2) return;
    db.beginTransaction();
    try {
      if (db.version === 0) await db.executeSql(CREATE_V1);
      if (db.version < 2) await db.executeSql(ADD_PRIORITY);
      db.version = 2;
      db.commit();
    } catch (error) {
      try { db.rollBack(); } catch (_) { /* 保留原错误并关闭写入口 */ }
      throw error;
    }
  }

  private async ready(context: common.UIAbilityContext):
    Promise<relationalStore.RdbStore> {
    if (this.store !== null) return this.store;
    if (this.opening === null) {
      this.opening = (async (): Promise<relationalStore.RdbStore> => {
        const db = await relationalStore.getRdbStore(context, CONFIG);
        await this.migrate(db);
        this.store = db;
        return db;
      })();
    }
    try {
      return await this.opening;
    } finally {
      this.opening = null;
    }
  }

  private async writeSerial<T>(work: () => Promise<T>): Promise<T> {
    const previous: Promise<void> = this.writeTail;
    let release: () => void = () => {};
    this.writeTail = new Promise<void>((resolve: () => void): void => {
      release = resolve;
    });
    await previous;
    try {
      return await work();
    } finally {
      release();
    }
  }

  private normalize(inputs: DraftInput[]): relationalStore.ValuesBucket[] {
    if (inputs.length > 200) throw new Error('一次最多保存200条巡检草稿');
    const now: number = Date.now();
    return inputs.map((item: DraftInput): relationalStore.ValuesBucket => {
      const title: string = item.title.trim();
      const body: string = item.body.trim();
      if (!title || !body) throw new Error('本组存在标题或内容为空的记录');
      if (!Number.isInteger(item.priority) ||
          item.priority < 0 || item.priority > 3) {
        throw new Error('priority必须是0至3的整数');
      }
      return { title, body, updated_at: now, priority: item.priority };
    });
  }

  async addBatch(context: common.UIAbilityContext,
    inputs: DraftInput[]): Promise<number> {
    if (inputs.length === 0) return 0;
    const values: relationalStore.ValuesBucket[] = this.normalize(inputs);
    return await this.writeSerial(async (): Promise<number> => {
      if (this.writeBlocked) throw new Error('数据库需人工恢复后再写入');
      const db: relationalStore.RdbStore = await this.ready(context);
      db.beginTransaction();
      try {
        const inserted: number =
          await db.batchInsert('inspection_draft', values);
        if (inserted !== values.length) {
          throw new Error('批量写入数量与输入数量不一致');
        }
        db.commit();
        return inserted;
      } catch (error) {
        try {
          db.rollBack();
        } catch (rollbackError) {
          this.writeBlocked = true;
          throw new Error('事务回滚失败,请暂停写入并检查数据库');
        }
        throw error;
      }
    });
  }

  // 保持连载01/02的接口:单条写入仍返回新增自增主键。
  async add(context: common.UIAbilityContext,
    title: string, body: string): Promise<number> {
    const values: relationalStore.ValuesBucket[] = this.normalize([
      { title, body, priority: 0 }
    ]);
    return await this.writeSerial(async (): Promise<number> => {
      if (this.writeBlocked) throw new Error('数据库需人工恢复后再写入');
      const db: relationalStore.RdbStore = await this.ready(context);
      return await db.insert('inspection_draft', values[0]);
    });
  }

  async all(context: common.UIAbilityContext): Promise<DraftRecord[]> {
    const db = await this.ready(context);
    const predicate = new relationalStore.RdbPredicates('inspection_draft');
    const cursor = await db.query(predicate,
      ['id', 'title', 'body', 'updated_at', 'priority']);
    const records: DraftRecord[] = [];
    try {
      const idIndex = cursor.getColumnIndex('id');
      const titleIndex = cursor.getColumnIndex('title');
      const bodyIndex = cursor.getColumnIndex('body');
      const timeIndex = cursor.getColumnIndex('updated_at');
      const priorityIndex = cursor.getColumnIndex('priority');
      while (cursor.goToNextRow()) {
        records.push({
          id: cursor.getLong(idIndex),
          title: cursor.getString(titleIndex),
          body: cursor.getString(bodyIndex),
          updatedAt: cursor.getLong(timeIndex),
          priority: cursor.getLong(priorityIndex)
        });
      }
    } finally {
      cursor.close();
    }
    return records.sort((a: DraftRecord, b: DraftRecord) =>
      b.updatedAt - a.updatedAt);
  }
}

这段逻辑刻意把事务开始位置放在 writeSerial 的内部:轮到当前请求时才拿连接并开启事务,执行完 commit() 或 rollBack() 后才释放队列。writeTail 保存的是一个永远以 resolve() 结束的排队信号,因此前一个请求失败不会永久阻断后续请求。每次 addBatch 都有自己的 work,返回值和异常只回到这次调用者,不会被下一次批处理覆盖。

必须特别说明 add() 的兼容边界:为了让老页面不改变调用形式,示例继续允许 add(context,title,body),且它返回的仍然是新增记录的自增主键**,保持了第一篇 insert() 的接口语义。addBatch() 返回的是写入条数,两个方法返回值不同,页面不能混用。原来的 add() 也进入相同的 writeSerial(),并没有为了复用批处理而悄悄改变已有调用方的行为。这个兼容约束比“代码能跑起来”重要得多。

两个失败分支,比“catch到错误”更重要

第一条分支发生在事务内部:batchInsert 抛出异常,或者完成后返回的插入数量与输入数量不一致。两种情况下都应该尝试 rollBack(),并让这一次保存请求以异常结束。不能在 catch 中调用 commit() 来“保存已成功的部分”,那会把“本组必须齐全”的业务约定推翻。第二条分支更严重:回滚本身失败。这时数据库状态可能不能通过当前仓储继续推断,示例将仓储标记为不可写并向上抛出;生产工程需在人工或恢复流程核验数据库后再解除写入阻断,再决定何时接受新请求。不能因为 UI 只需要一个 Toast,就把未知持久化状态解释成“保存失败,所有数据都没了”。

事务失败点、回滚与不可写分支的红笔标注图

页面还有一种常见误区:await 本身不等于防重。用户按下保存按钮,第一次异步写入尚未返回时又触发第二次,队列虽然保证两次操作不会交错,却会顺序写入两组相同数据。要降低这种概率,页面应当在开始调用之前将 saving 置为真,并在 finally 恢复;在业务层实现真正幂等,还需要下一篇设计稳定请求标识、唯一约束或去重策略。不能把单进程的排队机制宣传成跨重启的重复提交保护。

@State private saving: boolean = false;
@State private message: string = '';
private repository: DraftRepository = new DraftRepository();

private async saveGroup(): Promise<void> {
  if (this.saving) return;
  this.saving = true;
  try {
    const items: DraftInput[] = [
      { title: '冷却泵检查', body: '记录轴承温度观察结果', priority: 2 },
      { title: '配电柜检查', body: '记录接线端子状态', priority: 1 }
    ];
    const context = getContext(this) as common.UIAbilityContext;
    const count = await this.repository.addBatch(context, items);
    this.message = `本组写入${count}条`;
  } catch (error) {
    this.message = '本组未确认保存,请检查输入并重试';
  } finally {
    this.saving = false;
  }
}

上面是接入既有页面组件的方法和字段片段;需要从仓储文件导入 DraftRepository、DraftInput,从 @kit.AbilityKit 导入 common。两个待保存项目是说明输入结构的示例数据,真正项目应从表单当前值组装,不能用每次点击自动写入同样两条的数据替换真实 UI 状态。

怎样构造有说服力的回归样本

可以准备三组可重复输入。第一组五条合法记录,调用前先记录当前 all() 行数,执行后核对新增数量和五条正文,而不是只检查返回值。第二组四条合法记录加一条空标题,预期在 normalize 阶段被整体拒绝,数据库没有新增行;注意这个样本只验证前置校验,不能冒充真正的事务中途失败。第三组需要测试专用故障注入,例如测试构建中通过可控的 SQL 约束触发插入异常,并读取数据库核对本组是否完整回滚。只有第三组能够说明事务失败路径是否真的工作。

单条保存、批量保存与双击重放的时序对比

并发测试也要分层:同一个 DraftRepository 的两个 addBatch() 几乎同时开始,预期按调用进入队列的顺序完成;接着让原来单条 add() 与批量方法同时提交,检查它们是否都通过同一个队列。如果还有后台任务自己创建了第二个仓储实例,当前队列无法覆盖那条写入链路;需要在应用架构层统一仓储实例、访问路径和事务策略。为了分析界面错误,建议同时记录请求的本地序号、开始与结束时间、结果分类,但不要把这些序号写成正式文章里的内部工单代号。

有时单条 insert() 反而比批处理更合适,例如写入一条必须立即显示结果的记录,或需要立即取得新增自增 ID 以关联后续附件。批处理适合一次接受一组明确的业务输入,不能因为“批量听起来更快”就把所有记录攒到大数组里。提交组过大还会延长数据库占用时间和重试成本,因此本例给出单组 200 条的业务上限;它是示例的防护策略,不是 HarmonyOS 数据库系统的通用限制。

这个阶段形成的可靠性分层很清晰:先校验整组业务输入;进入同实例写队列;在数据库事务中执行批量写入;成功才向用户确认,失败走回滚和错误分类。原子性解决“部分成功”,串行化解决同实例交错,幂等性解决重复请求——它们不是同一个概念。下一篇就在这套仓储上加入可重试的请求标识和重复提交防护,同时讨论应用关闭、恢复后怎样判断一组草稿是否已经落盘。

参考资料:华为开发者:关系型数据库持久化、relationalStore 接口参考、ArkData API 变更说明。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐