事务加持防超卖:ArkTS 在鸿蒙里玩转 TRANSACTION 出入库

实例:库存进销存(Inventory)|技术:TRANSACTION 批量出入库、库存预警

一、为什么要事务:一个真实的数据灾难场景

假设没有事务,出库操作分两步执行:

复制代码
第 1 步:插入出库流水 stock_flow(quantity=10)
第 2 步:更新商品库存 product.stock = stock - 10

如果第 2 步执行前应用崩溃(或第 2 步 SQL 报错),数据库就处于中间状态 :流水里记了「出库 10 件」,但商品库存还是原值------账面与实际不符,盘点对不上账。更危险的是超卖:两个并发出库请求同时读到 stock=10,各自校验「库存充足」后都执行出库,最后库存变成负数。

TRANSACTION 事务解决的就是这类「多步写操作必须原子化」的问题:要么全部成功(commit),要么全部回滚(rollBack),绝无中间状态。进销存系统里,每一笔出入库都必须是事务------这是行业铁律。

二、RDB 事务 API 三件套

鸿蒙 relationalStore 提供三个事务方法,构成标准使用模式:

方法 作用 类比
beginTransaction() 开启事务 盖上印章:开始记账
commit() 提交,所有操作生效 签字确认:账目生效
rollBack() 回滚,所有操作撤销 撕掉重记:全部作废

标准模板(try-catch 包裹,任何异常回滚):

typescript 复制代码
try {
  await store.beginTransaction();
  // ... 多个写操作(insert/update/delete)
  await store.commit();
} catch (e) {
  await store.rollBack();  // 任何一步失败,全部撤销
}

关键认知 :事务开始后,所有写操作对其他连接不可见(隔离性);只有 commit 后数据才真正落盘。如果中途任何操作抛异常,rollBack 把前面所有操作全部撤销,数据库恢复到事务开始前的状态。

三、出入库事务的完整实现

落地版的 stockInOut 是本书最完整的单事务示例------它同时承担:写流水、校验库存、更新库存、超卖拦截:

typescript 复制代码
static async stockInOut(context: common.Context, op: StockOp): Promise<boolean> {
  const store = await InventoryDao.getStore(context);
  let ok = false;
  try {
    await store.beginTransaction();
    const totalPrice = op.quantity * op.unitPrice;
    // 1. 写流水
    const flowValues: relationalStore.ValuesBucket = {
      product_id: op.productId, type: op.type, quantity: op.quantity,
      unit_price: op.unitPrice, total_price: totalPrice,
      flow_time: Date.now(), remark: op.remark,
    };
    await store.insert(InventoryDao.FLOW_TABLE, flowValues);
    // 2. 更新库存(出库前检查余量,不足则回滚)
    if (op.type === 1) {
      const check = await store.querySql(
        `SELECT stock FROM ${InventoryDao.TABLE} WHERE id = ${op.productId}`
      );
      let stock = 0;
      if (check.goToNextRow()) {
        stock = check.getLong(check.getColumnIndex('stock'));
      }
      check.close();
      if (stock < op.quantity) {
        await store.rollBack();  // 库存不足,回滚(流水也撤销)
        return false;
      }
      await store.executeSql(
        `UPDATE ${InventoryDao.TABLE} SET stock = stock - ${op.quantity}, updated_time = ${Date.now()} WHERE id = ${op.productId}`
      );
    } else {
      await store.executeSql(
        `UPDATE ${InventoryDao.TABLE} SET stock = stock + ${op.quantity}, updated_time = ${Date.now()} WHERE id = ${op.productId}`
      );
    }
    await store.commit();
    ok = true;
  } catch (e) {
    try {
      await store.rollBack();
    } catch (e2) {
      // 忽略回滚失败
    }
    hilog.error(DOMAIN, TAG, `出入库事务失败: ${e}`);
  }
  return ok;
}

逐段拆解

第 1 步:写流水 。无论入库出库,先插入流水记录(total_price = quantity × unit_price 冗余计算)。注意:这一步在事务内------如果后续失败,这条流水会被回滚,不会出现「孤儿流水」。

第 2 步:出库预检。出库(type=1)时先查当前库存:

  • stock < quantity库存不足 ,主动 rollBack() 并返回 false------前面插入的流水也被撤销,数据完全恢复;
  • 充足 → 执行 stock = stock - quantity 原子递减。

关于 stock = stock - ${op.quantity} 的原子性 :这个 UPDATE 是在数据库内部完成的读-改-写,SQLite 对单条 UPDATE 自带原子性(隐式事务),即使并发也不会超卖------两个请求串行执行,第二个看到的是第一个减完后的值。事务 + 原子 UPDATE 双保险,彻底杜绝超卖

第 3 步:入库直接累加 。入库(type=0)无校验,stock = stock + quantity

第 4 步:commit / rollBack 。全部成功 commit(),异常或预检失败 rollBack(),返回值 ok 告诉页面成功与否。

返回值设计Promise<boolean> 而非抛出异常------页面通过 if (ok) 区分「成功」和「库存不足被拒绝」,弹出不同提示(「📦 入库成功」vs「库存不足,操作已回滚」),比 try-catch 语义更清晰。

四、为什么预检要在事务内?

有人会问:库存预检(SELECT stock)可以放在事务开始之前吗?答案:不行

原因在于并发------如果在事务外先 SELECT 再 beginTransaction,两个请求可能同时读到 stock=10(都认为充足),然后都进入事务执行出库,最终库存变负。预检必须放在事务内:beginTransaction 之后,SQLite 的读写锁保证同一时刻只有一个事务在写,预检读到的是最新的、稳定的库存值。

更严谨的方案是 UPDATE ... WHERE stock >= quantity 带条件更新,通过「受影响行数」判断是否成功:

sql 复制代码
UPDATE product SET stock = stock - 10 WHERE id = 1 AND stock >= 10;
-- 受影响行数为 0 说明库存不足(条件不满足)

这是「乐观锁」思路,用 SQL 条件代替先查后改。落地版选择先查后改是因为逻辑更直观、便于教学;生产环境两者皆可,条件更新略优(少一次查询)。

五、事务的隔离性与性能

隔离性:RDB 默认的隔离级别下,事务内未提交的修改对其他连接不可见。这意味着:

  • 事务 A 写入流水但未 commit 时,页面其他查询看不到这条流水(一致性读);
  • commit 后立即可见。

性能:事务把多次写操作合并为一次磁盘 fsync,比逐条自动提交快一个数量级------批量导入场景收益巨大(5-4 文章预告过)。对单笔出入库(2 次写),事务开销可忽略。

六、删除商品的级联事务

删除商品也涉及双表(先删流水再删商品),虽然单用户场景无并发风险,但包进事务更规范:

typescript 复制代码
static async deleteProduct(context: common.Context, id: number): Promise<void> {
  const store = await InventoryDao.getStore(context);
  const flowPred = new relationalStore.RdbPredicates(InventoryDao.FLOW_TABLE);
  flowPred.equalTo('product_id', id);
  await store.delete(flowPred);   // 先删流水
  const productPred = new relationalStore.RdbPredicates(InventoryDao.TABLE);
  productPred.equalTo('id', id);
  await store.delete(productPred);  // 再删商品
}

顺序依然是「先子后主」(先删流水再删商品),避免孤儿流水。当前未包事务(Demo 简化),生产版本应加上------删除中途崩溃可能留下「商品已删但流水还在」的脏数据。

七、技术要点对照表

技术点 实现方式 生产价值
原子写入 beginTransaction/commit/rollBack 流水+库存要么全成要么全撤
超卖拦截 事务内预检 stock < quantity 并发下库存不为负
原子递减 stock = stock - N 单条 UPDATE 自带原子性
条件更新 UPDATE ... WHERE stock >= N 乐观锁替代先查后改
回滚语义 失败返回 false 页面区分成功/被拒
级联删除 先删流水再删商品 无孤儿数据

八、常见问题 FAQ

Q1:事务能嵌套吗?

A:RDB 支持嵌套 beginTransaction(内部事务并入外部),但建议避免------嵌套事务的语义容易混乱。一个业务动作一个事务,保持扁平。

Q2:事务内可以查询吗?

A:可以。事务内的 SELECT 读到的是「事务内已修改但未提交」的数据(自身可见),其他连接读不到。预检查询正是利用这一点。

Q3:rollBack 失败怎么办?

A:罕见但可能(如连接已断)。落地版用嵌套 try-catch 吞掉回滚异常并记日志。生产环境应上报监控------回滚失败意味着数据可能处于中间状态,需要人工介入。

Q4:为什么返回 boolean 而不是抛异常?

A:库存不足是业务预期 (不是编程错误),用返回码表达比异常更清晰。真正的异常(SQL 错误)仍走 catch 分支回滚。业务拒绝用返回码,系统错误用异常------这是 API 设计的通行准则。

Q5:出入库能批量吗(一次多商品)?

A:可以,把所有商品的流水插入 + 库存更新放在同一个事务里即可------一次 commit 保证整批原子。库存充足性预检可以提前循环完成,或依赖「条件更新受影响行数」判断。

九、文章小结

本篇文章讲解了进销存最核心的 TRANSACTION 事务:beginTransaction / commit / rollBack 三件套 保证「流水 + 库存」原子更新,事务内预检库存杜绝超卖,stock = stock - N 原子递减做双保险,失败返回 boolean 让页面语义清晰。这是本书 20 个实例中第一次正式使用事务,也是数据一致性的分水岭------从本实例起,凡是「多步写操作」都必须问自己:这个操作需要事务吗?

下一篇(6-4)展示 12 商品 + 20 条流水的种子数据,让预警看板一开屏就「红灯闪烁」。

十、事务的进阶话题

10.1 事务的四大特性(ACID)

理解事务为什么可靠,需要知道数据库事务的四大特性:

特性 含义 在本实例的体现
原子性 Atomicity 要么全成要么全撤 流水+库存同生共死
一致性 Consistency 事务前后数据合法 库存永不出现负数
隔离性 Isolation 事务间互不可见 未提交的修改其他连接看不到
持久性 Durability 提交后永久保存 commit 后崩溃不丢数据

RDB 默认提供了完整的 ACID 保障,开发者只需要正确地使用事务三件套即可。理解这四个特性,是理解「为什么事务能解决一致性问题」的理论基础。

10.2 事务与批量操作的性能

一个常见的性能误区是「每笔操作都开事务最安全」。实际上每笔 insert 默认是隐式事务 (自动提交),大批量写入时反复 fsync 磁盘是性能瓶颈。正确的做法是手动包一个大事务

typescript 复制代码
await store.beginTransaction();
for (const item of batchItems) {
  await store.insert(TABLE, item);  // 循环插入
}
await store.commit();  // 一次提交,一次磁盘落盘

实例 6 的单笔出入库(2 次写)用不用事务性能差异极小,但批量导入(如初始化 1000 条商品)必须手动事务------5-4 文章预告过,这里正式展开。性能优化的顺序:先正确(事务保证一致),再高效(批量合并提交)

10.3 事务内查询的一致性读

事务内执行 SELECT 读到的是「事务开始时的快照」还是「最新数据」?RDB 的默认行为:事务内自身写入的修改对自身可见,其他连接未提交的修改不可见。这意味着:

  • 事务内先 UPDATE 再 SELECT,读到的是更新后的值(自身可见);
  • 页面其他查询(事务外)在 commit 前看不到事务内的中间状态(隔离性)。

这正是预检(SELECT stock)必须在事务内的原因------它读到的是事务内稳定一致的库存,不会读到其他并发出库的中间值。

10.4 什么时候不需要事务

事务不是万能的,以下场景不需要:

  1. 单条写操作:单条 INSERT/UPDATE/DELETE 自带原子性(隐式事务),无需手动包裹;
  2. 纯查询:SELECT 不修改数据,无事务需求;
  3. 可容忍最终一致:非关键数据的异步统计(如点赞数延迟 +1),可以不用事务。

判断标准很简单:一次业务操作涉及「多条写语句」且「数据一致性不可妥协」 → 必须事务。进销存出入库、订单下单、转账汇款都属于这类;浏览记录、日志上报不属于。

十一、技术要点对照表(补充)

技术点 实现方式 生产价值
ACID RDB 默认保障 原子/一致/隔离/持久
隐式事务 单条写自动提交 单条操作无需包裹
批量事务 begin + 循环 + commit 大批量导入性能提升
一致性读 事务内快照 预检读到稳定值
乐观锁 UPDATE ... WHERE stock >= N 条件更新判断余量

十二、动手实验建议

在 DevEco Studio 里验证事务的行为:

  1. 超卖拦截实验:把清风抽纸库存改成 0(数据库工具),页面执行出库 1 件 → 观察「库存不足,操作已回滚」提示,数据库确认流水没新增;
  2. 原子性实验 :临时注释掉 stockInOut 里的 commit(模拟崩溃),执行出入库 → 观察数据没有变化(事务未提交,修改被回滚);
  3. 批量事务实验:写一个循环插入 1000 条流水的测试方法,分别用「逐条(隐式事务)」和「手动事务」计时,对比耗时差异------你会直观感受到事务批量提交的性能优势。

十三、事务调试技巧

实际开发中事务问题最难排查,这里给三个调试技巧:

1. 用日志标记事务边界。在 begin/commit/rollBack 前后打 hilog 日志,异常时能快速定位「事务开没开、提交没提交」:

typescript 复制代码
hilog.info(DOMAIN, TAG, 'beginTransaction');
await store.beginTransaction();
try {
  // ...操作
  await store.commit();
  hilog.info(DOMAIN, TAG, 'commit 成功');
} catch (e) {
  await store.rollBack();
  hilog.error(DOMAIN, TAG, `rollBack: ${e}`);
}

2. 检查「事务未提交」的典型症状。页面数据不更新、但数据库工具能看到记录------大概率是 commit 没执行(异常被吞)。检查 catch 分支是否遗漏 commit,或事务内是否提前 return。

3. rollBack 的幂等性。rollBack 在事务未开启或已提交时调用会抛异常------落地版用嵌套 try-catch 吞掉,但生产环境应记录日志,因为「回滚失败」本身是异常信号,值得关注。

相关推荐
世人万千丶1 小时前
去重插入与超限淘汰:ArkTS 实现鸿蒙搜索历史的 LIMIT 艺术
运维·服务器·学习·华为·harmonyos·鸿蒙
2601_949950632 小时前
在线练题更方便,用练题簿打造属于自己的移动题库
学习·考研·刷题·小程序推荐
贾伟康2 小时前
【知律|08】HarmonyOS ArkTS 语音阅读实战:管理法条朗读状态和中断恢复
生命周期·harmonyos·arkts·语音合成·arkui
woshihuanglaoshi3 小时前
十二商品二十流水:鸿蒙进销存种子数据装满预警看板
学习·华为·harmonyos
MartinYeung53 小时前
[论文学习]ProAct:针对LLM越狱的主动防禦框架
网络·学习·安全
weixin_431600444 小时前
NestJS 入门(10):日志——为什么常用 Winston?
后端·学习·日志·nest.js·winston
何事误红尘4 小时前
ZYNQ学习ARM裸机笔记():VITIS
学习
不懂的浪漫5 小时前
吴恩达《AI Engineering Skills Map》译读:四项核心能力与持续学习底座
人工智能·学习
YM52e6 小时前
六十条商品铺满三页:鸿蒙分页列表种子数据与加载效果
华为·harmonyos