Activiti 6.0 系列写到第六篇,这一篇专门聊并行网关。
工作流里最常见的场景,就是“一个任务拆成多个子任务同时分给不同的人处理,全部完成后再继续往下走”。比如采购申请要同时让财务、法务、技术三个部门会签,三个部门都在自己权限内并行处理,全部同意之后流程才进入下一节点。用代码硬写这种逻辑很痛苦,但用流程引擎本身就简单很多——这就是并行网关(Parallel Gateway)的核心价值。
我最初接触并行网关时总把它想复杂了,以为它和代码里的多线程、并发控制有什么深层关系。实际用下来发现,并行网关只是流程定义层面的一个“分流汇合标记”,它不负责真正开线程,所有并行分支仍然由引擎以同步方式推进。搞清楚这个认知,后面很多问题就都会迎刃而解。
1. 并行网关的核心原理与运行机制
1.1 并行网关到底帮我们做了什么
并行网关在 BPMN 规范里也叫“并行分叉网关”,它做的事情用两句话就能概括:分叉时把所有出口都激活,汇合时等所有入口都到齐。
分叉(Fork)场景是这样:流程走到并行网关时,引擎会查看这个网关后面连了几条顺序流。排他网关是“选了这条就不走那条”,并行网关恰恰相反——它不做任何条件判断,每一条出口顺序流都会被激活,生成对应的执行实例(Execution)。也就是说,5 条出口就会生成 5 个并行执行分支,每个分支都从自己的后继节点继续运行。
汇合(Join)场景是这样:并行网关作为汇合节点时,引擎会统计有几条入口顺序流。每来一个分支,引擎就记录一次,等到所有入口顺序流对应的分支都到达了,它才把流程继续向后推动。如果只来了其中一部分,流程就卡在网关这里,表现为流程实例停留在“等待”状态。
这里有一个关键的运行细节:汇合时并行网关会“吞掉”先到的分支。比如 3 条入口顺序流,第一条先到达后,引擎不会让流程实例做出任何推进动作,只是内部记录“已到达 1/3”,直到第 3 条也到了,它才合并所有分支数据,生成一个后续执行实例并继续流转。这个特性直接导致了后面要讲的“死锁”问题。
1.2 从 BPMN 文件看并行网关的真实面目
用 Activiti 6.0 建模时,并行网关在 BPMN XML 里的表现形式如下:
<parallelGateway id="forkGateway" name="并行分叉" /> <parallelGateway id="joinGateway" name="并行汇合" />分叉网关和汇合网关本质上用的是同一个标签,它们在流程定义里是“成对”出现的。一个并行网关既可以做分叉,也可以做汇合,完全取决于它在流程里的位置和连线的方向。官方建模工具里通常用菱形加加号图标来表示,菱形加 X 是排他网关,菱形加 O 是包容网关,加号就是并行网关——这个图形差异记住就不会选错。
流程流转时,并行网关并不知道自己下游要等哪些节点,它只看“出口顺序流”。所以如果你建少了连线,或者连线的 targetRef 写错了,并行分支数就会和预期不一致。实际项目的流程定义我建议直接在 XML 源码里审查,图形界面拉完线之后经常存在视觉上的“看似相连、实际没连上”的情况。
2. 并行网关和排他、包容网关的选型差异
2.1 三种网关的核心区别对比
很多初学者在建模时拿不准该用哪种网关。我把最常见的三种网关放在一起对比,看完基本就清楚了:
| 网关类型 | 分叉策略 | 汇合策略 | 典型场景 |
|---|---|---|---|
| 排他网关 | 只走第一个满足条件的分支 | 只要有任意一个分支到达,就继续流转 | 审批通过/驳回、金额分档处理 |
| 并行网关 | 所有出口分支全部激活 | 必须等全部分支都到达 | 会签、并行任务、多部门同时审批 |
| 包容网关 | 激活所有满足条件的分支 | 满足条件的分支全部到达才会继续 | 部分会签、有条件的并行处理 |
排他网关处理的是“多选一”的问题,并行网关处理的是“全选”的问题,包容网关则是“选部分”的问题。业务上如果有“同时要多人审批”的需求,那就是并行网关;如果是“只要有一方通过就行”,那是排他或者包容网关。
2.2 实际业务里如何快速判断
我之前接手过一个财务报销流程,产品经理最初画的图是“部门负责人审批、财务复核、出纳付款”三个节点串行。后来业务要求改成“负责人审批通过后,财务和出纳同时处理”,这里就出现了并行需求。最初团队成员想用排他网关加两个条件表达式来模拟,结果流程怎么跑都不对,原因就在于排他网关只会走其中一个分支。
判断标准就一条:这些分支任务是不是“都必须完成,且彼此没有先后要求”。只要答案是“是”,就闭眼用并行网关。
另外有个细节值得提醒:并行网关的多个分支之间并不要求“同时发生”,它只要求“最终都要到达汇合网关”。所以分支里有一个节点审批特别慢,其他快的分支到了汇合点也只能先等着,这是正常现象,不是引擎卡住了。
3. 从流程建模到跑通一次完整并行流转
3.1 第一步:设计一份可运行的流程定义
我先给出一份最基础的并行网关流程定义。它包含一个开始事件、一个用户任务、一个并行分叉网关、两个并行用户任务、一个并行汇合网关和一个结束事件。这份定义我建议直接拿去测试,跑通了再改造。
<?xml version="1.0" encoding="UTF-8"?> <definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:activiti="http://activiti.org/bpmn" targetNamespace="http://www.activiti.org/test"> <process id="parallelProcess" name="并行网关测试流程" isExecutable="true"> <startEvent id="startEvent" name="开始" /> <userTask id="initiateTask" name="发起申请" activiti:assignee="${initiator}" /> <parallelGateway id="forkGateway" name="分叉" /> <userTask id="taskA" name="任务A" activiti:assignee="userA" /> <userTask id="taskB" name="任务B" activiti:assignee="userB" /> <parallelGateway id="joinGateway" name="汇合" /> <endEvent id="endEvent" name="结束" /> <sequenceFlow id="flow1" sourceRef="startEvent" targetRef="initiateTask" /> <sequenceFlow id="flow2" sourceRef="initiateTask" targetRef="forkGateway" /> <sequenceFlow id="flow3" sourceRef="forkGateway" targetRef="taskA" /> <sequenceFlow id="flow4" sourceRef="forkGateway" targetRef="taskB" /> <sequenceFlow id="flow5" sourceRef="taskA" targetRef="joinGateway" /> <sequenceFlow id="flow6" sourceRef="taskB" targetRef="joinGateway" /> <sequenceFlow id="flow7" sourceRef="joinGateway" targetRef="endEvent" /> </process> </definitions>注意一个基础但容易犯错的点:BPMN 文件里的activiti:assignee我绑的是固定值userA、userB,这只是测试用。真实项目中建议绑变量,比如${assigneeA}、${assigneeB},否则任务分配写死会让后续流程复用非常痛苦。
3.2 第二步:部署流程并启动实例
部署和启动代码用 Activiti 6.0 的 API 可以这样写:
// 部署流程 repositoryService.createDeployment() .addClasspathResource("parallel-process.bpmn20.xml") .name("并行网关测试流程") .deploy(); // 启动流程实例 Map<String, Object> variables = new HashMap<>(); variables.put("initiator", "zhangsan"); ProcessInstance processInstance = runtimeService.startProcessInstanceByKey("parallelProcess", variables);启动后查询任务列表,你会发现一个有意思的现象:流程不会自动走到分叉网关后面的任务A和任务B,而是先停在“发起申请”节点。这是 Activiti 的正常机制——用户任务需要人工触发 complete 之后,流程才会继续推进到下一个节点。
3.3 第三步:完成任务并验证并行分支
模拟发起人完成“发起申请”任务:
// 查询发起人的任务 List<Task> tasks = taskService.createTaskQuery() .processInstanceId(processInstance.getId()) .taskAssignee("zhangsan") .list(); // 完成该任务 for (Task task : tasks) { taskService.complete(task.getId()); }完成之后,再查一次所有任务:
List<Task> currentTasks = taskService.createTaskQuery() .processInstanceId(processInstance.getId()) .list(); for (Task task : currentTasks) { System.out.println("当前任务节点: " + task.getName() + ",办理人: " + task.getAssignee()); }打印结果应该是两条任务记录:任务A(办理人 userA)和任务B(办理人 userB)。这一步就是并行网关分叉生效的直接证明——一个前置任务完成后,同时产生两个独立的任务实例。两个任务相互独立,谁先完成都不影响对方。
3.4 第四步:逐个完成并行分支,观察汇合行为
现在分别完成这两个任务。先完成任务A,再查流程状态:
taskService.complete(taskA.getId());此时任务B还存在,而且流程实例没有走到下一步,这说明汇合网关还在等待任务B。等任务B也完成后,流程才会走到结束事件:
taskService.complete(taskB.getId()); // 查询历史,确认流程是否结束 HistoricProcessInstance historic = historyService.createHistoricProcessInstanceQuery() .processInstanceId(processInstance.getId()) .singleResult(); System.out.println("流程结束时间: " + historic.getEndTime());如果打印出的 endTime 不为空,说明整个流程已经正常走完。这个过程中我特别建议你关注一下ACT_RU_EXECUTION表的数据变化。完成发起申请后,这张表里会多出两个并行执行实例,它们的PARENT_ID_指向同一个父执行;汇合完成后,并行子执行全部消失,只剩一个主执行继续向下走。理解这张表的变化,比死记网关概念有用得多。
4. 实际案例:并行网关实现多角色会签
4.1 会签场景的需求梳理
前面举的流程还是太简单了,真正最能体现并行网关价值的是“会签”场景。我拿一个最典型的需求拆解:
业务背景:某公司采购设备,金额超过 50 万时,需要技术总监、财务总监、法务总监三人同时审批。三者并行处理,互不依赖,但必须全部审批通过,合同才能进入用印环节。
建模思路很简单:发起人提交申请后,接一个并行网关,分叉到技术审批、财务审批、法务审批三个用户任务,再汇合到合同用印节点。
有人会问:这不是和前面两个分支的例子一样吗?区别在于,真实业务里每个会签节点不是“完成”就行,而是要有“同意/驳回”的表单数据,而且如果任意一个人驳回了,整个流程要能提前终止。后半句话就涉及到一个并行网关的常见改造方案。
4.2 带驳回逻辑的会签设计
严格讲,纯并行网关不支持“提前终止”这种逻辑。你可以在汇合网关之后加一个排他网关,判断所有会签结果里是否有“驳回”。但问题是:如果所有分支都等到汇合才判断,其中一个审批人驳回了,其他两个人还是要把流程走完——这在真实办公场景里效率太低。
常用的方案是用事件子流程配合消息中间件来打断并行分支,或者简化业务规则:把“驳回”也视为一种“完成”,最后在汇合后的排他网关上统一判断。折中后的设计是:
流程走向:发起申请 -> 并行分叉 -> 技术审批/财务审批/法务审批 -> 并行汇合 -> 排他网关判断结果 -> 通过则合同用印,驳回则退回修改。
实现上,每个会签节点的表单里带一个布尔变量,比如techApprove、financeApprove、legalApprove。汇合之后用 EL 表达式判断:
<exclusiveGateway id="judgeGateway" name="会签结果判断" /> <sequenceFlow id="approveFlow" sourceRef="judgeGateway" targetRef="contractSeal"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${techApprove == true && financeApprove == true && legalApprove == true}]]> </conditionExpression> </sequenceFlow> <sequenceFlow id="rejectFlow" sourceRef="judgeGateway" targetRef="modifyTask"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${techApprove == false || financeApprove == false || legalApprove == false}]]> </conditionExpression> </sequenceFlow>完整完成任务时,记得把审批结果存入流程变量:
Map<String, Object> vars = new HashMap<>(); vars.put("techApprove", true); taskService.complete(taskId, vars);这种设计的好处是流程定义简单,所有会签结果都在汇合后统一处理;代价是驳回场景下并行分支要全部走完。如果你的业务流程允许,建议把“驳回”设计成“不通过也会走到汇合”,避免引入复杂的终止子流程。等流程跑稳定了,再考虑用事件子流程做超时未处理之类的增强。
4.3 会签任务实现时的参数传递经验
会签场景最容易踩的坑是变量覆盖。多个并行分支操作同一个流程实例时,如果用的是runtimeService.setVariable(),各分支间会互相覆盖数据。我推荐的做法是:先拿到当前任务对应的 executionId,用taskService.complete()传局部变量。这样每个分支的审批结果都挂在各自的执行实例上,汇合后引擎会将所有子执行的变量合并到主执行上。
这里有个细节:汇合时多个并行子执行如果都设置了同名变量,后到达的会覆盖先到达的。所以我在设计变量名时就刻意区分了techApprove、financeApprove、legalApprove,而不是统一叫approve——这就是实战经验,设计变量时留个心眼,能为后续省掉大量排错时间。
5. 常见问题与排查技巧实录
5.1 流程走到并行网关后卡住不动
这是使用并行网关最常遇到的问题。现象是:流程实例显示在网关节点,但所有任务都查询不到,流程也不继续走。排查思路按下面三步走,基本能定位:
第一步,查ACT_RU_EXECUTION表,看有多少条执行实例,它们的ACT_ID_是什么。第二步,查ACT_RU_TASK表,看是否存在等待中的任务。第三步,对照 BPMN XML 检查连线关系。
最常见的原因是:分叉网关和汇合网关没有正确配对。比如你建了三条并行分支,但汇合网关只画了两条入口连线,其中一个分支直接绕过汇合网关连到了后面的节点,这样不仅会产生错误的分支行为,甚至可能直接报错。遇到这种情况,直接在源码里数每一个网关的 outgoing/ incoming 数量,一眼就能看出问题。
还有一种隐蔽情况:某个并行分支上的用户任务没有设置办理人,导致任务创建后没有 assignee,但你按 assignee 查询时自然查不到。用taskQuery().processInstanceId(...).list()不带任何过滤条件,就能看到这类“无主任务”。
5.2 并行网关分支只走了一条
现象是:分叉网关之后只有一个任务被创建,另一个分支没有任何反应。这种情况基本不是网关问题,而是分支连线写错了。最常见的低级错误是两条分支连到了同一个目标任务上,或者分叉网关的其中一条出口不是指向用户任务,而是直接指向了汇合网关。XML 里检查一下之后其实很容易看出问题。
另外还有一种可能:你用了并行网关,代码里却混用了runtimeService.createProcessInstanceQuery()只查了主执行分支,没查子执行。并行分支的任务本来就在子执行上,代码过滤条件如果限制得太死,就会误以为分支没创建。
5.3 并行网关和多人审批的区别
这点值得单独拿出来说,因为很多人在群里问过类似问题:任务需要多个人处理,是不是就该用并行网关?我明确回答:不是。
并行网关解决的是“多个不同任务同时处理”的问题;如果一个任务本身要多人办理,且办理人之间互相可见、任意一人完成后任务就结束,那应该用用户任务的多人办理配置,比如activiti:assignee配合候选人组,而不是并行网关。
如果你要给同一个任务配置“多人会签”,每人都必须审批、全部通过才流转,在 Activiti 6.0 里通常用多实例(Multi-instance)子元素配合并行网关实现,或者直接给用户任务加activiti:collection和activiti:elementVariable做多实例循环。简而言之,并行网关控制的是流程的“形状”,多实例控制的是单个任务上的“人数”,两个概念不要混为一谈。
5.4 并行网关死锁的典型案例
关于“死锁”我再展开讲一个真实案例。某次我在一个模拟项目里给流程只加了分叉网关,忘记在最后加汇合网关,而是让两个分支的末端分别连向了两个不同的结束事件。这种流程定义能部署、能启动,分叉也正常,但流程结束后查看历史会发现留下了两个不相干的执行记录,而且业务上无法确定“流程是否真正跑完”。
更严重的死锁场景是:分叉网关后有两个分支,分支A最终会流到汇合网关,分支B却流到了结束事件。这时分支B先到汇合点等待,而分支A已经到结束事件,永远不会再到汇合点,流程实例就永久卡死了。这种问题在 BPMN 建模工具里不容易发现,我建议每画完一个并行网关,都手动检查一下所有分支最终是否都汇合到同一个节点——这也是我在项目中把它列为流程评审必检项的原因。
6. 并行网关实操中的几个避坑建议
6.1 流程定义阶段建议在 XML 源码层面做 review
图形化建模工具给人很强的误导性——线连没连上看不清,网关类型图标容易混淆。尤其是并行网关和包容网关,在工具里的图标相似度很高,选错了一个流程就跑飞。我实操的习惯是:每次在工具里画完流程图,都要打开 XML 源码逐个检查parallelGateway节点的outgoing数量。分叉网关的 outgoing 数量等于分支数,汇合网关的 incoming 数量也等于分支数,这个核对方法简单且可靠。
6.2 关于流程变量的作用域
并行分支上的任务变量默认是子执行实例级别,汇合后统一合并到主执行实例。这意味着你在任意一个分支里设置了变量,汇合后流程后续节点都能读取。但有一个坑:如果并行分支里调用了 Java 服务任务或脚本任务,它们对变量的修改可能在汇合时发生冲突。稳妥的做法是让每个分支写不同的变量名,绝对避免不同分支同时写同一个变量名。
6.3 合理利用历史表来验证流程模型
并行网关相关的问题,在运行期看运行时表只能看到“当前状态”,要看到全流程的走向,必须结合历史表。我常用的一条 SQL 是:
SELECT * FROM ACT_HI_ACTINST WHERE PROC_INST_ID_ = ? ORDER BY START_TIME_;这条 SQL 能把流程实例经过的所有节点按时间列出来。如果预期是“发起 -> 分叉 -> 任务A/任务B -> 汇合 -> 结束”,而实际记录里缺少了某个节点,或者顺序反了,对照这张表就可以快速确认模型哪一步出了问题。
6.4 避免在并行网关后面直接挂脚本任务
并行网关分叉后直接挂 Java 服务任务或脚本任务,引擎会同步执行这些分支,并不会真正开多线程并行。如果一个分支里的服务任务执行时间很长,它会阻塞其他分支的推进吗?不会。引擎是逐个将每个分支的执行实例放入命令上下文处理的,但从调用方的视角看,所有分支会在同一个事务提交后全部完成。如果你的服务任务里有远程调用、网络IO、重量级计算,建议在服务任务里自己做异步化,否则整个流程的“并行”只是逻辑上的并行,执行时仍然会占用引擎线程,影响其他流程实例的处理速度。
7. 写在最后的一点体会
并行网关是我在实际开发中用得最多的一种网关,它的逻辑简单、表现稳定,但正因为简单,很多人反而不重视建模细节。我见过太多“生产环境流程跑一半卡死”的案例,最终排查下来不是引擎问题,而是流程图本身画错了——要么汇合网关漏了连线,要么分支上挂了“永不结束”的子流程。
我个人建议每一个刚接触 Activiti 的人,都先用最简单的流程定义把并行网关的分叉、汇合、变量传递、历史记录完整跑一遍,亲眼看一下ACT_RU_EXECUTION表的变化,遇到问题再去查文档,会顺手很多。后续如果业务复杂度上来了,再去了解多实例、事件子流程这些进阶玩法——它们的底层很多思路都建立在并行网关之上。