把库存减一,再插入借用记录,看起来只有两行代码。危险恰好藏在两行之间:第二步失败后,界面少了一件器材,却没有人能够解释它借给了谁。如果用户因为等待过久又按了一次按钮,另一类问题还会出现——同一次借用被记录两遍。本篇沿着这段最短的写入路径,拆开“原子提交”和“重复请求”两个独立问题,再把它们放回器材簿的仓库边界。

器材借还事务的技术封面

一、先写出不能被破坏的等式

延续第一篇 GearLedger 工程与 API 14 基线,GEAR、LOAN、SCHEMA_META 的定义保持不变,代码仍然放在 data/GearRepository.ets。同一器材需要满足:总量减去未归还流水数,等于可借数量。借出操作同时减少可借量并插入未归还流水;归还操作同时增加可借量并填写归还时间。只更新其中一项,数据库可能仍然合法,业务却已经错误。

事务解决的是一组写入作为整体提交或撤销,幂等解决的是同一业务意图再次到达时如何处理。两次独立事务都可以成功,因此把代码包进事务并不能自动阻止重复借出。我们使用 REQUEST_KEY 区分一次借用意图,并由唯一约束兜底;重试必须继续使用原编号,而重新借一件才使用新的编号。编号不承担身份认证,也不应该包含手机号等个人敏感信息。

本篇最小页面让用户输入借用单号,默认值为“录音-001”,借用人为“周可”,器材固定为编号一的无线麦克风。这种可观察的输入比在每次点击时偷偷生成新编号更适合验证幂等。正式产品可以自动生成标识,但必须在请求开始前创建,并保存到此次操作状态中;超时、重试和页面恢复都要遵守同一个标识生命周期。

库存条件仍然要在事务内部检查。首页显示还有四支麦克风,只能说明上一次查询时的情况。页面按钮、组件状态以及防抖都不能替代数据库中的最终判断。同样,不能先在普通连接上读出四,再等若干异步任务结束后写入三;这个时间窗口里,其他操作可能已经改变了库存。

我们没有提供修改流水归属的方法。把一笔已借出的麦克风记录直接改成三脚架,会同时影响两种器材的数量,已经超出普通字段编辑的含义。如果需要纠错,先归还原记录,再创建正确借用,并保留两次操作的关联依据更容易审计。数据模型是否允许直接编辑,必须由业务语义决定,不能因为数据库有更新接口就把所有字段都做成输入框。

二、把提交与回滚包成一个小边界

第一篇已经给出 serial()、store 和所需导入。下面把一个私有方法与两个公开方法加入同一个 GearRepository 类。事务内部的所有查询和写入都通过 tx,不要夹入 this.store.querySql(),否则代码表面上在一个方法里,实际可能跨越了不同的事务上下文。

private transaction<T>(work: (tx: relationalStore.Transaction) => Promise<T>): Promise<T> {
  return this.serial(async () => {
    const tx = await this.store.createTransaction();
    try {
      const value = await work(tx);
      await tx.commit();
      return value;
    } catch (error) {
      try { await tx.rollback(); } catch (rollbackError) {
        console.error(`借还回滚异常:${(rollbackError as BusinessError).code}`);
      }
      throw error as Error;
    }
  });
}

borrow(gearId: number, borrower: string, requestKey: string): Promise<number> {
  const person = borrower.trim();
  const key = requestKey.trim();
  if (person.length === 0 || key.length === 0 || key.length > 80) {
    return Promise.reject(new Error('借用人和借用单号不合法'));
  }
  return this.transaction(async (tx: relationalStore.Transaction) => {
    const prior = await tx.querySql(
      'SELECT ID, GEAR_ID, BORROWER FROM LOAN WHERE REQUEST_KEY = ?', [key]);
    try {
      if (prior.goToNextRow()) {
        if (prior.getLong(1) !== gearId || prior.getString(2) !== person) {
          throw new Error('借用单号与原记录不一致');
        }
        return prior.getLong(0);
      }
    } finally { prior.close(); }
    const stock = await tx.querySql('SELECT AVAILABLE FROM GEAR WHERE ID = ?', [gearId]);
    let available = 0;
    try {
      if (!stock.goToNextRow()) { throw new Error('器材不存在'); }
      available = stock.getLong(0);
    } finally { stock.close(); }
    if (available < 1) { throw new Error('可借库存不足'); }
    const match = new relationalStore.RdbPredicates('GEAR');
    match.equalTo('ID', gearId).equalTo('AVAILABLE', available);
    const values: relationalStore.ValuesBucket = { AVAILABLE: available - 1 };
    if (await tx.update(values, match) !== 1) { throw new Error('库存已变化,请重试'); }
    const loan: relationalStore.ValuesBucket = {
      GEAR_ID: gearId, BORROWER: person, REQUEST_KEY: key,
      CREATED_AT: Date.now(), RETURNED_AT: 0
    };
    return tx.insert('LOAN', loan);
  });
}

returnLoan(loanId: number): Promise<void> {
  return this.transaction(async (tx: relationalStore.Transaction) => {
    const loan = await tx.querySql('SELECT GEAR_ID, RETURNED_AT FROM LOAN WHERE ID = ?', [loanId]);
    let gearId = 0;
    try {
      if (!loan.goToNextRow()) { throw new Error('借用记录不存在'); }
      if (loan.getLong(1) !== 0) { return; }
      gearId = loan.getLong(0);
    } finally { loan.close(); }
    const stock = await tx.querySql('SELECT TOTAL, AVAILABLE FROM GEAR WHERE ID = ?', [gearId]);
    let available = 0;
    try {
      if (!stock.goToNextRow()) { throw new Error('器材不存在'); }
      available = stock.getLong(1);
      if (available >= stock.getLong(0)) { throw new Error('库存与流水不一致'); }
    } finally { stock.close(); }
    const gearMatch = new relationalStore.RdbPredicates('GEAR');
    gearMatch.equalTo('ID', gearId).equalTo('AVAILABLE', available);
    const values: relationalStore.ValuesBucket = { AVAILABLE: available + 1 };
    if (await tx.update(values, gearMatch) !== 1) { throw new Error('库存已变化,请重试'); }
    const loanMatch = new relationalStore.RdbPredicates('LOAN');
    loanMatch.equalTo('ID', loanId).equalTo('RETURNED_AT', 0);
    const returned: relationalStore.ValuesBucket = { RETURNED_AT: Date.now() };
    if (await tx.update(returned, loanMatch) !== 1) { throw new Error('归还状态已变化'); }
  });
}

代码里每个结果集都在使用后关闭,提交发生在 work() 完整结束之后。不能省掉插入操作前的等待,也不能把一个尚未结束的异步写入留给事务外层处理。return tx.insert() 仍会把 Promise 交给外层等待,因此只有插入真正结束,才会继续执行提交。

失败回滚时保留原始异常,回滚自身的异常则单独记录错误码。开发阶段要关注两条线索的先后关系:第一条解释业务操作为什么失败,第二条解释清理是否完成。不要用回滚异常直接覆盖库存不足或唯一约束冲突,否则定位时只剩下一条与用户操作距离很远的信息。

事务应该尽量短。借用人确认、弹窗交互、远程请求和图片上传,都不应放在事务回调中等待。用户可能把对话框留在那里很久,数据库却持续持有事务资源,后面的器材操作只能排队。本篇在进入仓库前取得文本,在事务中只做必要查询与写入,提交后再刷新页面;这样业务等待与数据一致性边界不会互相拖累。

更新库存时附带旧值条件,也有阅读上的意义:代码明确表达了“仅更新刚刚验证过的状态”。如果将来有人错误地绕开队列,这个条件仍能帮助暴露竞争,而不是悄悄覆盖别人的修改。不过它不是事务的替代品,因为库存变化与流水写入仍然需要共同提交。单条更新很安全,并不意味着两张表之间自然一致。

DevEco Studio 中的原子借出实现

三、页面负责表达意图,仓库负责完成意图

用下面页面替换第一篇的 pages/Index.ets,数据库文件与已有数据继续保留。一个页面级忙碌状态可以减少重复点击,但真正防止重复扣减的仍是请求编号检查与唯一约束。归还按钮使用最近一次借出返回的流水编号,页面重建后该按钮重新停用;生产版本应从未归还流水列表选择记录,不能猜测一个编号。

import { common } from '@kit.AbilityKit';
import { GearRepository, GearRow } from '../data/GearRepository';

@Entry
@Component
struct Index {
  @State key: string = '录音-001';
  @State borrower: string = '周可';
  @State status: string = '填写借用单后提交';
  @State available: number = 0;
  @State ready: boolean = false;
  @State busy: boolean = false;
  @State lastLoan: number = 0;

  aboutToAppear(): void { this.run(false, true); }

  async run(returning: boolean, refreshOnly: boolean = false): Promise<void> {
    if (this.busy) { return; }
    this.busy = true;
    try {
      const repository = await GearRepository.open(getContext(this) as common.UIAbilityContext);
      if (!refreshOnly) {
        if (returning) { await repository.returnLoan(this.lastLoan); }
        else { this.lastLoan = await repository.borrow(1, this.borrower, this.key); }
        this.status = returning ? '归还已提交' : '借用单已提交';
      }
      const rows: GearRow[] = await repository.listGear();
      const mic = rows.find((row: GearRow) => row.id === 1);
      if (mic !== undefined) { this.available = mic.available; this.ready = true; }
    } catch (error) {
      this.status = (error as Error).message;
    } finally { this.busy = false; }
  }

  build() {
    Column({ space: 18 }) {
      Text('器材借还').fontSize(28)
      Text(`无线麦克风 · 可借 ${this.available} 件 / 总量 4 件`)
      TextInput({ text: this.borrower, placeholder: '借用人' })
        .onChange((value: string) => { this.borrower = value; })
      TextInput({ text: this.key, placeholder: '借用单号' })
        .onChange((value: string) => { this.key = value; })
      Button('借出一支').enabled(this.ready && !this.busy).onClick(() => { this.run(false); })
      Button('归还最近一笔').enabled(this.ready && !this.busy && this.lastLoan > 0)
        .onClick(() => { this.run(true); })
      Text(this.status).fontColor('#475569')
    }.padding(24).width('100%').height('100%').backgroundColor('#F4F7FA')
  }
}

一次重复借用会返回原流水编号,状态可以再次显示“借用单已提交”,库存却不再减少。如果原借用已经归还,再次提交同一编号仍然定位原记录,不会重新借出;这符合“同一编号代表同一次意图”的约定。界面想发起新的借用,就必须使用新编号,而不能通过删除旧流水来腾出编号。

提交过程中还要注意输入快照。仓库方法在调用开始时取得借用人与单号,随后用户即使修改输入框,也不会改变已经进入事务的参数。界面若希望操作结束后显示本次内容,应保存同一份快照,不要拿结束时的输入框文本重新拼接成功提示。否则数据库记录属于周可,提示却显示后来输入的另一个人,用户会误以为保存发生了串单。

从页面意图到提交结果的借用时序

这套代码限定为单进程仓库写入。串行队列让当前应用内的借还按顺序进行,独立事务则保护数据库中的整体更新。如果产品以后增加其他进程或扩展组件写库,可能出现数据库锁冲突,应围绕整个事务做有界重试,并继续使用原请求编号。不能在一个已经失败的事务上只重试最后一条语句,也不应对任何错误都无限循环。

四、故障注入比点击成功更能说明问题

先从初始四支麦克风开始,借出一次后预期剩三支,再用相同借用单号提交一次,预期仍为三支。修改借用人但保留单号,应该得到编号与原记录不一致的提示。这个场景检查了请求编号是否绑定业务内容,避免把另一人的借用误认为前一人的重试。

接着用新编号借出,在库存更新之后、流水插入之前临时抛出异常,确认整个操作失败后库存仍与操作前相同。移除异常再提交,应能完成借出。这个实验同时覆盖事务原子性和队列恢复:如果第二次永远无法执行,通常是队尾保留了被拒绝的 Promise;如果库存减少而没有流水,通常是操作没有处于同一事务上下文。

归还也要独立验证。第一次归还应把数量加回,第二次归还同一笔流水应保持不变。用不存在的流水编号调用仓库,应拒绝操作,而不是随便给某类器材加库存。库存已经等于总量时却仍存在未归还记录,说明历史数据不一致;这里选择停止并报告,不做“最多加到总量”的静默修正,避免把需要调查的差异掩盖掉。

时间字段也有清晰边界。Date.now() 用于记录本机上的发生时间,不能证明真实世界中的先后顺序,更不能作为唯一主键。系统时间调整时,新流水可能拥有更早的时间戳,所以主键和请求编号继续承担身份作用。后续分页会同时使用时间与流水编号建立稳定次序;如果需要跨终端审计,则要另外设计可信时间来源与同步规则。

库存、请求编号与归还状态的排查路径

排查时按照“请求是否相同、事务是否开始、写入是否完整等待、提交是否完成、刷新是否成功”的顺序阅读代码。提交成功后刷新失败,与提交本身失败也要区分;用户看到列表没有变化,并不代表数据库没有写入。因此本篇总是保留请求编号,让再次提交能够安全返回同一结果,而不会依赖界面猜测前一次操作的命运。

五、把守恒规则带到下一次改动

这里没有用大量按钮禁用逻辑包围数据库,而是把三个规则固定在仓库:借用单号对应唯一意图,库存与流水一起提交,已经归还的记录再次归还不产生增量。页面可以换样式,操作入口可以增加,只要仍然通过这两个公开方法,核心规则就能继续生效。

还有一条约束需要写进后续需求讨论:总量不能随意减少到已借出数量以下。新增“报废器材”或“盘点修正”时,应分别设计业务操作和审计依据,不能复用普通借出按钮来凑结果。下一篇处理数据库结构升级时,也会保留这些不变量,避免升级完成了,器材账却被种子数据重置。

官方参考:API 14 关系型数据库接口、Transaction 查询、更新与提交。

Logo

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

更多推荐