☰
EasyAdminBlazor 审批并发控制:两个人同时审批为什么只能成功一个?
2026/10/2 16:28:07 网站建设 项目流程

审批里最容易被低估的问题不是"流程怎么配",而是两个人同时点"同意"会发生什么。

如果处理得不好,结果可能是:单据被推进两级、同一个节点被完成两次、历史里出现两条互相矛盾的审批记录、或者"已通过"之后又被驳回。这篇文章讲 EasyAdminBlazor 2.3 是怎么用数据库条件更新把这些问题挡住的。


一、先把并发场景列全

审批模块要处理的竞争不止一种:

场景期望结果
同一个人重复点击"同意"第二次必须失败,不能推进两级
或签节点 A、B 同时点"同意"只有一个人成功,节点只完成一次
审批人点"同意"的同时发起人点"撤回"只有一个成功
同一节点两个人同时"转交"只有一次转交生效,不能互相覆盖
最后一级并发同意只产生一次"已通过"
一级已通过后,再对一级"驳回"必须失败,不能把已通过的节点改坏

这六种场景在ApprovalConcurrencyTests.cs里都有对应用例。


二、为什么不用锁

第一反应通常是"加锁":lock、SemaphoreSlim、分布式锁。但在审批这个场景里,锁不是好答案。

进程内锁在多实例部署下直接失效。两个请求打到不同实例,各自的锁互不可见。

分布式锁成本高、粒度难定。按单据加锁意味着要维护锁的获取、续期、释放、超时,还得处理"持锁进程崩了"。

真正的约束是数据本身。“这张单据现在必须是审批中、且处于第 2 级”——这个条件数据库最清楚,也最能原子地判定。

所以源码采用的方案是条件更新(乐观并发):把"我希望的前置状态"写进WHERE,用受影响行数判断是否抢到了这次操作。


三、核心机制一:业务状态的条件更新

1. 更新语句

// 1) 业务状态原子推进(状态 + 级次条件更新,并发审批只有一个能成功)varaffected=awaitSetApprovalStatus<TBill>(tx.GetRepository<TBill>().UpdateDiy,status,status==ApprovalStatus.Approved?0:nextLevel).Where(a=>a.Id==billId&&a.ApprovalStatus==ApprovalStatus.Pending&&a.CurrentLevel==currentLevel).ExecuteAffrowsAsync();if(affected<=0)returnApprovalOperationOutcome.CreateConflict();

其中SetApprovalStatus只负责写两个字段:

/// <summary>/// 条件更新单据审批状态与级次(服务端根据当前状态/级次计算,不接受客户端传入)。/// </summary>privatestaticIUpdate<TBill>SetApprovalStatus<TBill>(IUpdate<TBill>update,ApprovalStatusstatus,intlevel)whereTBill:class,IApprovalBill=>update.Set(x=>x.ApprovalStatus,status).Set(x=>x.CurrentLevel,level);

参数注释里那句话很关键:状态和目标级次都是服务端算出来的,不接受客户端传"我要把它改成第 3 级"。客户端只能表达"我要同意",能不能推进由服务端根据当前数据决定。

2. 对应的 SQL

上面的 LINQ 最终会生成类似:

UPDATEblog_articleSETApprovalStatus=1,CurrentLevel=3WHEREId=1001ANDApprovalStatus=1ANDCurrentLevel=2;

数据库保证这条UPDATE的原子性。两个请求同时执行时:

请求 A:UPDATE ... WHERE CurrentLevel = 2 → affected = 1(成功) 请求 B:UPDATE ... WHERE CurrentLevel = 2 → affected = 0(此时已经是 3)

affected = 0就是"有人抢先了"的信号,直接返回冲突,不再执行后续步骤。

3. 冲突怎么反馈给用户

if(outcome.Conflict){// 兼容既有提示语:单据级条件更新失败通常意味着他人已把单据推进/结束returnnewApprovalSubmitResult(false,L("该单据已被其他人处理,请刷新后重试"));}

返回的是可读提示,不是异常堆栈。异常信息通过GenericFailure(ex)统一处理成通用提示(只带 TraceId,不泄露数据库细节)。


四、核心机制二:原子占用审批节点

只更新业务状态还不够。sys_approval_record里保存着节点和待办,如果业务状态更新成功但节点记录没被正确占用,就会出现"节点还在待办里"或者"两个人各写一条审批意见"的问题。

所以紧接着是第二步——原子占用当前待办节点:

// 2) 原子占用当前待办节点:只有"当前节点 + 待处理 + 审批人是我"的记录会被更新。// 或签场景下 A、B 同时点同意时这里只有一个请求 affected > 0,不会重复推进节点。varclaimed=awaittx.GetRepository<SysApprovalRecord>().UpdateDiy.Set(x=>x.Status,ApprovalRecordStatus.Approved).Set(x=>x.Comment,commentSnapshot).Set(x=>x.OperateTime,DateTime.Now).Set(x=>x.IsCurrent,false).Set(x=>x.OperatorUserId,userId).Set(x=>x.OperatorUserName,userName).Set(x=>x.BeforeStatus,ApprovalStatus.Pending).Set(x=>x.AfterStatus,status).Where(x=>x.BillType==billType&&x.BillId==billId&&x.Round==round&&x.Level==currentLevel&&x.IsCurrent&&x.Status==ApprovalRecordStatus.Pending&&(x.ApproverUserId==userId||_user.IsAdministrator)).ExecuteAffrowsAsync();if(claimed<=0)returnApprovalOperationOutcome.CreateConflict();

注意WHERE里的五个条件缺一不可:

条件作用
Round只操作当前轮次,历史轮次不动
Level == currentLevel只操作当前级次
IsCurrent只操作"当前节点"
Status == Pending只操作待处理,已处理的不动
ApproverUserId == userId(或管理员)只有该节点的审批人能认领

这就是所谓"认领":谁先把这条记录从Pending改成Approved,谁就完成了这个节点。第二个请求的claimed是 0,拿不到节点,整个操作回滚。


五、或签:同节点多人时怎么收尾

"角色"来源的审批节点往往是多人(或签)。A 同意之后,同一节点的 B、C 就不应该再看到这条待办。

源码的处理是把同节点剩余待处理记录统一置为Skipped:

// 3) 或签:同节点其他待处理记录自动跳过awaittx.GetRepository<SysApprovalRecord>().UpdateDiy.Set(x=>x.Status,ApprovalRecordStatus.Skipped).Set(x=>x.IsCurrent,false).Set(x=>x.OperateTime,DateTime.Now).Where(x=>x.BillType==billType&&x.BillId==billId&&x.Round==round&&x.Level==currentLevel&&x.Status==ApprovalRecordStatus.Pending).ExecuteAffrowsAsync();

注意这里没有ApproverUserId == userId条件——目的就是把同节点其他所有人的待办一起清掉。

Skipped是独立的记录状态,和"同意/驳回"区分开。这样时间线上能看出"A 同意了,B、C 被跳过",而不是把 B、C 显示成同意了。


六、节点推进:状态机 + 快照

下一步是决定"往哪走",由纯函数状态机负责:

var(status,nextLevel)=ApprovalStateMachine.Next(ApprovalAction.Approve,currentLevel,maxLevel);
ApprovalAction.ApprovewhencurrentLevel<maxLevel=>(ApprovalStatus.Pending,currentLevel+1),ApprovalAction.Approve=>(ApprovalStatus.Approved,0),

这里的maxLevel不是直接取流程配置,而是优先取提交时落库的节点快照:

/// <summary>/// 计算流程的有效最大级次。/// 优先以"提交时已落库的节点"为准(流程实例快照),这样运行中的审批不会因为/// 流程配置后来被修改而突然改变节点数量或节点内容。/// </summary>privateintResolveMaxLevel(IReadOnlyCollection<SysApprovalRecord>currentRecords,ApprovalFlowConfigflow){...varmaxLevel=(int?)_orm.Select<SysApprovalRecord>().Where(x=>x.BillType==billType&&x.BillId==billId&&x.Round==round&&x.Level>0).Max(x=>(int?)x.Level);snapshotMax=maxLevel??0;// 快照缺失(历史数据)时退回流程配置,但必须至少覆盖当前级次varconfiguredMax=flow.Levels.Count==0?0:flow.Levels.Max(x=>x.Level);varmax=Math.Max(snapshotMax,configuredMax);...}

这是个很实务的考虑:管理员在流程跑到一半时改了流程配置(比如从 3 级改成 2 级),正在审批中的单据不应该突然"少一级"或"多一级"。以提交时的快照为准,配置只影响新提交的单据。

七、下一级激活失败怎么办

如果还有下一级,要把下一级节点置为IsCurrent = true。这里有一个明确的一致性要求:

if(next.Count==0){// 下一节点缺失属于流程数据不一致,必须回滚而不是让流程悬空thrownewInvalidOperationException($"审批流程数据不一致:单据{billType}#{billId}缺少第{nextLevel}级待办节点");}foreach(varrecordinnext)record.IsCurrent=true;awaittx.GetRepository<SysApprovalRecord>().UpdateDiy.SetSource(next).ExecuteAffrowsAsync();

抛异常 → 整个事务回滚 → 业务状态回到"第 N 级审批中",审批记录也没被改。这比"业务状态推到第 N+1 级、但没有任何人收到待办"要好得多。第 18 篇会详细讲这个事务。


八、驳回、撤回、转交用的是同一套模式

驳回

// 1) 业务状态原子更新varaffected=awaitSetApprovalStatus<TBill>(tx.GetRepository<TBill>().UpdateDiy,status,level).Where(a=>a.Id==billId&&a.ApprovalStatus==ApprovalStatus.Pending&&a.CurrentLevel==currentLevel).ExecuteAffrowsAsync();if(affected<=0)returnnull;// 2) 原子占用当前节点(改为 Rejected)varclaimed=awaittx.GetRepository<SysApprovalRecord>().UpdateDiy.Set(x=>x.Status,ApprovalRecordStatus.Rejected)....ExecuteAffrowsAsync();if(claimed<=0)returnnull;// 3) 同节点其他待处理记录跳过// 4) 后续节点全部跳过,避免待办列表出现幽灵任务// 5) 本轮结果回写发起记录

驳回比同意多一步:后续节点要全部跳过。否则单据已经"已驳回",后面的审批人待办里还挂着任务,就出现了幽灵待办。

撤回

撤回的前置校验更严格:

varrecords=awaitGetRecordsAsync(billType,billId);if(records.Any(x=>x.StatusisApprovalRecordStatus.ApprovedorApprovalRecordStatus.Rejected))returnnewApprovalSubmitResult(false,L("已有审批人处理过该单据,不能撤回"));

只要有人处理过就不能撤回。然后同样是"业务状态条件更新 + 占用当前节点 + 后续节点跳过 + 留痕":

// 1) 业务状态原子更新:仍是"审批中 + 当前级次"才允许撤回,// 与审批人的同意/驳回形成互斥(谁先提交谁生效)varaffected=awaitSetApprovalStatus<TBill>(tx.GetRepository<TBill>().UpdateDiy,status,level).Where(a=>a.Id==billId&&a.ApprovalStatus==ApprovalStatus.Pending&&a.CurrentLevel==currentLevel).ExecuteAffrowsAsync();if(affected<=0)returnfalse;

那句注释是重点:同意和撤回竞争的是同一个条件更新,谁先提交谁生效,不需要额外的互斥锁。

转交

转交不改业务状态,只改"这个节点由谁处理":

// 原子转交:只有"当前节点 + 待处理 + 审批人是我"的记录会被改写。// 两个并发转交只有一个 affected > 0,不会互相覆盖。varaffected=awaittx.GetRepository<SysApprovalRecord>().UpdateDiy.Set(x=>x.ApproverUserId,toUserIdSnapshot).Set(x=>x.ApproverUserName,targetNameSnapshot).Set(x=>x.OperatorUserId,userId).Set(x=>x.OperatorUserName,userName).Where(x=>x.BillType==billType&&x.BillId==billId&&x.Round==round&&x.Level==currentLevel&&x.IsCurrent&&x.Status==ApprovalRecordStatus.Pending&&(x.ApproverUserId==userId||_user.IsAdministrator)).ExecuteAffrowsAsync();if(affected<=0)returnnull;

转交还有几条业务校验,防止把流程搞乱:

  • 转交对象必须存在且未停用;
  • 不能转交给单据发起人(避免变相自审);
  • 不能转交给当前节点已有的审批人(避免重复任务)。

九、审计留痕:历史只允许新增

处理并发时还有一个隐含要求:不能为了"修复状态"去改历史记录。

源码里的做法是:当前节点记录被条件更新(认领),但结果留痕、转交留痕、撤回留痕都是INSERT新记录:

// 转交留痕:历史只新增,记录"原审批人 → 目标用户 + 原因",// 因此仍能追溯到"原来是谁、什么时候转给谁、为什么转交"。vartrail=NewRecord(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.ToUserId=toUserIdSnapshot;trail.ToUserName=targetNameSnapshot;trail.Comment=commentSnapshot;awaittx.GetRepository<SysApprovalRecord>().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 → 审批记录必须保持原样,不能出现"记录已同意、业务还在审批中"

十一、这套方案不覆盖什么

条件更新解决的是"同一行数据的竞争",它不能替代所有并发控制:

  1. 跨单据的业务规则(例如"这个客户所有合同的审批总额不能超过 100 万")不在这里处理,需要在业务层加锁或做汇总校验。
  2. 多实例下的初始化类操作(例如动态创建租户)仍然需要分布式锁——条件更新保护的是已存在的数据行。
  3. 真正的会签/并行分支超出模块范围,需要工作流引擎。
  4. 条件更新依赖数据库隔离级别。主流数据库的默认隔离级别下,UPDATE ... WHERE的原子性是有保证的;如果你的环境做了特殊设置(比如某些只读副本上写),要单独验证。

十二、小结

EasyAdminBlazor 审批并发控制的核心可以概括成两句话:

  1. 业务状态用"状态 + 级次"条件更新,affected <= 0就代表有人抢先,直接返回冲突;
  2. 审批节点用"当前节点 + 待处理 + 审批人是我"条件更新来认领,或签时其余待处理记录置Skipped,历史只新增不改写。

这两个条件更新都在同一个事务里,所以不会出现"状态改了但节点没认领"的半成功状态——那是下一篇文章的主题。


如果你的后台需要审批,又担心"两个人同时点会怎样",可以让团队直接读 EasyAdminBlazor 的审批实现:并发语义写在了条件更新的WHERE里,也写在了测试用例里。

  • 文档:https://easyadmin.wang-zhan.com.cn/doc
  • 源码:https://gitee.com/gudufy/EasyAdminBlazor

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

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

立即咨询