☰
审批事务踩坑实录:杜绝半成功的薛定谔审批
2026/10/3 13:02:24 网站建设 项目流程

文章目录

    • 前言
    • 一、一次审批到底要动几块地方
    • 二、事务封装:ExecuteInTransactionAsync
      • 1. 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();vartransaction=newApprovalTransaction(_unitOfWorkManager,uow);try{varresult=awaitaction(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){_manager=manager;_uow=uow;}////// 取指定实体的仓储(与事务绑定)。/// 仓储建立在当前租户 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. 提交和回滚都要显式调用

varresult=awaitaction(transaction);uow.Commit();returnresult;

回滚那边也一样,catch 里显式调:

catch(Exceptionex){try{uow.Rollback();}catch(ExceptionrollbackEx){_logger.LogError(rollbackEx,"回滚失败");}throw;}

Commit 和 Rollback 都得自己喊,别指望框架偷偷帮你。回滚失败也会记日志,但原始异常继续往上抛——调用方必须知道"这次没成",不能假装无事发生。

4. 资源释放放 finally

finally{transaction.Dispose();}

Dispose 里用 Interlocked.Exchange 保证只释放一次,仓储挨个释放,最后释放 IUnitOfWork。资源释放放 finally 是底线,放 try 里是赌命。

三、写序列:以"同意"为例

把上面的封装套进业务,结构非常清晰:

outcome=awaitExecuteInTransactionAsync(asynctx=>{// 1) 业务状态原子推进varaffected=awaitSetApprovalStatus(tx.GetRepository().UpdateDiy,status,...).Where(a=>a.Id==billId&&a.ApprovalStatus==ApprovalStatus.Pending&&a.CurrentLevel==currentLevel).ExecuteAffrowsAsync();if(affected<=0)returnApprovalOperationOutcome.CreateConflict();// 2) 原子占用当前待办节点varclaimed=awaittx.GetRepository().UpdateDiy....ExecuteAffrowsAsync();if(claimed<=0)returnApprovalOperationOutcome.CreateConflict();// 3) 或签:同节点其他待处理记录自动跳过...// 4) 还有下一级 → 激活下一节点;否则本轮结束if(status==ApprovalStatus.Pending){varnext=await...;if(next.Count==0){// 下一节点缺失属于流程数据不一致,必须回滚而不是让流程悬空thrownewInvalidOperationException($"审批流程数据不一致:单据{billType}#{billId}缺少第{nextLevel}级待办节点");}foreach(varrecordinnext)record.IsCurrent=true;await...ExecuteAffrowsAsync();returnApprovalOperationOutcome.Next(pending:next);}// 5) 本轮结束:回写发起记录...returnApprovalOperationOutcome.Finished(status,resultRecord);},$"Approve:{billType}#{billId}");

四个写操作、一个"下一节点缺失"的异常点,全部在同一个 action 里。任何一处抛异常,或者提前 return 冲突,事务都不会提交。

注意 CreateConflict() 的玩法:它不抛异常,而是返回一个"冲突"结果,事务正常提交。因为 affected=0 的时候,前面的 UPDATE 根本没改数据,没有东西需要回滚。冲突是正常的业务结果,不是系统故障,不该记错误日志——就像你表白被拒,那是业务结果,不是系统崩溃,日志里不用写 ERROR。

四、通知必须在事务提交之后

最容易翻车的一步来了,通知。看提交路径:

// 通知在事务提交之后发送ListpendingToNotify;try{pendingToNotify=awaitExecuteInTransactionAsync(asynctx=>{...returnrecords.Where(x=>x.IsCurrent).ToList();},$"Submit:{billType}#{bill.Id}");}catch(Exceptionex){returnnewApprovalSubmitResult(false,GenericFailure(ex));}if(pendingToNotifyisnull){returnnewApprovalSubmitResult(false,L("单据状态已发生变化,请刷新后重试"));}bill.ApprovalStatus=status;bill.CurrentLevel=currentLevel;// 事务已提交:此时才发送通知。通知失败不回滚审批。 if (_options.EnableNotification && pendingToNotify.Count > 0){awaitNotifyPendingSafeAsync(pendingToNotify);}

三个设计决定,个个都是踩过坑才长出来的:

**(1)事务里只返回"待通知的数据",不发通知。**站内信写入加 SignalR 推送塞进事务,会把事务时间拉长,还可能因为推送失败把审批一起回滚。想象一下:审批明明通过了,就因为短信没发出去,整单撤回。这比"已读不回"还离谱,这是"已读,但撤回"。

**(2)通知失败不回滚审批。**NotifyPendingSafeAsync 内部做了异常隔离。审批已经成功落库,不能因为"消息没发出去"就撤销一次合法审批。事办完了,通知没到位,但事还是办了——这很合理,像你外卖到了,骑手忘点送达,你照样吃饭。

**(3)事务失败时绝不发通知。**通知代码在 catch 之外、事务提交之后,所以"审批失败但通知说已通过"这种灵异事件根本不存在。

顺带一提,内存里的单据对象也是事务成功之后才更新的:

bill.ApprovalStatus=status;bill.CurrentLevel=currentLevel;

放事务里更新,回滚之后内存对象就成了跟数据库对不上的"幽灵状态"。幽灵不可怕,幽灵数据才可怕,半夜查日志能吓哭你。

五、失败时返回什么

privatestringGenericFailure(Exceptionex){vartraceId=Guid.NewGuid().ToString("N")[..16];_logger.LogError(ex,"审批处理失败,TraceId:{TraceId}",traceId);returnLF("审批处理失败,请稍后重试。TraceId:{0}",traceId);}

用户看到的是"审批处理失败,请稍后重试。TraceId:xxxx",运维拿 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重复操作不产生额外副作用

其中有个测试特别狠:把二级待办节点直接删了(制造脏数据),断言一级同意不生效、流程不悬空。这就像为了验证安全气囊,真把车怼墙上去——不,是故意拆了路障再开,看系统会不会自己掉坑里。

还有并发测试:并发操作之后,数据库状态必须与"最终成功的那一个"完全一致,不存在混合态。一句话:不允许"薛定谔的审批"。

七、边界与注意事项

  1. 事务只覆盖单个数据库。多租户下审批用的是当前租户库的 ORM,别把主库写入塞进同一个本地事务,跨库得上分布式事务。

  2. 注入的 UnitOfWorkManager 要和 ORM 匹配。构造函数留了可注入参数,容器里有更合适的就注入,没有就现场 new 一个。

  3. 事务内别发起无关查询。源码在提交前把审批人姓名一次性查好,注释写得很直白:单连接事务里再发起查询会触发 SQLite 锁等待。

  4. DDL 别进事务。这条对所有用 CodeFirst 自动建表的场景都适用,不限于审批。

  5. 别用 _orm 直连绕过事务。一旦绕过,回滚就不完整,而且问题只会在故障时暴露。平时岁月静好,出事天崩地裂。

八、小结

关注点做法
事务边界UnitOfWorkManager.Begin() → Commit() / Rollback()
数据访问仓储必须 Binding 到同一个 UnitOfWork
DDL事务开始前 SyncStructure,不混进事务
冲突条件更新 affected = 0 返回冲突,不抛异常
数据不一致抛异常 → 整体回滚(例如下一节点缺失)
通知事务提交之后发送;失败不回滚审批
内存对象事务成功后才同步状态
错误暴露通用提示 + TraceId,不泄露数据库细节

一句话总结:审批要么全部成功,要么什么都没发生。落到源码里就是——所有写操作都在同一个 action 里,都走绑定了 UnitOfWork 的仓储,任何异常都让事务回滚。

给 .NET + Blazor 后台加过审批的朋友应该懂:事务和并发这两块最容易被低估,等到线上出了"半成功",你才知道什么叫改一个字段,救一个月。

P.S. 无意间发现了一个巨牛的人工智能教程,非常通俗易懂,对AI感兴趣的朋友强烈推荐去看看,传送门https://blog.csdn.net/HHX_01

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询