审批里最容易被低估的问题不是"流程怎么配",而是两个人同时点"同意"会发生什么。
如果处理得不好,结果可能是:单据被推进两级、同一个节点被完成两次、历史里出现两条互相矛盾的审批记录、或者"已通过"之后又被驳回。这篇文章讲 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 → 审批记录必须保持原样,不能出现"记录已同意、业务还在审批中"十一、这套方案不覆盖什么
条件更新解决的是"同一行数据的竞争",它不能替代所有并发控制:
- 跨单据的业务规则(例如"这个客户所有合同的审批总额不能超过 100 万")不在这里处理,需要在业务层加锁或做汇总校验。
- 多实例下的初始化类操作(例如动态创建租户)仍然需要分布式锁——条件更新保护的是已存在的数据行。
- 真正的会签/并行分支超出模块范围,需要工作流引擎。
- 条件更新依赖数据库隔离级别。主流数据库的默认隔离级别下,
UPDATE ... WHERE的原子性是有保证的;如果你的环境做了特殊设置(比如某些只读副本上写),要单独验证。
十二、小结
EasyAdminBlazor 审批并发控制的核心可以概括成两句话:
- 业务状态用"状态 + 级次"条件更新,
affected <= 0就代表有人抢先,直接返回冲突; - 审批节点用"当前节点 + 待处理 + 审批人是我"条件更新来认领,或签时其余待处理记录置
Skipped,历史只新增不改写。
这两个条件更新都在同一个事务里,所以不会出现"状态改了但节点没认领"的半成功状态——那是下一篇文章的主题。
如果你的后台需要审批,又担心"两个人同时点会怎样",可以让团队直接读 EasyAdminBlazor 的审批实现:并发语义写在了条件更新的WHERE里,也写在了测试用例里。
- 文档:https://easyadmin.wang-zhan.com.cn/doc
- 源码:https://gitee.com/gudufy/EasyAdminBlazor