大家好,我是[晚风依旧似温柔],新人一枚,欢迎大家关注~
本文目录:
- 前言
- 一、转账与订单:为什么不能“分两次随便写”
- 二、HarmonyOS RDB 事务核心 API
- 关键接口一览
- 事务的内部控制原则
- 三、构建最小实践场景:主表与明细表设计
- 1. 数据库表结构设计
- 2. 数据库初始化代码
- 四、核心代码实现:事务提交与异常回滚
- 1. 正常流程:开启事务并成功 Commit
- 2. 异常回滚流程:故意制造第二步失败并执行 Rollback
- 3. 查询确认结果:验证状态恢复
- 五、关键代码分析与细节解构
- 1. `try...catch` 的作用域限定
- 2. 必须处理异步等待
- 3. 错误捕获后的完整性保证
- 六、开发中容易踩坑的地方
- 1. 事务嵌套导致的异常
- 2. 耗时异步操作阻塞数据库锁
- 3. ResultSet 未关闭导致锁不释放
- 七、哪些业务必须使用事务?
- 八、总结与排查清单
- 参考资料
前言
在移动应用开发中,本地数据库的高频写入是常见的场景。无论是商城应用创建订单、钱包应用处理转账,还是社交应用同步多条聊天记录,往往都需要向数据库的多张表中连续写入数据。
如果数据写入到一半时,系统突然抛出异常、设备断电或触发主键冲突,原本应该保持一致的数据就会陷入“半生不熟”的状态:主表里生成了订单记录,但明细表里没有任何商品;或者扣了账户 A 的余额,却未能增加账户 B 的余额。
在 HarmonyOS 应用开发中,通过@kit.ArkData(ArkData 核心数据集)提供的关系型数据库(Relational Store,简称 RDB)事务机制,可以确保此类“多步写操作”满足原子性(Atomicity),即要么全部写入成功,要么彻底撤销已写入的内容。
本文将以订单与明细写入的场景为例,介绍 HarmonyOS RDB 事务的基本运作机制,并展示完整的事务提交与回滚实践代码。
一、转账与订单:为什么不能“分两次随便写”
在关系型数据库操作中,许多业务逻辑本质上是由一组独立的 SQL 或写入指令组成的。
以电商应用“提交订单”为例,业务逻辑通常划分为两个核心写入步骤:
- 写入订单主表(orders):记录订单编号、下单用户、总金额及支付状态。
- 写入订单明细表(order_items):记录该订单包含的每个商品 ID、单价及购买数量。
如果直接使用常规的insert()接口分别执行这两步,代码的流转过程将是无保护状态的:
如果在步骤 1 执行完毕后,步骤 2 遇到了数据校验失败(如传入了无效的数据格式、违背外键约束、甚至主键冲突),或者应用发生了非预期的运行期异常,就会导致数据库中残留一条“只有订单主表、没有商品明细”的垃圾数据。
在金融转账场景中,这种单步成功的操作会导致“资金蒸发”或“凭空增加”。为了解决这一问题,必须将这一系列操作包裹在一个事务(Transaction)中。事务能够赋予操作原子性——要么全做,要么不做(All or Nothing)。
二、HarmonyOS RDB 事务核心 API
HarmonyOS 的 RDB 模块在@kit.ArkData中开放了基础的事务控制接口。通过对relationalStore.RdbStore实例进行操作,可以显式地开启、提交或回滚事务。
关键接口一览
| 接口名称 | 说明 | 适用 API Level |
|---|---|---|
beginTransaction() | 在当前 RDB 句柄上开启一个显式事务。 | API 9+ |
commit() | 提交当前事务,使事务内所有的改动永久生效。 | API 9+ |
rollback() | 回滚当前事务,撤销该事务开启后所做的所有数据修改。 | API 9+ |
事务的内部控制原则
- 独占与锁机制:调用
beginTransaction()后,RDB 句柄会进入事务状态。在此期间,其他线程或操作如果尝试在此句柄上开启新事务或进行冲突写操作,可能会触发等待或锁异常。 - 状态成对出现:每一个
beginTransaction()必须在逻辑路径上对应一个commit()或rollback()。如果既没有提交也没有回滚,会导致数据库连接处于未决状态,严重时会卡死后续的数据库读写操作。 - 异常扑捉与保障:必须利用 ArkTS 的
try...catch...finally结构,将事务的开启、执行与终止进行严格的保护。
三、构建最小实践场景:主表与明细表设计
为了验证事务的正确提交与失败回滚,我们建立一个包含两张关联表的最小实践场景。
1. 数据库表结构设计
定义两张表:
orders(订单主表):order_id(主键),user_id,total_amountorder_items(订单明细表):item_id(主键),order_id,product_name,price
2. 数据库初始化代码
在进行事务测试前,先完成数据库的获取与建表工作。
import{relationalStore}from'@kit.ArkData';import{common}from'@kit.AbilityKit';import{BusinessError}from'@kit.BasicServicesKit';exportclassTransactionDemoService{privaterdbStore:relationalStore.RdbStore|null=null;// 数据库配置privatestoreConfig:relationalStore.StoreConfig={name:'OrderDemo.db',securityLevel:relationalStore.SecurityLevel.S1};// 初始化数据库并建表publicasyncinitRdb(context:common.UIAbilityContext):Promise<void>{try{this.rdbStore=awaitrelationalStore.getRdbStore(context,this.storeConfig);// 创建订单主表constsqlCreateOrders=`CREATE TABLE IF NOT EXISTS orders ( order_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, total_amount REAL NOT NULL );`;// 创建订单明细表constsqlCreateItems=`CREATE TABLE IF NOT EXISTS order_items ( item_id TEXT PRIMARY KEY, order_id TEXT NOT NULL, product_name TEXT NOT NULL, price REAL NOT NULL );`;awaitthis.rdbStore.executeSql(sqlCreateOrders);awaitthis.rdbStore.executeSql(sqlCreateItems);console.info('RDB Initialized and tables created successfully.');}catch(err){consterror=errasBusinessError;console.error(`Failed to init RDB, code:${error.code}, message:${error.message}`);}}}四、核心代码实现:事务提交与异常回滚
接下来,我们编写两个完整的业务方法:一个演示正常的事务提交,另一个演示制造异常后触发事务回滚,并验证数据的一致性。
1. 正常流程:开启事务并成功 Commit
在此流程中,主表与明细表的写入均符合数据库约束,最终顺利提交。
/** * 正常场景:写入主表和明细表,并提交事务 */publicasynccreateOrderSuccess(orderId:string,userId:string,price:number):Promise<boolean>{if(!this.rdbStore){console.error('RdbStore is not initialized.');returnfalse;}try{// 1. 开启事务this.rdbStore.beginTransaction();// 2. 写入订单主表constorderBucket:relationalStore.ValuesBucket={'order_id':orderId,'user_id':userId,'total_amount':price};awaitthis.rdbStore.insert('orders',orderBucket);// 3. 写入订单明细表constitemBucket:relationalStore.ValuesBucket={'item_id':`ITEM_${Date.now()}`,'order_id':orderId,'product_name':'蓝牙耳机','price':price};awaitthis.rdbStore.insert('order_items',itemBucket);// 4. 所有写入步骤成功,提交事务this.rdbStore.commit();console.info(`Order${orderId}transaction committed successfully.`);returntrue;}catch(err){consterror=errasBusinessError;console.error(`Transaction failed, code:${error.code}, message:${error.message}`);// 出现异常时安全回滚if(this.rdbStore){this.rdbStore.rollback();}returnfalse;}}2. 异常回滚流程:故意制造第二步失败并执行 Rollback
为了模拟“写了一半遭遇失败”的真实场景,我们在写入订单明细表时,故意向数据库插入一条触发主键冲突(或数据类型不匹配)的数据。
/** * 异常场景:故意制造第二步写入失败,测试 Rollback 效果 */publicasynccreateOrderWithFailure(orderId:string,userId:string,price:number):Promise<void>{if(!this.rdbStore){console.error('RdbStore is not initialized.');return;}// 先预置一条数据,用以触发后续的主键冲突异常constduplicateItemId='CONFLICT_ITEM_001';constpresetBucket:relationalStore.ValuesBucket={'item_id':duplicateItemId,'order_id':'EXISTING_ORDER','product_name':'预置商品','price':10.0};awaitthis.rdbStore.insert('order_items',presetBucket);try{// 1. 开启事务this.rdbStore.beginTransaction();// 2. 第一步:写入订单主表(这一步理论上会成功)constorderBucket:relationalStore.ValuesBucket={'order_id':orderId,'user_id':userId,'total_amount':price};awaitthis.rdbStore.insert('orders',orderBucket);console.info(`Step 1: Inserted order header${orderId}successfully inside transaction.`);// 3. 第二步:故意插入相同主键 item_id 的记录,强制抛出 SQLite 冲突异常constinvalidItemBucket:relationalStore.ValuesBucket={'item_id':duplicateItemId,// 触发 PRIMARY KEY constraint failed'order_id':orderId,'product_name':'冲突商品','price':price};// 此处会抛出 BusinessError 异常,直接中断程序跳转至 catch 块awaitthis.rdbStore.insert('order_items',invalidItemBucket);// 4. 如果没有发生异常,才会执行 commit(当前场景下不会触发)this.rdbStore.commit();}catch(err){consterror=errasBusinessError;console.warn(`Captured expected failure in transaction: [Code${error.code}]${error.message}`);// 5. 捕获到异常,执行回滚操作if(this.rdbStore){this.rdbStore.rollback();console.info('Transaction rollback executed successfully.');}}}3. 查询确认结果:验证状态恢复
在执行回滚之后,核心验证逻辑在于检查第一步写入的订单主表记录是否存在。如果事务正常回滚,数据库中应该找不到该order_id。
/** * 查询校验:检查特定订单是否存在于主表中 */publicasynccheckOrderExists(orderId:string):Promise<boolean>{if(!this.rdbStore){returnfalse;}constpredicates=newrelationalStore.RdbPredicates('orders');predicates.equalTo('order_id',orderId);constresultSet=awaitthis.rdbStore.query(predicates);constcount=resultSet.rowCount;// 记得及时关闭 ResultSetresultSet.close();console.info(`Query result for order${orderId}: count =${count}`);returncount>0;}五、关键代码分析与细节解构
在实现事务逻辑时,有几个非常关键的技术细节决定了代码的稳定性和安全性:
1.try...catch的作用域限定
开启事务必须紧贴try块的开头。一旦开启,后续的每一条insert、update或delete都会被纳入当前的事务上下文中。如果在事务块内部有非数据库操作(如网络请求或复杂数据转换),建议将不涉及数据库的操作移出事务外,避免锁定数据库的时间过长。
2. 必须处理异步等待
HarmonyOS 的insert()方法属于异步调用(返回Promise)。在事务块中,必须使用await确保每一步写入完成后再发起下一次写入或提交。如果在未await的情况下直接调用commit(),可能导致事务提交时异步写入尚未真正完成,引发难以排查的数据竞态问题。
3. 错误捕获后的完整性保证
当捕获到BusinessError时,调用rollback()是恢复 RDBStore 状态的唯一手段。如果不执行rollback(),后续再次在该 RDB 句柄上调用beginTransaction()会抛出“当前已处于事务中”的错误,进而破坏后续所有的写业务。
六、开发中容易踩坑的地方
根据官方接口规范与实际数据操作逻辑,在 HarmonyOS 中使用 RDB 事务时,以下几个问题最容易导致异常:
1. 事务嵌套导致的异常
目前 HarmonyOS RDBStore 接口不直接支持标准的嵌套事务(Savepoints)。如果在已经开启事务(未 commit 或 rollback)的状态下,再次发起beginTransaction()调用,系统会抛出错误。必须保证单句柄下事务的扁平与单重。
2. 耗时异步操作阻塞数据库锁
在beginTransaction()和commit()/rollback()之间,严格禁止加入长时间等待的逻辑(例如 HTTP 网络请求、大文件 I/O 读写、复杂的加解密计算等)。
因为事务处于开启状态时, SQLite 会对数据库文件施加锁(如 RESERVED 或 EXCLUSIVE 锁)。长耗时逻辑会导致其他线程的数据库操作超时,引发Database Busy相关的错误。
3. ResultSet 未关闭导致锁不释放
如果在事务内部执行了query()操作,获取了ResultSet结果集,必须显式调用resultSet.close()。未关闭的结果集可能会持有读锁,进而干扰事务的提交或后续的写操作。
七、哪些业务必须使用事务?
在 HarmonyOS 应用的开发中,并非所有的数据库写操作都需要事务。判断是否必须引入事务,关键看是否存在跨表或跨多行的强一致性依赖。
以下几类业务场景建议强制使用事务:
- 金融与支付类操作:如钱包扣款、积分兑换、余额转账。A 账户减少与 B 账户增加必须在同一个事务内。
- 复合实体保存:如“订单与订单项”、“表单与附件元数据”、“文章与标签关系”。主表与关联明细表同步写入。
- 批量数据同步与重置:如全量同步远端服务器的联系人或聊天记录。要求“全量更新成功,或者失败时保留旧版本数据”,防止出现同步断层。
- 多表级联删除:删除某个用户时,需要同步清空该用户的历史缓存表、配置表及关联数据。
八、总结与排查清单
当你在应用开发中遇到 RDB 数据写不进去、写到一半停止或数据库报繁忙错误时,可以按照以下顺序进行快速排查:
- 检查 API 调用匹配:确认每一个
beginTransaction()在任何路径下(包含分支逻辑和异常捕获)都有对应的commit()或rollback()。 - 检查异步逻辑:确认事务块内部的所有 RDB 方法前都加上了
await关键字。 - 排查耗时任务:确认没有把 HTTP 接口请求、跨进程通信(IPC)等慢速任务放在事务上下文中。
- 检查结果集状态:确认事务内部如果有查询,所有的
ResultSet都在使用完毕后调用了.close()。 - 确认并发竞争:确认没有在多个线程中同时使用同一个 RDB 句柄频繁并发开启显式事务。
掌握 HarmonyOS RDB 事务的使用规范,是确保移动端本地高频数据读写稳定性的一项基本功。通过合理利用事务的回滚机制,能够让我们的应用在面对突发异常时依然保持数据的干净与一致。
参考资料
- 《 relationalStore (关系型数据库) - HarmonyOS API Reference 》 华为开发者联盟
- 《 ArkData 开发指南 - 关系型数据库开发实践 》 华为开发者联盟
社区讨论:在你目前的鸿蒙应用开发中,针对离线数据同步这种耗时较长的写操作,你是如何平衡数据库锁占用和事务原子性的?欢迎在评论区分享你的处理方式。
如果觉得有帮助,别忘了点个赞+关注支持一下~
喜欢记得关注,别让好内容被埋没~