ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

审批事务踩坑实录:杜绝半成功的薛定谔审批

审批事务踩坑实录:杜绝半成功的薛定谔审批 文章目录前言一、一次审批到底要动几块地方二、事务封装ExecuteInTransactionAsync1. DDL 必须在事务外2. 仓储必须绑到同一个工作单元3. 提交和回滚都要显式调用4. 资源释放放 finally三、写序列以同意为例四、通知必须在事务提交之后五、失败时返回什么六、测试怎么保证真的回滚了七、边界与注意事项八、小结P.S. 无意间发现了一个巨牛的人工智能教程非常通俗易懂对AI感兴趣的朋友强烈推荐去看看 传送门https://blog.csdn.net/HHX_01前言上一篇聊过并发两个人同时点审批只有一个能赢。今天聊个更阴的——一次审批要写好几处数据写到一半炸了怎么办。先把结论拍在桌上审批要么全成要么全没。中间态就像表白要么在一起要么连那句我喜欢你一起撤回。最怕的是什么最怕你俩都默认在一起了结果对方手机里你还躺在待定分组。一、一次审批到底要动几块地方以同意为例你点一下按钮后台大概要干五件事1. 更新业务表ApprovalStatus CurrentLevel 2. 更新审批记录当前节点标为已同意含意见、处理人、时间、审计字段 3. 跳过同节点其他待处理记录或签 4. 激活下一节点或把本轮结果回写发起记录 5. 提交之后发送站内信你要是不把它们塞进同一个事务就会收获各种半成功名场面。场景A业务表显示已通过审批记录还停在待处理。翻译成人话单据那边已经放行了审批人待办里还挂着一个永远点不完的任务。像什么像你房子都搬走了房东的账单还每个月准时寄到老地址。场景B反过来。审批记录显示A已同意业务表还卡在第一级。时间线上写满了有人同意过单据却像没人碰过。这叫朋友圈官宣了婚礼没办。最狠的是第4步挂了业务状态已经推到第2级第2级的待办节点却没激活。流程直接悬空谁处理都不行。这就像开会时说这个问题我们拉个群细聊然后这个群永远没被拉出来。二、事务封装ExecuteInTransactionAsync上面这些问题一个事务全解决。上代码/// /// 在数据库事务中执行审批写操作。 /// 任一步抛异常则整体回滚绝不允许业务状态改了、审批记录没写的半成功状态。 /// /// 关键点事务体必须通过 UnitOfWork 上的仓储ApprovalTransaction访问数据库。 /// 直接用 _orm 的操作会游离在事务之外自动提交 /// 那样下一节点缺失就回滚会失效。 /// /// private async Task ExecuteInTransactionAsync(Func action, string operation){// 事务外先确保审批记录表结构存在避免 DDL 落在事务里EnsureApprovalStructure();// UnitOfWorkManager 负责开启事务仓储必须通过 manager.Binding 加入该工作单元// 否则仓储会各自新开连接SQLite 下直接表现为 database is locked事务也失去意义。varuow_unitOfWorkManager.Begin();vartransactionnewApprovalTransaction(_unitOfWorkManager,uow);try{varresultawaitaction(transaction);uow.Commit();returnresult;}catch(Exceptionex){try{uow.Rollback();}catch(ExceptionrollbackEx){_logger.LogError(rollbackEx,审批事务回滚失败操作{Operation},operation);}_logger.LogError(ex,审批事务执行失败并已回滚操作{Operation},operation);throw;}finally{transaction.Dispose();}}这段代码有四个点每个点背后都躺过一个程序员。1. DDL 必须在事务外先看这一行// 事务外先确保审批记录表结构存在避免 DDL 落在事务里 EnsureApprovalStructure();对应的实现privatevoidEnsureApprovalStructure(){try{_orm.CodeFirst.SyncStructure();}catch(Exceptionex){// 同步失败不影响后续流程表通常已存在只记录告警_logger.LogWarning(ex,同步审批记录表结构失败将按现有结构继续);}}很多人第一次写审批事务就撞上database is locked第一反应是加超时。别急问题不是慢是你的 ORM 在事务里第一次碰实体顺手就建了张表。DDL 和事务写锁干起来了SQLite 当场锁给你看。解法不玄乎进事务之前先把表结构同步好。就像进考场前先上好厕所别等卷子发下来再举手老师不批的。2. 仓储必须绑到同一个工作单元privatesealedclassApprovalTransaction:IDisposable{privatereadonlyUnitOfWorkManager_manager;privatereadonlyIUnitOfWork_uow;privatereadonlyList_repositories[];publicApprovalTransaction(UnitOfWorkManagermanager,IUnitOfWorkuow){_managermanager;_uowuow;}////// 取指定实体的仓储与事务绑定。/// 仓储建立在当前租户 ORM 上并通过 UnitOfWorkManager.Binding 纳入事务/// 因此事务内的读写共用同一条连接。///publicIBaseRepositoryGetRepository()whereTEntity:class{varrepository_manager.Orm.GetRepository();_manager.Binding(repository);_repositories.Add(repository);returnrepository;}publicvoidDispose(){if(Interlocked.Exchange(ref_disposed,1)1)return;foreach(varrepositoryin_repositories){try{repository.Dispose();}catch{/* 释放失败不影响事务结果 */}}_repositories.Clear();_uow.Dispose();}}这段的灵魂是_manager.Binding(repository)。没这一行你的仓储各开各的连接事务名存实亡绑对了 UnitOfWork事务连接 ├── 业务表仓储 ├── 审批记录仓储 └── 同一个连接 → 一起提交 / 一起回滚 没绑 业务表仓储 → 连接A → 自动提交 审批记录仓储 → 连接B → 自动提交 → 回滚只滚了一半另一半早提交了扎心一点这就像两口子各存各的私房钱出了事你只能把自己那份退回来人家那份早花完了你还得倒贴。3. 提交和回滚都要显式调用varresultawaitaction(transaction);uow.Commit();returnresult;回滚那边也一样catch 里显式调catch(Exceptionex){try{uow.Rollback();}catch(ExceptionrollbackEx){_logger.LogError(rollbackEx,回滚失败);}throw;}Commit 和 Rollback 都得自己喊别指望框架偷偷帮你。回滚失败也会记日志但原始异常继续往上抛——调用方必须知道这次没成不能假装无事发生。4. 资源释放放 finallyfinally{transaction.Dispose();}Dispose 里用 Interlocked.Exchange 保证只释放一次仓储挨个释放最后释放 IUnitOfWork。资源释放放 finally 是底线放 try 里是赌命。三、写序列以同意为例把上面的封装套进业务结构非常清晰outcomeawaitExecuteInTransactionAsync(asynctx{// 1) 业务状态原子推进varaffectedawaitSetApprovalStatus(tx.GetRepository().UpdateDiy,status,...).Where(aa.IdbillIda.ApprovalStatusApprovalStatus.Pendinga.CurrentLevelcurrentLevel).ExecuteAffrowsAsync();if(affected0)returnApprovalOperationOutcome.CreateConflict();// 2) 原子占用当前待办节点varclaimedawaittx.GetRepository().UpdateDiy....ExecuteAffrowsAsync();if(claimed0)returnApprovalOperationOutcome.CreateConflict();// 3) 或签同节点其他待处理记录自动跳过...// 4) 还有下一级 → 激活下一节点否则本轮结束if(statusApprovalStatus.Pending){varnextawait...;if(next.Count0){// 下一节点缺失属于流程数据不一致必须回滚而不是让流程悬空thrownewInvalidOperationException($审批流程数据不一致单据{billType}#{billId}缺少第{nextLevel}级待办节点);}foreach(varrecordinnext)record.IsCurrenttrue;await...ExecuteAffrowsAsync();returnApprovalOperationOutcome.Next(pending:next);}// 5) 本轮结束回写发起记录...returnApprovalOperationOutcome.Finished(status,resultRecord);},$Approve:{billType}#{billId});四个写操作、一个下一节点缺失的异常点全部在同一个 action 里。任何一处抛异常或者提前 return 冲突事务都不会提交。注意 CreateConflict() 的玩法它不抛异常而是返回一个冲突结果事务正常提交。因为 affected0 的时候前面的 UPDATE 根本没改数据没有东西需要回滚。冲突是正常的业务结果不是系统故障不该记错误日志——就像你表白被拒那是业务结果不是系统崩溃日志里不用写 ERROR。四、通知必须在事务提交之后最容易翻车的一步来了通知。看提交路径// 通知在事务提交之后发送ListpendingToNotify;try{pendingToNotifyawaitExecuteInTransactionAsync(asynctx{...returnrecords.Where(xx.IsCurrent).ToList();},$Submit:{billType}#{bill.Id});}catch(Exceptionex){returnnewApprovalSubmitResult(false,GenericFailure(ex));}if(pendingToNotifyisnull){returnnewApprovalSubmitResult(false,L(单据状态已发生变化请刷新后重试));}bill.ApprovalStatusstatus;bill.CurrentLevelcurrentLevel;// 事务已提交此时才发送通知。通知失败不回滚审批。 if (_options.EnableNotification pendingToNotify.Count 0){awaitNotifyPendingSafeAsync(pendingToNotify);}三个设计决定个个都是踩过坑才长出来的**1事务里只返回待通知的数据不发通知。**站内信写入加 SignalR 推送塞进事务会把事务时间拉长还可能因为推送失败把审批一起回滚。想象一下审批明明通过了就因为短信没发出去整单撤回。这比已读不回还离谱这是已读但撤回。**2通知失败不回滚审批。**NotifyPendingSafeAsync 内部做了异常隔离。审批已经成功落库不能因为消息没发出去就撤销一次合法审批。事办完了通知没到位但事还是办了——这很合理像你外卖到了骑手忘点送达你照样吃饭。**3事务失败时绝不发通知。**通知代码在 catch 之外、事务提交之后所以审批失败但通知说已通过这种灵异事件根本不存在。顺带一提内存里的单据对象也是事务成功之后才更新的bill.ApprovalStatusstatus;bill.CurrentLevelcurrentLevel;放事务里更新回滚之后内存对象就成了跟数据库对不上的幽灵状态。幽灵不可怕幽灵数据才可怕半夜查日志能吓哭你。五、失败时返回什么privatestringGenericFailure(Exceptionex){vartraceIdGuid.NewGuid().ToString(N)[..16];_logger.LogError(ex,审批处理失败TraceId{TraceId},traceId);returnLF(审批处理失败请稍后重试。TraceId{0},traceId);}用户看到的是审批处理失败请稍后重试。TraceIdxxxx运维拿 TraceId 去日志里定位。数据库异常、表名、SQL 片段一律不上界面。这个设计很人性用户不需要知道你炸在哪张表只需要知道没成重试一下。就像去医院你只需要挂号单号不需要知道是哪个科室的打印机坏了。六、测试怎么保证真的回滚了回滚这种能力光看代码不算数得有能制造失败的测试。源码的做法在事务中途人为制造失败然后断言数据库里什么都没变。测试制造的失败断言Approve_WhenNextNodeActivationFails_RollsBackBillRecordAndHistory下一节点激活失败业务状态、审批记录、历史全部回滚Reject_WhenFinalUpdateFails_RollsBackBillAndRecords最后一处更新失败单据与记录都回到操作前Revoke_WhenFinalUpdateFails_RollsBackEverything撤回收尾失败撤回相关写入全部回滚Approve_WhenNextNodeMissing_RollsBackBillAndRecord删除二级待办节点脏数据一级同意不生效流程不悬空Submit_WhenRecordInsertFails_RollsBackBillStatus插入审批流水失败业务状态不进入审批中UnitOfWork_RepositoryIsBoundToCurrentTransaction——仓储确实绑在事务连接上ApprovalWrites_AlwaysUseCurrentTenantOrm——审批读写只走当前租户库Notification_IsNotSentWhenTransactionFails事务失败不发送成功通知RepeatedOperations_ProduceNoAdditionalSideEffects重复操作不产生额外副作用其中有个测试特别狠把二级待办节点直接删了制造脏数据断言一级同意不生效、流程不悬空。这就像为了验证安全气囊真把车怼墙上去——不是故意拆了路障再开看系统会不会自己掉坑里。还有并发测试并发操作之后数据库状态必须与最终成功的那一个完全一致不存在混合态。一句话不允许薛定谔的审批。七、边界与注意事项事务只覆盖单个数据库。多租户下审批用的是当前租户库的 ORM别把主库写入塞进同一个本地事务跨库得上分布式事务。注入的 UnitOfWorkManager 要和 ORM 匹配。构造函数留了可注入参数容器里有更合适的就注入没有就现场 new 一个。事务内别发起无关查询。源码在提交前把审批人姓名一次性查好注释写得很直白单连接事务里再发起查询会触发 SQLite 锁等待。DDL 别进事务。这条对所有用 CodeFirst 自动建表的场景都适用不限于审批。别用 _orm 直连绕过事务。一旦绕过回滚就不完整而且问题只会在故障时暴露。平时岁月静好出事天崩地裂。八、小结关注点做法事务边界UnitOfWorkManager.Begin() → Commit() / Rollback()数据访问仓储必须 Binding 到同一个 UnitOfWorkDDL事务开始前 SyncStructure不混进事务冲突条件更新 affected 0 返回冲突不抛异常数据不一致抛异常 → 整体回滚例如下一节点缺失通知事务提交之后发送失败不回滚审批内存对象事务成功后才同步状态错误暴露通用提示 TraceId不泄露数据库细节一句话总结审批要么全部成功要么什么都没发生。落到源码里就是——所有写操作都在同一个 action 里都走绑定了 UnitOfWork 的仓储任何异常都让事务回滚。给 .NET Blazor 后台加过审批的朋友应该懂事务和并发这两块最容易被低估等到线上出了半成功你才知道什么叫改一个字段救一个月。P.S. 无意间发现了一个巨牛的人工智能教程非常通俗易懂对AI感兴趣的朋友强烈推荐去看看传送门https://blog.csdn.net/HHX_01
返回列表