文章目录
- 前言
- 一、一次审批到底要动几块地方
- 二、事务封装: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 | 重复操作 | 不产生额外副作用 |
其中有个测试特别狠:把二级待办节点直接删了(制造脏数据),断言一级同意不生效、流程不悬空。这就像为了验证安全气囊,真把车怼墙上去——不,是故意拆了路障再开,看系统会不会自己掉坑里。
还有并发测试:并发操作之后,数据库状态必须与"最终成功的那一个"完全一致,不存在混合态。一句话:不允许"薛定谔的审批"。
七、边界与注意事项
事务只覆盖单个数据库。多租户下审批用的是当前租户库的 ORM,别把主库写入塞进同一个本地事务,跨库得上分布式事务。
注入的 UnitOfWorkManager 要和 ORM 匹配。构造函数留了可注入参数,容器里有更合适的就注入,没有就现场 new 一个。
事务内别发起无关查询。源码在提交前把审批人姓名一次性查好,注释写得很直白:单连接事务里再发起查询会触发 SQLite 锁等待。
DDL 别进事务。这条对所有用 CodeFirst 自动建表的场景都适用,不限于审批。
别用 _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