Skip to content

Activiti网关、Execution与分支汇聚原理

网关不是流程图上的装饰菱形,而是控制 Execution 如何分裂、选择和汇聚的执行节点。排他网关解决“多选一”,并行网关解决“全部同时走”,包容网关解决“满足条件的多条都走”。三者看起来都能画分支,但运行语义完全不同。

如果只会写 ${approved == true},却不知道网关进入同一个数据库事务、并行分支为什么产生多个 Execution、汇聚到底等待谁,就无法解释“任务完成接口报错但任务仍在”“明明完成了两个审批却卡在汇聚”“扩容应用后流程仍不继续”等生产问题。

学习目标

完成本页后,你应该能够:

  1. 区分 Sequence Flow、Activity、Execution、Task 和流程实例。
  2. 解释排他、并行、包容和事件型网关的准确语义。
  3. 解释分支时 Execution 怎样分裂,汇聚时怎样合并。
  4. 正确设计条件表达式、默认流和变量类型。
  5. 说明“排他网关汇聚不等待、并行网关汇聚要等待”的原因。
  6. 解释包容网关为什么只等待真正被激活且可到达的分支。
  7. 写出可执行 BPMN 和安全的 Java 任务完成 Demo。
  8. 处理多人审批、可选会审、超时和异常分支等商业场景。
  9. 根据 Task、Execution、Variable、Job、History 和日志排查卡网关。
  10. 回答网关常见面试题并指出错误建模后果。

一、先理解流程为什么需要网关

如果没有网关,业务代码容易出现:

java
if (amount.compareTo(new BigDecimal("10000")) > 0) {
    createFinanceTask();
    if (sensitive) {
        createSecurityTask();
    }
} else {
    finish();
}

随着角色、分支、会签、超时和驳回增加,条件散落在 Controller、Service、定时任务和消息消费者里,流程图与代码无法对应,历史也无法回答“当时为什么走这条路”。

工作流网关把路由结构放进 BPMN,可执行条件使用流程变量:

mermaid
flowchart TD
    A["提交数据资产发布申请"] --> B{"是否包含敏感字段"}
    B -->|"是"| C["安全审核"]
    B -->|"否"| D["数据负责人审核"]
    C --> D
    D --> E["发布完成"]

但网关不应承担复杂业务计算。推荐先由业务 Service 计算稳定事实,例如 sensitive=trueriskLevel=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,共享或继承流程变量语义,但局部变量可能只在特定执行作用域可见。

mermaid
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。它用于互斥分支:一次到达只选择一个出口。

mermaid
flowchart TD
    A["提交报销单"] --> B{"金额范围"}
    B -->|"amount小于等于5000"| C["直属主管审批"]
    B -->|"amount大于5000"| D["部门负责人审批"]
    B -->|"没有条件命中"| E["默认异常处理"]

4.1 排他分支执行过程

mermaid
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。

错误条件:

text
flow A: amount >= 5000
flow B: amount >= 10000

当 amount=15000 时两条都为 true,最终选择可能依赖顺序,业务含义不清。

正确做法:

text
flow A: amount >= 5000 && amount < 10000
flow B: amount >= 10000

或者在业务层先计算唯一 approvalLevel,网关只比较枚举值。

4.2 默认线为什么重要

默认线是所有条件都不满足时的兜底,不等于“正常通过”。它应该进入明确异常处理、人工复核或拒绝结束,避免数据静默走错分支。

默认 Sequence Flow 上即使配置条件,也通常不会按普通条件线使用;模型应把它当无条件兜底,具体校验以引擎版本为准。

4.3 排他汇聚为什么不等待

排他网关用作合并时,不会等待所有入线,因为互斥分支理论上只有一条被激活。任意一条 Execution 到达后即可继续。

mermaid
flowchart TD
    A{"申请结果"} -->|"通过"| B["生成发布记录"]
    A -->|"拒绝"| C["记录拒绝原因"]
    B --> D{"排他合并"}
    C --> D
    D --> E["归档流程"]

如果上游实际存在并行分支,却错误使用排他网关汇聚,两条路径可能各自穿过汇聚并让下游执行两次。需要同步并行路径时必须使用有同步语义的网关。

五、并行网关:所有分支都走

并行网关也叫 AND Gateway。分支时忽略条件,激活所有出线;汇聚时等待需要同步的入线。

mermaid
flowchart TD
    A["合同提交"] --> B{"并行拆分"}
    B --> C["法务审核"]
    B --> D["财务审核"]
    C --> E{"并行汇聚"}
    D --> E
    E --> F["合同盖章"]

5.1 并行拆分怎样产生Execution

mermaid
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 在不同线程/节点上处理。

因此:

text
BPMN并行 = 多条执行路径可以独立推进
不等于 = Java代码必然同时运行

5.3 并行汇聚为什么会等待

只有一条分支到达时,汇聚不能继续,否则下游会在其他审批未完成时提前执行。引擎会保留到达状态,直到同一流程实例中需要同步的并行路径都到达。

mermaid
flowchart TD
    A["法务分支到达汇聚"] --> B["记录一个Execution已到达"]
    B --> C{"财务分支是否也到达"}
    C -->|"否"| D["保留等待状态,下游不执行"]
    E["财务分支稍后到达"] --> F["检查同一并行结构的到达情况"]
    F --> G["条件满足,合并Execution"]
    G --> H["创建盖章任务"]

5.4 为什么会永远卡在并行汇聚

典型错误模型:

mermaid
flowchart TD
    A{"并行拆分"} --> B["分支A"]
    A --> C["分支B"]
    B --> D{"条件判断"}
    D -->|"通过"| E{"并行汇聚"}
    D -->|"拒绝"| F["直接结束"]
    C --> E

当分支 A 走“直接结束”,汇聚可能永远等不到对应路径。是否卡死与模型结构、结束事件类型和引擎执行语义有关,但这种不对称设计本身就难以证明正确。

解决思路:

  • 让所有被并行拆出的路径在正常路径上成对汇聚。
  • 如果任一拒绝要终止整个流程,使用明确的终止语义并测试其他分支如何取消。
  • 将条件放在并行结构外,避免一部分路径绕过 Join。
  • 对所有真值组合做模型测试。

六、包容网关:满足条件的多条都走

包容网关也叫 OR Gateway。它介于排他和并行之间:

  • 排他:只选一条。
  • 并行:所有出线都走。
  • 包容:所有条件为 true 的出线都走。

商业场景:数据资产发布时,根据数据特征选择审核部门:

mermaid
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 包容拆分过程

mermaid
flowchart TD
    A["Execution进入包容网关"] --> B["计算每条条件出线"]
    B --> C["收集所有结果为true的出线"]
    C --> D{"命中数量是否大于0"}
    D -->|"是"| E["为每条命中出线激活执行路径"]
    D -->|"否且有默认线"| F["激活默认路径"]
    D -->|"否且无默认线"| G["抛异常并回滚"]

6.2 包容汇聚为什么比并行复杂

并行网关可以按结构等待并行入线;包容网关必须判断“还有没有已激活、尚未到达但未来可能到达这里的 Execution”。未被拆分激活的路径不能成为等待条件。

mermaid
flowchart TD
    A["某个Execution到达包容汇聚"] --> B["检查同流程实例其他Execution"]
    B --> C{"是否有已激活路径仍可能到达本汇聚"}
    C -->|"有"| D["继续等待"]
    C -->|"没有"| E["合并当前已到达路径"]
    E --> F["沿汇聚后的Sequence Flow继续"]

复杂嵌套、循环和交叉连线会提高“可到达性”判断和人工排查成本。包容网关不是不能用,而是要保持拆分/汇聚边界清楚,并测试所有条件组合。

七、事件型网关:谁先发生就走谁

事件型网关不读取 approvedamount 等普通条件来选路,而是等待事件。例如订单提交后:等待用户支付消息,或者等待 30 分钟超时,谁先发生就走谁。

mermaid
flowchart TD
    A["创建待支付订单"] --> B{"事件型网关"}
    B --> C["等待支付消息"]
    B --> D["等待30分钟定时器"]
    C --> E["订单已支付"]
    D --> F["订单超时取消"]

关键点:

  • 它表达的是事件竞争,不是变量条件判断。
  • 当一个事件获胜后,其他等待通常应被取消。
  • 支付回调和超时任务可能接近同时发生,业务层仍必须使用订单状态条件更新和幂等键防止双重副作用。
  • 支持的 Catch Event 类型和组合取决于 Activiti 版本。

工作流引擎的事件竞争不能替代数据库并发控制。

八、条件表达式从哪里取变量

常见表达式:

xml
<conditionExpression xsi:type="tFormalExpression"><![CDATA[
    ${approved == true}
]]></conditionExpression>

表达式求值时会从变量作用域、引擎 Bean/表达式管理器等上下文解析名称,具体顺序随版本和配置不同。

流程变量可以来自:

  • 启动实例时传入的变量。
  • 完成 User Task 时传入的变量。
  • RuntimeService/TaskService 设置的变量。
  • Delegate、Listener 或脚本节点产生的变量。
  • 局部变量作用域。

8.1 全局变量和局部变量

全局变量通常对流程实例中的后续 Execution 可见;局部变量只属于某个 Execution 或 Task 作用域。把网关所需变量误设为 Task Local,后续网关可能解析不到。

API 名称随版本略有差异,常见思路:

java
taskService.setVariable(taskId, "approved", Boolean.TRUE);
taskService.setVariableLocal(taskId, "temporaryComment", "仅当前任务使用");

网关变量应有明确作用域契约,不要依赖“刚好能从父作用域找到”。

8.2 类型为什么比字符串值重要

下面三者不是同一个 Java 类型:

java
variables.put("approved", Boolean.TRUE);
variables.put("approved", "true");
variables.put("approved", 1);

表达式 ${approved == true} 期望 Boolean。依赖表达式引擎隐式转换会让不同版本、不同输入产生难以预测的结果。

金额使用 BigDecimal

java
variables.put("amount", new BigDecimal("10000.00"));

不要使用 double 表示商业金额。更推荐业务层计算枚举:

java
variables.put("approvalLevel", ApprovalLevel.DIRECTOR.name());

网关表达式变成:

xml
${approvalLevel == 'DIRECTOR'}

8.3 缺失变量会怎样

变量不存在时,表达式可能报“unknown property/cannot resolve identifier”,或者在某些表达式中按 null 参与比较。若异常发生在 taskService.complete 同一事务,任务完成和流程推进通常一起回滚,用户刷新后仍会看到原任务。

这不是 Activiti “重复生成任务”,而是事务没有提交。

九、任务完成到网关选择的事务过程

mermaid
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
<?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:在业务层计算网关事实

java
public enum ApprovalLevel {
    FINANCE,
    DIRECTOR
}
java
@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

xml
<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

xml
<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 版本安全公告和表达式实现为准做升级。

条件应该引用变量:

xml
${riskLevel == 'HIGH'}

不要根据前端传入字符串动态生成:

java
// 错误示意:把用户输入拼入可执行表达式
String expression = "${" + request.getCondition() + "}";

十七、变量变更的版本兼容

V1 使用:

text
approved: Boolean

V2 改成:

text
reviewResult: String,值为APPROVED或REJECTED

旧实例仍执行 V1 表达式,因此应用代码至少在旧实例结束前要继续写 approved,或者在迁移时补齐变量。

兼容发布可以暂时双写:

java
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 条件有八种组合,至少要验证:

text
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 版本调整:

java
@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。

二十一、商业场景二:数据资产可选会审

根据敏感字段、跨境属性、质量风险激活安全、合规、质量审核。适合包容网关。

生产还要考虑:

  • 条件计算使用哪个时刻的数据快照。
  • 申请过程中资产属性变化是否重新计算。
  • 各审核结论如何汇总。
  • 任一拒绝是否取消其他待办。
  • 发布后审计要能说明本次激活了哪些分支及原因。

建议业务审批记录保存网关决策快照:

json
{
  "sensitive": true,
  "crossBorder": false,
  "qualityRisk": "HIGH",
  "activatedReviews": ["SECURITY", "QUALITY"],
  "ruleVersion": "2026-07-01"
}

敏感字段内容本身不要放进流程变量,只保存分类结果和不可敏感的决策依据。

二十二、商业场景三:订单支付与超时

支付消息与超时定时器竞争适合事件型网关,但最终业务状态要使用条件更新:

sql
UPDATE orders
SET status = 'PAID', paid_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'WAITING_PAYMENT';

只有更新行数为 1 才获得状态转换权。如果超时取消先更新成功,迟到支付回调要进入退款/人工处理,不能只依赖流程 Token 判断资金事实。

工作流负责协调步骤,数据库状态和支付平台回执才是资金事实来源。

二十三、排查总流程

mermaid
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 抛异常,整个命令回滚。
  • 当前请求因乐观锁冲突失败。
  • 接口吞掉异常并返回了错误的“成功”。
  • 前端查到了缓存中的旧待办。

证据:

  1. 保存完整服务端异常和最内层 Cause。
  2. 查原 Task 是否仍在 ACT_RU_TASK
  3. 查历史任务是否生成结束时间。
  4. 查网关变量实际 Java 类型,而不只看字符串展示。
  5. 查业务审批记录事务是否回滚。
  6. 查同一 taskId 是否有并发提交。

不要重复点按钮直到“碰巧成功”,否则外部通知可能重复。接口应使用 requestId 和任务状态实现幂等。

二十五、故障二:排他网关走错分支

依次检查:

  • 实例实际 definitionId 对应哪版 BPMN。
  • 出线条件是否重叠。
  • 变量是在启动时还是完成任务时被覆盖。
  • 变量类型是否 Boolean/BigDecimal/String 与表达式一致。
  • 局部变量是否遮蔽或未传播为全局变量。
  • Default Flow 是否把异常输入送到了看似正常的分支。
  • 业务规则计算使用的数据是否被并发修改。

诊断输出应脱敏:

java
Map<String, VariableInstance> variables = runtimeService
        .getVariableInstances(processInstanceId);

variables.forEach((name, variable) ->
        log.info("processInstanceId={}, variableName={}, variableType={}",
                processInstanceId,
                name,
                variable.getTypeName()));

具体 VariableInstance API 依 Activiti 版本调整。生产日志通常只记录变量名、类型和必要枚举值,不输出完整敏感数据。

二十六、故障三:并行网关一直不继续

先回答:汇聚前理论上有几条被激活路径,实际到达几条?

检查:

  1. 当前还有哪些 User Task 未完成。
  2. 是否有异步 Job 失败或退避。
  3. 某分支是否经条件或错误边界绕过 Join。
  4. 某分支是否停在 Receive Task、消息事件或子流程。
  5. 是否误把两个不同并行结构交叉汇聚。
  6. 是否有人直接改表删除了 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、补偿和对账。

三十二、学习实验与验收

建议完成以下实验:

  1. 排他网关分别传 FINANCEDIRECTOR、null 和未知值,验证唯一分支与默认线。
  2. 故意让两条排他条件同时为 true,观察结果并改成互斥条件。
  3. 删除 Default Flow,让所有条件 false,保存异常、Task和事务结果。
  4. 建立两分支并行审核,只完成一个任务,证明 Join 继续等待。
  5. 完成第二个任务,证明下游任务只生成一次。
  6. 让一个并行分支绕开 Join,复现并分析不对称模型。
  7. 为三个包容条件执行八种真值组合并断言 Task 集合。
  8. 把 Boolean 改成 String,观察表达式结果或异常并说明类型契约。
  9. 把所需变量设为 Local,验证后续网关的可见性。
  10. 部署 V2 改变量名,证明 V1 运行实例仍需要旧变量。

验收时必须能回答:

  • Execution 和 Task 为什么不是同一个对象?
  • 并行为什么不是 Java 多线程?
  • 排他汇聚为什么不等待,并行汇聚为什么等待?
  • 包容汇聚怎样判断还应该等谁?
  • 没有条件命中且没有 Default Flow 时事务如何变化?
  • 为什么复杂金额规则应在业务 Service 中计算?
  • 为什么完成任务后任务仍在可能是正确的事务回滚结果?
  • 卡网关时从哪一版 BPMN 和哪些运行时证据开始?

关联知识点