Activiti网关、Execution与分支汇聚原理
网关不是流程图上的装饰菱形,而是控制 Execution 如何分裂、选择和汇聚的执行节点。排他网关解决“多选一”,并行网关解决“全部同时走”,包容网关解决“满足条件的多条都走”。三者看起来都能画分支,但运行语义完全不同。
如果只会写 ${approved == true},却不知道网关进入同一个数据库事务、并行分支为什么产生多个 Execution、汇聚到底等待谁,就无法解释“任务完成接口报错但任务仍在”“明明完成了两个审批却卡在汇聚”“扩容应用后流程仍不继续”等生产问题。
学习目标
完成本页后,你应该能够:
- 区分 Sequence Flow、Activity、Execution、Task 和流程实例。
- 解释排他、并行、包容和事件型网关的准确语义。
- 解释分支时 Execution 怎样分裂,汇聚时怎样合并。
- 正确设计条件表达式、默认流和变量类型。
- 说明“排他网关汇聚不等待、并行网关汇聚要等待”的原因。
- 解释包容网关为什么只等待真正被激活且可到达的分支。
- 写出可执行 BPMN 和安全的 Java 任务完成 Demo。
- 处理多人审批、可选会审、超时和异常分支等商业场景。
- 根据 Task、Execution、Variable、Job、History 和日志排查卡网关。
- 回答网关常见面试题并指出错误建模后果。
一、先理解流程为什么需要网关
如果没有网关,业务代码容易出现:
if (amount.compareTo(new BigDecimal("10000")) > 0) {
createFinanceTask();
if (sensitive) {
createSecurityTask();
}
} else {
finish();
}随着角色、分支、会签、超时和驳回增加,条件散落在 Controller、Service、定时任务和消息消费者里,流程图与代码无法对应,历史也无法回答“当时为什么走这条路”。
工作流网关把路由结构放进 BPMN,可执行条件使用流程变量:
flowchart TD
A["提交数据资产发布申请"] --> B{"是否包含敏感字段"}
B -->|"是"| C["安全审核"]
B -->|"否"| D["数据负责人审核"]
C --> D
D --> E["发布完成"]但网关不应承担复杂业务计算。推荐先由业务 Service 计算稳定事实,例如 sensitive=true、riskLevel=HIGH,再让网关读取这些小而明确的变量。
二、五个前置概念
2.1 Sequence Flow
Sequence Flow 是 BPMN 节点之间的有向连线。条件通常配置在连线上,不是配置在排他网关节点本身。
2.2 Activity
User Task、Service Task、Gateway、Event 都是流程执行中的节点类型。每个节点应有稳定且唯一的 id,因为运行时、历史、迁移和排查都依赖 Activity ID。
2.3 Execution
Execution 可以理解为引擎内部表示“当前执行走到哪里”的执行路径或 Token 载体。一个流程实例至少有根 Execution;并行分支出现后,可能产生多个并发子 Execution。
Execution 不是 Java Thread。两个并行 BPMN 分支不代表引擎同时创建两个操作系统线程。它表示流程语义上的并发路径,真正执行时间取决于同步命令、异步作业和任务完成时机。
2.4 Task
User Task 到达后会产生待办 Task。Task 是人要办理的工作项,Execution 是引擎内部执行路径。一个并行流程实例可以同时有多个 Task 和多个 Execution。
2.5 ProcessInstance
流程实例是某张业务单据的一次完整流程运行。并发 Execution 仍属于同一个 ProcessInstance,共享或继承流程变量语义,但局部变量可能只在特定执行作用域可见。
flowchart TD
A["ProcessInstance根Execution"] --> B["并行分支Execution A"]
A --> C["并行分支Execution B"]
B --> D["安全审核Task"]
C --> E["数据负责人Task"]三、四类常见网关总览
| 网关 | 分支语义 | 汇聚语义 | 条件来源 | 典型场景 |
|---|---|---|---|---|
| Exclusive Gateway | 满足条件的路径中只选择一条 | 不等待其他路径,直接继续 | Sequence Flow条件+默认流 | 金额分级、通过/拒绝 |
| Parallel Gateway | 所有出线都激活 | 等待所需并行入线到达 | 不依赖条件 | 财务和法务必须同时审核 |
| Inclusive Gateway | 激活所有条件为true的路径 | 等待本次实际激活且能到达汇聚的路径 | 每条出线条件+默认流 | 根据风险可选多个部门会审 |
| Event-based Gateway | 不按变量选路,等待首先发生的事件 | 通常不作普通同步汇聚 | 消息、信号、定时等事件 | 等待用户响应或超时 |
复杂网关和部分事件组合在不同 Activiti 版本中的支持程度不同。商业建模优先选择语义明确、团队能测试和排查的元素,不为了“图上高级”而使用罕见网关。
四、排他网关:满足条件只走一条
排他网关常写作 XOR Gateway。它用于互斥分支:一次到达只选择一个出口。
flowchart TD
A["提交报销单"] --> B{"金额范围"}
B -->|"amount小于等于5000"| C["直属主管审批"]
B -->|"amount大于5000"| D["部门负责人审批"]
B -->|"没有条件命中"| E["默认异常处理"]4.1 排他分支执行过程
flowchart TD
A["Execution进入排他网关"] --> B["读取当前变量作用域"]
B --> C["按引擎模型顺序检查出线"]
C --> D{"某条条件是否为true"}
D -->|"是"| E["选择该Sequence Flow"]
E --> F["Execution沿唯一出口继续"]
D -->|"否"| G{"是否还有条件出线"}
G -->|"有"| C
G -->|"没有"| H{"是否配置Default Flow"}
H -->|"有"| I["沿默认线继续"]
H -->|"没有"| J["抛出无可用出线异常并回滚命令"]“第一条为 true 的出线”在常见 Activiti 实现中与模型解析后的顺序有关,但 BPMN 图形布局不应被当作业务优先级契约。更安全的做法是让条件互斥,不让两条同时为 true。
错误条件:
flow A: amount >= 5000
flow B: amount >= 10000当 amount=15000 时两条都为 true,最终选择可能依赖顺序,业务含义不清。
正确做法:
flow A: amount >= 5000 && amount < 10000
flow B: amount >= 10000或者在业务层先计算唯一 approvalLevel,网关只比较枚举值。
4.2 默认线为什么重要
默认线是所有条件都不满足时的兜底,不等于“正常通过”。它应该进入明确异常处理、人工复核或拒绝结束,避免数据静默走错分支。
默认 Sequence Flow 上即使配置条件,也通常不会按普通条件线使用;模型应把它当无条件兜底,具体校验以引擎版本为准。
4.3 排他汇聚为什么不等待
排他网关用作合并时,不会等待所有入线,因为互斥分支理论上只有一条被激活。任意一条 Execution 到达后即可继续。
flowchart TD
A{"申请结果"} -->|"通过"| B["生成发布记录"]
A -->|"拒绝"| C["记录拒绝原因"]
B --> D{"排他合并"}
C --> D
D --> E["归档流程"]如果上游实际存在并行分支,却错误使用排他网关汇聚,两条路径可能各自穿过汇聚并让下游执行两次。需要同步并行路径时必须使用有同步语义的网关。
五、并行网关:所有分支都走
并行网关也叫 AND Gateway。分支时忽略条件,激活所有出线;汇聚时等待需要同步的入线。
flowchart TD
A["合同提交"] --> B{"并行拆分"}
B --> C["法务审核"]
B --> D["财务审核"]
C --> E{"并行汇聚"}
D --> E
E --> F["合同盖章"]5.1 并行拆分怎样产生Execution
flowchart TD
A["一个Execution到达并行网关"] --> B["为所有出线创建并发执行路径"]
B --> C["Execution A进入法务User Task"]
B --> D["Execution B进入财务User Task"]
C --> E["产生法务待办"]
D --> F["产生财务待办"]这两个任务可以由不同人在不同时间完成。它们属于同一个流程实例,但不是两个独立流程实例。
5.2 并行不等于多线程同时执行
如果拆分后是两个同步 Service Task,具体引擎可能在同一个命令和线程中依次执行节点;只有配置异步继续、作业执行器等机制后,才可能由不同 Job 在不同线程/节点上处理。
因此:
BPMN并行 = 多条执行路径可以独立推进
不等于 = Java代码必然同时运行5.3 并行汇聚为什么会等待
只有一条分支到达时,汇聚不能继续,否则下游会在其他审批未完成时提前执行。引擎会保留到达状态,直到同一流程实例中需要同步的并行路径都到达。
flowchart TD
A["法务分支到达汇聚"] --> B["记录一个Execution已到达"]
B --> C{"财务分支是否也到达"}
C -->|"否"| D["保留等待状态,下游不执行"]
E["财务分支稍后到达"] --> F["检查同一并行结构的到达情况"]
F --> G["条件满足,合并Execution"]
G --> H["创建盖章任务"]5.4 为什么会永远卡在并行汇聚
典型错误模型:
flowchart TD
A{"并行拆分"} --> B["分支A"]
A --> C["分支B"]
B --> D{"条件判断"}
D -->|"通过"| E{"并行汇聚"}
D -->|"拒绝"| F["直接结束"]
C --> E当分支 A 走“直接结束”,汇聚可能永远等不到对应路径。是否卡死与模型结构、结束事件类型和引擎执行语义有关,但这种不对称设计本身就难以证明正确。
解决思路:
- 让所有被并行拆出的路径在正常路径上成对汇聚。
- 如果任一拒绝要终止整个流程,使用明确的终止语义并测试其他分支如何取消。
- 将条件放在并行结构外,避免一部分路径绕过 Join。
- 对所有真值组合做模型测试。
六、包容网关:满足条件的多条都走
包容网关也叫 OR Gateway。它介于排他和并行之间:
- 排他:只选一条。
- 并行:所有出线都走。
- 包容:所有条件为 true 的出线都走。
商业场景:数据资产发布时,根据数据特征选择审核部门:
flowchart TD
A["提交数据资产"] --> B{"包容拆分"}
B -->|"sensitive等于true"| C["安全审核"]
B -->|"crossBorder等于true"| D["跨境合规审核"]
B -->|"qualityRisk等于HIGH"| E["质量审核"]
C --> F{"包容汇聚"}
D --> F
E --> F
F --> G["数据负责人终审"]如果三个条件中两个为 true,本次实例只激活两条路径,汇聚应等待这两条,而不是永远等待未激活的第三条。
6.1 包容拆分过程
flowchart TD
A["Execution进入包容网关"] --> B["计算每条条件出线"]
B --> C["收集所有结果为true的出线"]
C --> D{"命中数量是否大于0"}
D -->|"是"| E["为每条命中出线激活执行路径"]
D -->|"否且有默认线"| F["激活默认路径"]
D -->|"否且无默认线"| G["抛异常并回滚"]6.2 包容汇聚为什么比并行复杂
并行网关可以按结构等待并行入线;包容网关必须判断“还有没有已激活、尚未到达但未来可能到达这里的 Execution”。未被拆分激活的路径不能成为等待条件。
flowchart TD
A["某个Execution到达包容汇聚"] --> B["检查同流程实例其他Execution"]
B --> C{"是否有已激活路径仍可能到达本汇聚"}
C -->|"有"| D["继续等待"]
C -->|"没有"| E["合并当前已到达路径"]
E --> F["沿汇聚后的Sequence Flow继续"]复杂嵌套、循环和交叉连线会提高“可到达性”判断和人工排查成本。包容网关不是不能用,而是要保持拆分/汇聚边界清楚,并测试所有条件组合。
七、事件型网关:谁先发生就走谁
事件型网关不读取 approved、amount 等普通条件来选路,而是等待事件。例如订单提交后:等待用户支付消息,或者等待 30 分钟超时,谁先发生就走谁。
flowchart TD
A["创建待支付订单"] --> B{"事件型网关"}
B --> C["等待支付消息"]
B --> D["等待30分钟定时器"]
C --> E["订单已支付"]
D --> F["订单超时取消"]关键点:
- 它表达的是事件竞争,不是变量条件判断。
- 当一个事件获胜后,其他等待通常应被取消。
- 支付回调和超时任务可能接近同时发生,业务层仍必须使用订单状态条件更新和幂等键防止双重副作用。
- 支持的 Catch Event 类型和组合取决于 Activiti 版本。
工作流引擎的事件竞争不能替代数据库并发控制。
八、条件表达式从哪里取变量
常见表达式:
<conditionExpression xsi:type="tFormalExpression"><![CDATA[
${approved == true}
]]></conditionExpression>表达式求值时会从变量作用域、引擎 Bean/表达式管理器等上下文解析名称,具体顺序随版本和配置不同。
流程变量可以来自:
- 启动实例时传入的变量。
- 完成 User Task 时传入的变量。
- RuntimeService/TaskService 设置的变量。
- Delegate、Listener 或脚本节点产生的变量。
- 局部变量作用域。
8.1 全局变量和局部变量
全局变量通常对流程实例中的后续 Execution 可见;局部变量只属于某个 Execution 或 Task 作用域。把网关所需变量误设为 Task Local,后续网关可能解析不到。
API 名称随版本略有差异,常见思路:
taskService.setVariable(taskId, "approved", Boolean.TRUE);
taskService.setVariableLocal(taskId, "temporaryComment", "仅当前任务使用");网关变量应有明确作用域契约,不要依赖“刚好能从父作用域找到”。
8.2 类型为什么比字符串值重要
下面三者不是同一个 Java 类型:
variables.put("approved", Boolean.TRUE);
variables.put("approved", "true");
variables.put("approved", 1);表达式 ${approved == true} 期望 Boolean。依赖表达式引擎隐式转换会让不同版本、不同输入产生难以预测的结果。
金额使用 BigDecimal:
variables.put("amount", new BigDecimal("10000.00"));不要使用 double 表示商业金额。更推荐业务层计算枚举:
variables.put("approvalLevel", ApprovalLevel.DIRECTOR.name());网关表达式变成:
${approvalLevel == 'DIRECTOR'}8.3 缺失变量会怎样
变量不存在时,表达式可能报“unknown property/cannot resolve identifier”,或者在某些表达式中按 null 参与比较。若异常发生在 taskService.complete 同一事务,任务完成和流程推进通常一起回滚,用户刷新后仍会看到原任务。
这不是 Activiti “重复生成任务”,而是事务没有提交。
九、任务完成到网关选择的事务过程
flowchart TD
A["业务服务校验任务和审批权限"] --> B["设置approved等流程变量"]
B --> C["调用taskService.complete"]
C --> D["删除或结束当前运行时Task"]
D --> E["Execution沿Sequence Flow进入网关"]
E --> F["表达式读取变量并选择分支"]
F --> G["创建下一Task或结束实例"]
G --> H["写历史和业务审批记录"]
H --> I["数据库事务提交"]如果 E/F/G 阶段抛异常,事务通常回滚:
- 原 Task 仍在运行时表中。
- 新 Task 不会提交。
- 同事务更新的业务状态也应回滚。
- 用户重试时需要幂等和并发校验。
如果业务状态更新和 taskService.complete 不在同一事务或不在同一数据库,就可能出现一边成功、一边失败。详细见:业务表与流程一致性。
十、完整排他网关BPMN Demo
<?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="https://example.com/processes">
<process id="expense_approval"
name="报销审批"
isExecutable="true">
<startEvent id="start"/>
<userTask id="managerReview"
name="直属主管审批"
activiti:candidateGroups="MANAGER"/>
<exclusiveGateway id="amountGateway"
name="判断审批级别"
default="toManualReview"/>
<userTask id="financeReview"
name="财务复核"
activiti:candidateGroups="FINANCE"/>
<userTask id="directorReview"
name="部门负责人审批"
activiti:candidateGroups="DIRECTOR"/>
<userTask id="manualReview"
name="异常数据人工复核"
activiti:candidateGroups="PROCESS_ADMIN"/>
<endEvent id="end"/>
<sequenceFlow id="f1" sourceRef="start" targetRef="managerReview"/>
<sequenceFlow id="f2" sourceRef="managerReview" targetRef="amountGateway"/>
<sequenceFlow id="toFinance"
sourceRef="amountGateway"
targetRef="financeReview">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[
${approvalLevel == 'FINANCE'}
]]></conditionExpression>
</sequenceFlow>
<sequenceFlow id="toDirector"
sourceRef="amountGateway"
targetRef="directorReview">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[
${approvalLevel == 'DIRECTOR'}
]]></conditionExpression>
</sequenceFlow>
<sequenceFlow id="toManualReview"
sourceRef="amountGateway"
targetRef="manualReview"/>
<sequenceFlow id="f3" sourceRef="financeReview" targetRef="end"/>
<sequenceFlow id="f4" sourceRef="directorReview" targetRef="end"/>
<sequenceFlow id="f5" sourceRef="manualReview" targetRef="end"/>
</process>
</definitions>默认线进入人工复核,而不是默认通过。这样即使代码错误生成未知 approvalLevel,也不会绕过审批。
十一、Java Demo:在业务层计算网关事实
public enum ApprovalLevel {
FINANCE,
DIRECTOR
}@Service
public class ExpenseApprovalService {
private static final BigDecimal DIRECTOR_THRESHOLD =
new BigDecimal("10000.00");
private final TaskService taskService;
private final ExpenseRepository expenseRepository;
private final ApprovalRecordRepository approvalRecordRepository;
public ExpenseApprovalService(
TaskService taskService,
ExpenseRepository expenseRepository,
ApprovalRecordRepository approvalRecordRepository) {
this.taskService = taskService;
this.expenseRepository = expenseRepository;
this.approvalRecordRepository = approvalRecordRepository;
}
@Transactional
public void approveManagerTask(
String taskId,
Long expenseId,
String operatorId,
String requestId) {
Task task = taskService.createTaskQuery()
.taskId(taskId)
.singleResult();
if (task == null) {
// 重复请求可能已经完成,业务上应结合requestId返回幂等结果
throw new IllegalStateException("任务不存在或已经完成");
}
if (!"managerReview".equals(task.getTaskDefinitionKey())) {
throw new IllegalStateException("当前任务不是直属主管审批节点");
}
assertOperatorCanComplete(task, operatorId);
Expense expense = expenseRepository.findByIdForUpdate(expenseId)
.orElseThrow(() -> new IllegalArgumentException("报销单不存在"));
if (approvalRecordRepository.existsByRequestId(requestId)) {
return;
}
ApprovalLevel level = expense.getAmount()
.compareTo(DIRECTOR_THRESHOLD) >= 0
? ApprovalLevel.DIRECTOR
: ApprovalLevel.FINANCE;
Map<String, Object> variables = new HashMap<>();
variables.put("approvalLevel", level.name());
variables.put("managerApproved", Boolean.TRUE);
taskService.complete(taskId, variables);
approvalRecordRepository.save(ApprovalRecord.approved(
requestId,
expenseId,
taskId,
task.getProcessInstanceId(),
operatorId));
}
private void assertOperatorCanComplete(Task task, String operatorId) {
// 示例省略组织权限查询;生产必须在服务端校验assignee/candidate和数据权限
}
}为什么不直接把金额比较写成 ${amount >= 10000}:
- 金额规则可能来自配置、客户等级、费用类型和组织政策。
- 表达式过长难以单元测试和版本兼容。
- Java Service 可以统一处理 BigDecimal、空值和异常。
- BPMN 保留“审批级别决定去向”的业务语义即可。
十二、并行审核Demo
<parallelGateway id="parallelSplit" name="并行发起审核"/>
<parallelGateway id="parallelJoin" name="等待全部审核"/>
<sequenceFlow id="toSplit" sourceRef="submitTask" targetRef="parallelSplit"/>
<sequenceFlow id="toSecurity" sourceRef="parallelSplit" targetRef="securityReview"/>
<sequenceFlow id="toCompliance" sourceRef="parallelSplit" targetRef="complianceReview"/>
<sequenceFlow id="securityToJoin" sourceRef="securityReview" targetRef="parallelJoin"/>
<sequenceFlow id="complianceToJoin" sourceRef="complianceReview" targetRef="parallelJoin"/>
<sequenceFlow id="afterJoin" sourceRef="parallelJoin" targetRef="ownerReview"/>建议拆分和汇聚使用两个不同 ID,即使 BPMN 允许一个网关同时有多入多出。明确的 Split/Join 更容易阅读、测试和排查。
十三、包容审核Demo
<inclusiveGateway id="optionalReviewSplit"
name="按风险选择审核部门"
default="toOwnerDirectly"/>
<sequenceFlow id="toSecurity"
sourceRef="optionalReviewSplit"
targetRef="securityReview">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[
${sensitive == true}
]]></conditionExpression>
</sequenceFlow>
<sequenceFlow id="toCrossBorder"
sourceRef="optionalReviewSplit"
targetRef="crossBorderReview">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[
${crossBorder == true}
]]></conditionExpression>
</sequenceFlow>
<sequenceFlow id="toQuality"
sourceRef="optionalReviewSplit"
targetRef="qualityReview">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[
${qualityRisk == 'HIGH'}
]]></conditionExpression>
</sequenceFlow>
<sequenceFlow id="toOwnerDirectly"
sourceRef="optionalReviewSplit"
targetRef="ownerReview"/>如果有包容 Join,默认直达路径必须与整体结构一起设计,避免某些组合绕过 Join 又与已激活分支在下游重复汇合。
十四、审批拒绝时并行分支怎么办
假设法务拒绝,财务任务仍在等待。业务必须明确策略:
策略A:所有人仍完成,最后统一判断
优点是轨迹完整;缺点是已经确定拒绝后其他人仍要处理。
策略B:任一拒绝终止整个流程
需要使用明确的终止结束事件、边界事件或父作用域取消策略,并验证其他 Task、Execution、Job 是否被清理。不能只让拒绝分支绕开 Join,否则另一个分支可能永远等待或产生孤立任务。
策略C:发出取消信号让其他分支收敛
适合复杂子流程,但模型和并发处理更难。取消与另一审批几乎同时发生时,服务端仍要幂等和乐观锁处理。
这不是“哪种网关更高级”的问题,而是业务是否允许提前失败、是否要求每个部门留痕、是否存在外部副作用。
十五、多人审批不要把并行网关和会签混淆
并行网关适合固定的不同职责分支,例如法务和财务各一个节点。多人会签通常使用 Multi-instance User Task:同一个任务定义根据人员集合生成多个实例,并按完成条件判断全票、比例或任一通过。
| 需求 | 更合适的模型 |
|---|---|
| 法务和财务分别审核 | 并行网关+两个User Task |
| 动态专家列表每人审核 | Multi-instance User Task |
| 满足风险条件的部门参与 | 包容网关 |
| 金额只进入一个审批级别 | 排他网关 |
把 30 个动态审批人画成 30 条并行线,会让模型无法维护;把职责不同的法务/财务硬塞进一个会签节点,又会丢失节点语义和不同表单要求。
十六、表达式安全边界
如果允许普通用户上传任意 BPMN,并允许表达式访问 Spring Bean,表达式可能调用不应暴露的方法。流程模型本质上是可执行配置,发布权限接近代码发布权限。
安全措施:
- BPMN 发布必须认证、授权、审批和审计。
- 限制可引用 Bean/Delegate 白名单。
- 不把用户输入直接拼进表达式文本。
- 对脚本任务、监听器类名和表达式做静态检查。
- 发布服务与普通业务接口隔离。
- 禁止流程日志打印完整敏感变量。
- 以当前 Activiti 版本安全公告和表达式实现为准做升级。
条件应该引用变量:
${riskLevel == 'HIGH'}不要根据前端传入字符串动态生成:
// 错误示意:把用户输入拼入可执行表达式
String expression = "${" + request.getCondition() + "}";十七、变量变更的版本兼容
V1 使用:
approved: BooleanV2 改成:
reviewResult: String,值为APPROVED或REJECTED旧实例仍执行 V1 表达式,因此应用代码至少在旧实例结束前要继续写 approved,或者在迁移时补齐变量。
兼容发布可以暂时双写:
Map<String, Object> variables = new HashMap<>();
variables.put("approved", approved); // V1兼容
variables.put("reviewResult", approved ? "APPROVED" : "REJECTED"); // V2
taskService.complete(taskId, variables);双写不是永久方案。应监控 V1 运行实例归零后再删除旧变量契约,并记录移除版本。
十八、网关测试不能只测一条成功路径
排他网关真值表
| approvalLevel | 预期路径 |
|---|---|
FINANCE | 财务复核 |
DIRECTOR | 负责人审批 |
| null | 默认人工复核 |
| 未知值 | 默认人工复核 |
包容网关组合测试
三个 Boolean 条件有八种组合,至少要验证:
sensitive crossBorder qualityHigh
false false false
true false false
false true false
false false true
true true false
true false true
false true true
true true true每种组合都要断言:
- 生成了哪些 Task。
- 未生成哪些 Task。
- 完成部分 Task 时汇聚仍等待。
- 完成全部已激活 Task 后只生成一次下游 Task。
- 流程结束后没有遗留 Execution 和 Task。
十九、自动化测试Demo
下面是思路性测试,具体断言 API 根据项目 Activiti 版本调整:
@SpringBootTest
@Transactional
class ExpenseGatewayProcessTest {
@Autowired
private RuntimeService runtimeService;
@Autowired
private TaskService taskService;
@Test
void shouldRouteToDirectorForLargeExpense() {
Map<String, Object> variables = new HashMap<>();
variables.put("approvalLevel", "DIRECTOR");
ProcessInstance instance = runtimeService.startProcessInstanceByKey(
"expense_approval",
"expense-10001",
variables);
Task managerTask = taskService.createTaskQuery()
.processInstanceId(instance.getId())
.taskDefinitionKey("managerReview")
.singleResult();
assertNotNull(managerTask);
taskService.complete(managerTask.getId(), variables);
Task directorTask = taskService.createTaskQuery()
.processInstanceId(instance.getId())
.taskDefinitionKey("directorReview")
.singleResult();
assertNotNull(directorTask);
long financeTasks = taskService.createTaskQuery()
.processInstanceId(instance.getId())
.taskDefinitionKey("financeReview")
.count();
assertEquals(0L, financeTasks);
}
}测试中应使用独立数据库或明确事务清理,不能依赖测试执行顺序和共享流程实例。
二十、商业场景一:支付审批分级
规则:
- 小于 1 万:主管审批。
- 1 万到 10 万:主管后财务审批。
- 大于等于 10 万:主管、财务后再由总监审批。
这里通常是排他路由到不同审批级别链,而不是并行网关。金额属于业务事实,应从数据库读取并在服务端用 BigDecimal 计算,不能信任前端提交的 approvalLevel。
如果规则经常变化,可把规则计算放在受控规则服务,输出稳定枚举给 BPMN。不要把几十行金额表达式塞进 Sequence Flow。
二十一、商业场景二:数据资产可选会审
根据敏感字段、跨境属性、质量风险激活安全、合规、质量审核。适合包容网关。
生产还要考虑:
- 条件计算使用哪个时刻的数据快照。
- 申请过程中资产属性变化是否重新计算。
- 各审核结论如何汇总。
- 任一拒绝是否取消其他待办。
- 发布后审计要能说明本次激活了哪些分支及原因。
建议业务审批记录保存网关决策快照:
{
"sensitive": true,
"crossBorder": false,
"qualityRisk": "HIGH",
"activatedReviews": ["SECURITY", "QUALITY"],
"ruleVersion": "2026-07-01"
}敏感字段内容本身不要放进流程变量,只保存分类结果和不可敏感的决策依据。
二十二、商业场景三:订单支付与超时
支付消息与超时定时器竞争适合事件型网关,但最终业务状态要使用条件更新:
UPDATE orders
SET status = 'PAID', paid_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'WAITING_PAYMENT';只有更新行数为 1 才获得状态转换权。如果超时取消先更新成功,迟到支付回调要进入退款/人工处理,不能只依赖流程 Token 判断资金事实。
工作流负责协调步骤,数据库状态和支付平台回执才是资金事实来源。
二十三、排查总流程
flowchart TD
A["确认业务单、processInstanceId和definitionId"] --> B["查询当前运行Task"]
B --> C["查询Execution树和当前Activity"]
C --> D["读取网关前变量名、类型、值和作用域"]
D --> E["下载实例对应版本BPMN"]
E --> F["对照入线、出线、条件和Default Flow"]
F --> G["检查并行/包容分支哪些已激活和到达"]
G --> H["检查Job、异常日志和事务回滚"]
H --> I["核对业务状态、审批记录和历史轨迹"]不要只打开“最新版本流程图”。实例绑定的可能是旧 definitionId,使用新图分析旧实例会得出错误结论。
二十四、故障一:完成任务后任务还在
可能原因:
- 网关表达式变量缺失或类型错误,
complete事务回滚。 - 下一节点 Listener/Delegate 抛异常,整个命令回滚。
- 当前请求因乐观锁冲突失败。
- 接口吞掉异常并返回了错误的“成功”。
- 前端查到了缓存中的旧待办。
证据:
- 保存完整服务端异常和最内层 Cause。
- 查原 Task 是否仍在
ACT_RU_TASK。 - 查历史任务是否生成结束时间。
- 查网关变量实际 Java 类型,而不只看字符串展示。
- 查业务审批记录事务是否回滚。
- 查同一 taskId 是否有并发提交。
不要重复点按钮直到“碰巧成功”,否则外部通知可能重复。接口应使用 requestId 和任务状态实现幂等。
二十五、故障二:排他网关走错分支
依次检查:
- 实例实际 definitionId 对应哪版 BPMN。
- 出线条件是否重叠。
- 变量是在启动时还是完成任务时被覆盖。
- 变量类型是否 Boolean/BigDecimal/String 与表达式一致。
- 局部变量是否遮蔽或未传播为全局变量。
- Default Flow 是否把异常输入送到了看似正常的分支。
- 业务规则计算使用的数据是否被并发修改。
诊断输出应脱敏:
Map<String, VariableInstance> variables = runtimeService
.getVariableInstances(processInstanceId);
variables.forEach((name, variable) ->
log.info("processInstanceId={}, variableName={}, variableType={}",
processInstanceId,
name,
variable.getTypeName()));具体 VariableInstance API 依 Activiti 版本调整。生产日志通常只记录变量名、类型和必要枚举值,不输出完整敏感数据。
二十六、故障三:并行网关一直不继续
先回答:汇聚前理论上有几条被激活路径,实际到达几条?
检查:
- 当前还有哪些 User Task 未完成。
- 是否有异步 Job 失败或退避。
- 某分支是否经条件或错误边界绕过 Join。
- 某分支是否停在 Receive Task、消息事件或子流程。
- 是否误把两个不同并行结构交叉汇聚。
- 是否有人直接改表删除了 Task,却没有推进 Execution。
只删除“看起来多余”的 ACT_RU_EXECUTION 会进一步破坏执行树。应先在测试库复现模型,并通过引擎支持的 API 或补偿流程修复。
二十七、故障四:包容网关多生成或少生成任务
检查每个条件在分支时刻的变量快照。不要用当前业务表值反推过去,因为业务数据可能已经变化。
建议记录:
- 网关 Activity ID。
- 流程定义 ID 和版本。
- 条件变量名称、类型和脱敏值。
- 实际激活 Sequence Flow ID 集合。
- 规则版本和业务数据版本。
如果同一个下游节点可从多条路径进入,还要确认是否允许生成多个 Task。BPMN 图上“看起来汇合”不等于具有同步合并语义。
二十八、故障五:扩容消费者或应用实例后仍卡住
网关等待通常是流程状态问题,不是计算容量问题。扩容应用节点只增加处理 API/Job 的能力,不能凭空补齐未到达的 Execution,也不能修复缺失变量和错误 BPMN。
短暂恢复可能是:
- 新节点让积压 Job 暂时被处理,但上游失败率仍高。
- 缓存刷新后暂时使用正确定义,发布配置随后又被旧实例覆盖。
- 数据库锁竞争短暂下降,但慢 SQL/热点实例根因仍在。
必须查看 Execution/Job Lag 分布和失败原因,不要把“吞吐短暂提高”当成网关模型已修复。
二十九、可观测性和审计
建议记录/监控:
| 数据 | 用途 |
|---|---|
| processDefinitionId、processInstanceId、businessKey | 锁定实例、版本和业务单 |
| 当前Activity ID与Task Definition Key | 定位执行节点 |
| 网关进入次数和各Sequence Flow命中次数 | 发现分支比例异常 |
| 网关表达式异常数 | 发现变量契约破坏 |
| 并行/包容汇聚等待时长 | 发现长期未到达分支 |
| Job失败/重试/Dead Letter数量 | 发现异步路径卡住 |
| 活跃Execution数量分布 | 发现并发路径泄漏或模型异常 |
| 业务状态与流程状态对账差异 | 发现事务一致性问题 |
指标 Label 不要直接使用 processInstanceId/businessKey,否则高基数会压垮 Prometheus。高基数身份放日志和 Trace,指标按 processKey、definitionVersion、activityId、result 聚合。
三十、常见误区
| 误区 | 准确结论 | 后果 |
|---|---|---|
| 网关就是if/else图标 | 网关会改变Execution选择、分裂和汇聚语义 | 只会画图,不会分析运行状态 |
| 排他网关会等所有入线 | 排他合并通常不做同步等待 | 并行路径穿过后下游重复执行 |
| 并行网关会创建Java线程 | 它创建流程并发路径,不保证物理并行 | 错误估算吞吐和事务 |
| 并行出线可配置条件 | 并行拆分通常激活所有出线并忽略条件 | 误以为某分支不会执行 |
| 包容汇聚等待所有图上入线 | 它应等待本次实际激活且仍可能到达的路径 | 无法解释条件组合 |
| 没有Default也没关系 | 全部条件false时可能抛异常并回滚 | 用户任务看似完成失败 |
| 前端传approved即可 | 必须服务端校验权限和业务事实 | 越权绕过审批 |
| 流程变量都用String最方便 | 类型转换让表达式结果不稳定 | 分支走错或运行异常 |
| 并行拒绝直接绕过Join | 其他路径可能永远等待或重复执行 | 僵尸任务和流程卡住 |
| 卡网关就直接改ACT表 | Task、Execution、History、Job和缓存有关联 | 执行树进一步损坏 |
三十一、面试标准回答
排他、并行和包容网关有什么区别
排他网关对出线条件求值并只选择一条,没有命中时走默认线,否则通常报错;它用作汇聚时不等待其他路径。并行网关拆分时激活所有出线,汇聚时等待并行路径到达,不依赖条件。包容网关激活所有条件为真的出线,汇聚时只等待本次已激活且仍可能到达的路径,因此执行和排查比并行更复杂。
并行网关是不是多线程
不是。并行网关表示一个流程实例产生多条并发Execution路径,可以独立停在不同任务;同步节点仍可能在同一个命令线程中依次执行。配置异步继续和Job Executor后才可能在不同线程或节点处理。流程并发语义与操作系统线程并发必须分开理解。
为什么流程会卡在并行网关
Join需要等待对应并行路径到达。先锁定实例绑定的definitionId,再查当前Task、Execution和Job,确认理论激活了几条分支、实际到达几条。常见根因是任务未完成、异步Job失败、某分支绕过Join、等待消息/子流程,或者有人直接删Task破坏Execution。扩容应用不能补齐缺失路径。
网关变量怎样设计
复杂业务规则在Service中根据可信数据库事实计算,流程变量只保存稳定的小字段和枚举;金额用BigDecimal,Boolean不传字符串,明确全局/局部作用域。条件应互斥并配置安全默认线。变量契约变化时要兼容旧流程定义和运行实例,不能只修改最新BPMN。
taskService.complete时网关报错会怎样
完成任务、推进Execution、计算网关、创建下一任务和写历史通常位于同一个引擎命令和数据库事务。表达式缺失、类型错误或下游监听器异常会让事务回滚,因此原Task可能仍存在。业务表更新如果处于同一事务也应回滚;如果跨库或异步,则要另外设计Outbox、补偿和对账。
三十二、学习实验与验收
建议完成以下实验:
- 排他网关分别传
FINANCE、DIRECTOR、null 和未知值,验证唯一分支与默认线。 - 故意让两条排他条件同时为 true,观察结果并改成互斥条件。
- 删除 Default Flow,让所有条件 false,保存异常、Task和事务结果。
- 建立两分支并行审核,只完成一个任务,证明 Join 继续等待。
- 完成第二个任务,证明下游任务只生成一次。
- 让一个并行分支绕开 Join,复现并分析不对称模型。
- 为三个包容条件执行八种真值组合并断言 Task 集合。
- 把 Boolean 改成 String,观察表达式结果或异常并说明类型契约。
- 把所需变量设为 Local,验证后续网关的可见性。
- 部署 V2 改变量名,证明 V1 运行实例仍需要旧变量。
验收时必须能回答:
- Execution 和 Task 为什么不是同一个对象?
- 并行为什么不是 Java 多线程?
- 排他汇聚为什么不等待,并行汇聚为什么等待?
- 包容汇聚怎样判断还应该等谁?
- 没有条件命中且没有 Default Flow 时事务如何变化?
- 为什么复杂金额规则应在业务 Service 中计算?
- 为什么完成任务后任务仍在可能是正确的事务回滚结果?
- 卡网关时从哪一版 BPMN 和哪些运行时证据开始?
