EasyAdminBlazor 审批并发控制:两个人同时审批为什么只能成功一个?

发布时间:2026/10/2 16:28:27
EasyAdminBlazor 审批并发控制:两个人同时审批为什么只能成功一个? 审批里最容易被低估的问题不是流程怎么配而是两个人同时点同意会发生什么。如果处理得不好结果可能是单据被推进两级、同一个节点被完成两次、历史里出现两条互相矛盾的审批记录、或者已通过之后又被驳回。这篇文章讲 EasyAdminBlazor 2.3 是怎么用数据库条件更新把这些问题挡住的。一、先把并发场景列全审批模块要处理的竞争不止一种场景期望结果同一个人重复点击同意第二次必须失败不能推进两级或签节点 A、B 同时点同意只有一个人成功节点只完成一次审批人点同意的同时发起人点撤回只有一个成功同一节点两个人同时转交只有一次转交生效不能互相覆盖最后一级并发同意只产生一次已通过一级已通过后再对一级驳回必须失败不能把已通过的节点改坏这六种场景在ApprovalConcurrencyTests.cs里都有对应用例。二、为什么不用锁第一反应通常是加锁lock、SemaphoreSlim、分布式锁。但在审批这个场景里锁不是好答案。进程内锁在多实例部署下直接失效。两个请求打到不同实例各自的锁互不可见。分布式锁成本高、粒度难定。按单据加锁意味着要维护锁的获取、续期、释放、超时还得处理持锁进程崩了。真正的约束是数据本身。“这张单据现在必须是审批中、且处于第 2 级”——这个条件数据库最清楚也最能原子地判定。所以源码采用的方案是条件更新乐观并发把我希望的前置状态写进WHERE用受影响行数判断是否抢到了这次操作。三、核心机制一业务状态的条件更新1. 更新语句// 1) 业务状态原子推进状态 级次条件更新并发审批只有一个能成功varaffectedawaitSetApprovalStatusTBill(tx.GetRepositoryTBill().UpdateDiy,status,statusApprovalStatus.Approved?0:nextLevel).Where(aa.IdbillIda.ApprovalStatusApprovalStatus.Pendinga.CurrentLevelcurrentLevel).ExecuteAffrowsAsync();if(affected0)returnApprovalOperationOutcome.CreateConflict();其中SetApprovalStatus只负责写两个字段/// summary/// 条件更新单据审批状态与级次服务端根据当前状态/级次计算不接受客户端传入。/// /summaryprivatestaticIUpdateTBillSetApprovalStatusTBill(IUpdateTBillupdate,ApprovalStatusstatus,intlevel)whereTBill:class,IApprovalBillupdate.Set(xx.ApprovalStatus,status).Set(xx.CurrentLevel,level);参数注释里那句话很关键状态和目标级次都是服务端算出来的不接受客户端传我要把它改成第 3 级。客户端只能表达我要同意能不能推进由服务端根据当前数据决定。2. 对应的 SQL上面的 LINQ 最终会生成类似UPDATEblog_articleSETApprovalStatus1,CurrentLevel3WHEREId1001ANDApprovalStatus1ANDCurrentLevel2;数据库保证这条UPDATE的原子性。两个请求同时执行时请求 AUPDATE ... WHERE CurrentLevel 2 → affected 1成功 请求 BUPDATE ... WHERE CurrentLevel 2 → affected 0此时已经是 3affected 0就是有人抢先了的信号直接返回冲突不再执行后续步骤。3. 冲突怎么反馈给用户if(outcome.Conflict){// 兼容既有提示语单据级条件更新失败通常意味着他人已把单据推进/结束returnnewApprovalSubmitResult(false,L(该单据已被其他人处理请刷新后重试));}返回的是可读提示不是异常堆栈。异常信息通过GenericFailure(ex)统一处理成通用提示只带 TraceId不泄露数据库细节。四、核心机制二原子占用审批节点只更新业务状态还不够。sys_approval_record里保存着节点和待办如果业务状态更新成功但节点记录没被正确占用就会出现节点还在待办里或者两个人各写一条审批意见的问题。所以紧接着是第二步——原子占用当前待办节点// 2) 原子占用当前待办节点只有当前节点 待处理 审批人是我的记录会被更新。// 或签场景下 A、B 同时点同意时这里只有一个请求 affected 0不会重复推进节点。varclaimedawaittx.GetRepositorySysApprovalRecord().UpdateDiy.Set(xx.Status,ApprovalRecordStatus.Approved).Set(xx.Comment,commentSnapshot).Set(xx.OperateTime,DateTime.Now).Set(xx.IsCurrent,false).Set(xx.OperatorUserId,userId).Set(xx.OperatorUserName,userName).Set(xx.BeforeStatus,ApprovalStatus.Pending).Set(xx.AfterStatus,status).Where(xx.BillTypebillTypex.BillIdbillIdx.Roundroundx.LevelcurrentLevelx.IsCurrentx.StatusApprovalRecordStatus.Pending(x.ApproverUserIduserId||_user.IsAdministrator)).ExecuteAffrowsAsync();if(claimed0)returnApprovalOperationOutcome.CreateConflict();注意WHERE里的五个条件缺一不可条件作用Round只操作当前轮次历史轮次不动Level currentLevel只操作当前级次IsCurrent只操作当前节点Status Pending只操作待处理已处理的不动ApproverUserId userId或管理员只有该节点的审批人能认领这就是所谓认领谁先把这条记录从Pending改成Approved谁就完成了这个节点。第二个请求的claimed是 0拿不到节点整个操作回滚。五、或签同节点多人时怎么收尾角色来源的审批节点往往是多人或签。A 同意之后同一节点的 B、C 就不应该再看到这条待办。源码的处理是把同节点剩余待处理记录统一置为Skipped// 3) 或签同节点其他待处理记录自动跳过awaittx.GetRepositorySysApprovalRecord().UpdateDiy.Set(xx.Status,ApprovalRecordStatus.Skipped).Set(xx.IsCurrent,false).Set(xx.OperateTime,DateTime.Now).Where(xx.BillTypebillTypex.BillIdbillIdx.Roundroundx.LevelcurrentLevelx.StatusApprovalRecordStatus.Pending).ExecuteAffrowsAsync();注意这里没有ApproverUserId userId条件——目的就是把同节点其他所有人的待办一起清掉。Skipped是独立的记录状态和同意/驳回区分开。这样时间线上能看出A 同意了B、C 被跳过而不是把 B、C 显示成同意了。六、节点推进状态机 快照下一步是决定往哪走由纯函数状态机负责var(status,nextLevel)ApprovalStateMachine.Next(ApprovalAction.Approve,currentLevel,maxLevel);ApprovalAction.ApprovewhencurrentLevelmaxLevel(ApprovalStatus.Pending,currentLevel1),ApprovalAction.Approve(ApprovalStatus.Approved,0),这里的maxLevel不是直接取流程配置而是优先取提交时落库的节点快照/// summary/// 计算流程的有效最大级次。/// 优先以提交时已落库的节点为准流程实例快照这样运行中的审批不会因为/// 流程配置后来被修改而突然改变节点数量或节点内容。/// /summaryprivateintResolveMaxLevel(IReadOnlyCollectionSysApprovalRecordcurrentRecords,ApprovalFlowConfigflow){...varmaxLevel(int?)_orm.SelectSysApprovalRecord().Where(xx.BillTypebillTypex.BillIdbillIdx.Roundroundx.Level0).Max(x(int?)x.Level);snapshotMaxmaxLevel??0;// 快照缺失历史数据时退回流程配置但必须至少覆盖当前级次varconfiguredMaxflow.Levels.Count0?0:flow.Levels.Max(xx.Level);varmaxMath.Max(snapshotMax,configuredMax);...}这是个很实务的考虑管理员在流程跑到一半时改了流程配置比如从 3 级改成 2 级正在审批中的单据不应该突然少一级或多一级。以提交时的快照为准配置只影响新提交的单据。七、下一级激活失败怎么办如果还有下一级要把下一级节点置为IsCurrent true。这里有一个明确的一致性要求if(next.Count0){// 下一节点缺失属于流程数据不一致必须回滚而不是让流程悬空thrownewInvalidOperationException($审批流程数据不一致单据{billType}#{billId}缺少第{nextLevel}级待办节点);}foreach(varrecordinnext)record.IsCurrenttrue;awaittx.GetRepositorySysApprovalRecord().UpdateDiy.SetSource(next).ExecuteAffrowsAsync();抛异常 → 整个事务回滚 → 业务状态回到第 N 级审批中审批记录也没被改。这比业务状态推到第 N1 级、但没有任何人收到待办要好得多。第 18 篇会详细讲这个事务。八、驳回、撤回、转交用的是同一套模式驳回// 1) 业务状态原子更新varaffectedawaitSetApprovalStatusTBill(tx.GetRepositoryTBill().UpdateDiy,status,level).Where(aa.IdbillIda.ApprovalStatusApprovalStatus.Pendinga.CurrentLevelcurrentLevel).ExecuteAffrowsAsync();if(affected0)returnnull;// 2) 原子占用当前节点改为 Rejectedvarclaimedawaittx.GetRepositorySysApprovalRecord().UpdateDiy.Set(xx.Status,ApprovalRecordStatus.Rejected)....ExecuteAffrowsAsync();if(claimed0)returnnull;// 3) 同节点其他待处理记录跳过// 4) 后续节点全部跳过避免待办列表出现幽灵任务// 5) 本轮结果回写发起记录驳回比同意多一步后续节点要全部跳过。否则单据已经已驳回后面的审批人待办里还挂着任务就出现了幽灵待办。撤回撤回的前置校验更严格varrecordsawaitGetRecordsAsync(billType,billId);if(records.Any(xx.StatusisApprovalRecordStatus.ApprovedorApprovalRecordStatus.Rejected))returnnewApprovalSubmitResult(false,L(已有审批人处理过该单据不能撤回));只要有人处理过就不能撤回。然后同样是业务状态条件更新 占用当前节点 后续节点跳过 留痕// 1) 业务状态原子更新仍是审批中 当前级次才允许撤回// 与审批人的同意/驳回形成互斥谁先提交谁生效varaffectedawaitSetApprovalStatusTBill(tx.GetRepositoryTBill().UpdateDiy,status,level).Where(aa.IdbillIda.ApprovalStatusApprovalStatus.Pendinga.CurrentLevelcurrentLevel).ExecuteAffrowsAsync();if(affected0)returnfalse;那句注释是重点同意和撤回竞争的是同一个条件更新谁先提交谁生效不需要额外的互斥锁。转交转交不改业务状态只改这个节点由谁处理// 原子转交只有当前节点 待处理 审批人是我的记录会被改写。// 两个并发转交只有一个 affected 0不会互相覆盖。varaffectedawaittx.GetRepositorySysApprovalRecord().UpdateDiy.Set(xx.ApproverUserId,toUserIdSnapshot).Set(xx.ApproverUserName,targetNameSnapshot).Set(xx.OperatorUserId,userId).Set(xx.OperatorUserName,userName).Where(xx.BillTypebillTypex.BillIdbillIdx.Roundroundx.LevelcurrentLevelx.IsCurrentx.StatusApprovalRecordStatus.Pending(x.ApproverUserIduserId||_user.IsAdministrator)).ExecuteAffrowsAsync();if(affected0)returnnull;转交还有几条业务校验防止把流程搞乱转交对象必须存在且未停用不能转交给单据发起人避免变相自审不能转交给当前节点已有的审批人避免重复任务。九、审计留痕历史只允许新增处理并发时还有一个隐含要求不能为了修复状态去改历史记录。源码里的做法是当前节点记录被条件更新认领但结果留痕、转交留痕、撤回留痕都是INSERT新记录// 转交留痕历史只新增记录原审批人 → 目标用户 原因// 因此仍能追溯到原来是谁、什么时候转给谁、为什么转交。vartrailNewRecord(billType,billId,round,currentLevel,levelName,ApprovalRecordStatus.Transferred,userId,userName,billTitle,bill.CreatedUserId??userId,submitterNameSnapshot,current:false,instanceKey:instanceKey,beforeStatus:fromStatus,afterStatus:fromStatus,operatorUserId:userId,operatorUserName:userName);trail.ToUserIdtoUserIdSnapshot;trail.ToUserNametargetNameSnapshot;trail.CommentcommentSnapshot;awaittx.GetRepositorySysApprovalRecord().InsertAsync(trail);每条记录还带BeforeStatus/AfterStatus/OperatorUserId/InstanceKey审计时能看到变更前后状态 实际操作人 属于哪一轮。OperatorUserId和ApproverUserId分开存也有讲究ApproverUserId该节点应由谁处理 OperatorUserId实际执行本次动作的人转交时 原审批人代审时 被代审人十、测试怎么锁定这些行为ApprovalConcurrencyTests.cs不是跑一遍没报错而是逐条锁定并发语义测试验证的问题ConcurrentApprove_OnlyOneSucceeds并发同意只有一个成功ConcurrentApprove_RepeatedRequest_DoesNotDuplicateHistory重复请求不产生重复历史、不再推进节点ApproveAndReject_AreMutuallyExclusive一级已通过后再驳回必须失败ApproveAndRevokeConcurrently_OnlyOneSucceeds同意与撤回并发只有一个成功ConcurrentTransfer_OnlyOneSucceeds并发转交不互相覆盖LastLevelConcurrentApprove_ProducesSingleCompletion末级并发同意只产生一次完成OrSign_ConcurrentApproveByDifferentApprovers_CompletesNodeOnce或签并发同意节点只完成一次、不重复激活下一级MissingNextLevel_RollsBackWholeTransaction下一节点缺失时整体回滚BusinessStatusConflict_DoesNotTouchApprovalRecords业务状态冲突时审批记录不被改动Revoke_AfterApproved_IsRejected已审批过的单据不能撤回Revoke_Twice_DoesNotDuplicateHistory第二次撤回不产生新历史Notification_IsNotSentWhenTransactionFails事务失败时绝不发审批成功通知其中BusinessStatusConflict_DoesNotTouchApprovalRecords最能说明设计意图他人已把业务状态推进到二级但一级待办记录仍是当前节点 → 条件更新 affected 0 → 审批记录必须保持原样不能出现记录已同意、业务还在审批中十一、这套方案不覆盖什么条件更新解决的是同一行数据的竞争它不能替代所有并发控制跨单据的业务规则例如这个客户所有合同的审批总额不能超过 100 万不在这里处理需要在业务层加锁或做汇总校验。多实例下的初始化类操作例如动态创建租户仍然需要分布式锁——条件更新保护的是已存在的数据行。真正的会签/并行分支超出模块范围需要工作流引擎。条件更新依赖数据库隔离级别。主流数据库的默认隔离级别下UPDATE ... WHERE的原子性是有保证的如果你的环境做了特殊设置比如某些只读副本上写要单独验证。十二、小结EasyAdminBlazor 审批并发控制的核心可以概括成两句话业务状态用状态 级次条件更新affected 0就代表有人抢先直接返回冲突审批节点用当前节点 待处理 审批人是我条件更新来认领或签时其余待处理记录置Skipped历史只新增不改写。这两个条件更新都在同一个事务里所以不会出现状态改了但节点没认领的半成功状态——那是下一篇文章的主题。如果你的后台需要审批又担心两个人同时点会怎样可以让团队直接读 EasyAdminBlazor 的审批实现并发语义写在了条件更新的WHERE里也写在了测试用例里。文档https://easyadmin.wang-zhan.com.cn/doc源码https://gitee.com/gudufy/EasyAdminBlazor