


实例:库存进销存(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 什么时候不需要事务
事务不是万能的,以下场景不需要:
- 单条写操作:单条 INSERT/UPDATE/DELETE 自带原子性(隐式事务),无需手动包裹;
- 纯查询:SELECT 不修改数据,无事务需求;
- 可容忍最终一致:非关键数据的异步统计(如点赞数延迟 +1),可以不用事务。
判断标准很简单:一次业务操作涉及「多条写语句」且「数据一致性不可妥协」 → 必须事务。进销存出入库、订单下单、转账汇款都属于这类;浏览记录、日志上报不属于。
十一、技术要点对照表(补充)
| 技术点 | 实现方式 | 生产价值 |
|---|---|---|
| ACID | RDB 默认保障 | 原子/一致/隔离/持久 |
| 隐式事务 | 单条写自动提交 | 单条操作无需包裹 |
| 批量事务 | begin + 循环 + commit | 大批量导入性能提升 |
| 一致性读 | 事务内快照 | 预检读到稳定值 |
| 乐观锁 | UPDATE ... WHERE stock >= N |
条件更新判断余量 |
十二、动手实验建议
在 DevEco Studio 里验证事务的行为:
- 超卖拦截实验:把清风抽纸库存改成 0(数据库工具),页面执行出库 1 件 → 观察「库存不足,操作已回滚」提示,数据库确认流水没新增;
- 原子性实验 :临时注释掉
stockInOut里的 commit(模拟崩溃),执行出入库 → 观察数据没有变化(事务未提交,修改被回滚); - 批量事务实验:写一个循环插入 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 吞掉,但生产环境应记录日志,因为「回滚失败」本身是异常信号,值得关注。