BPMN 2.0 与 Camunda 实战

本文系统讲解 BPMN 2.0 的核心元素子集与 Camunda 的工程落地方式,回答该用哪些元素、如何避免图形失控、Camunda 7 与 8 如何取舍。覆盖事件、任务、网关、边界事件、多实例、补偿的语义,给出可运行的 BPMN XML、Java Delegate、外部任务模式、REST API 调用与历史表查询,并总结建模规范与常见坑。

引言

BPMN 2.0 是工作流领域唯一被广泛接受的图形化标准。它的价值在于把「流程」这件事从代码里解放出来,让业务分析师、合规人员、实施顾问能在同一张图上对话。但 BPMN 的规范厚度也是它的风险:超过 40 种元素、十余种事件类型,如果团队没有建模规范,最终产出的图会比代码更难维护。

Camunda 是这个标准最成功的工程实现。它的定位很清晰:把引擎嵌入你的 Java 应用(7.x),或者把引擎做成独立的云原生服务(8.x,即 Zeebe),业务代码通过 Delegate、External Task 或 REST 与引擎交互。选 Camunda 的团队通常有两个特征:流程规则由业务方主导,以及流程里有大量人工审批节点。

工程上真正的难点不在「怎么画图」,而在三件事:元素子集的约束(用少了表达力不够,用多了没人看得懂)、运行中实例的版本兼容(流程定义改了,老实例怎么办)、以及人工任务与自动任务的边界(哪些逻辑该进引擎,哪些该留在服务里)。这三件事没有标准答案,但有一批被反复验证过的做法。

本文先从「该用哪些元素」这个最实际的问题切入,给出一个可落地的元素子集,再逐类讲解语义,然后用完整的 XML 与 Java 代码演示服务任务、边界事件、多实例审批、补偿的写法,接着讲流程变量、表达式、作业执行器这些运行机制,最后讲 Camunda 7 与 8 的取舍、数据库表结构与运维要点。想先看整体选型框架的读者,可以从 工作流引擎全景与选型 开始。

目录

  1. 该用哪些 BPMN 元素
  2. 流程定义的结构骨架
  3. 事件:开始、结束、中间与边界
  4. 任务:服务、用户、脚本与业务规则
  5. 网关:排他、并行、包容与事件
  6. 子流程与调用活动
  7. 多实例与会签
  8. 补偿事件与事务子流程
  9. 流程变量与作用域
  10. 表达式语言与 Bean 绑定
  11. Camunda 7 与 Camunda 8 的取舍
  12. 服务任务与 Java Delegate
  13. 外部任务模式
  14. 异步延续与作业执行器
  15. REST API 与流程启动
  16. 数据库表结构与历史数据
  17. 与 DMN 的协作
  18. 建模规范与版本管理
  19. 运行中实例的版本兼容
  20. 权衡取舍
  21. 常见坑清单
  22. 小结

1. 该用哪些 BPMN 元素

规范给了 40 多个元素,实战中 90% 的流程只需要 15 个左右。建议把下面的子集作为团队默认工具箱,超出子集必须评审:

类别推荐元素谨慎使用
事件StartEvent、EndEvent、TimerBoundaryEvent、MessageBoundaryEvent、ErrorBoundaryEventSignalEvent、EscalationEvent
任务ServiceTask、UserTask、CallActivityScriptTask、BusinessRuleTask
网关ExclusiveGateway、ParallelGatewayInclusiveGateway、EventBasedGateway
结构SubProcess、MultiInstanceTransactionSubProcess、AdHocSubProcess
其他SequenceFlow、DataObjectComplexGateway

约束的理由很实际:InclusiveGateway 的语义(等待所有满足条件的分支)在图上很难一眼看出,容易写出死锁;ScriptTask 里塞业务逻辑会让流程定义不可测试;EventBasedGateway 与边界事件的组合行为在跨引擎实现间有差异,迁移时会踩坑。

「谨慎使用」不等于禁用。BusinessRuleTask 在调用 DMN 决策表时是推荐做法,只是不要用它调用自定义 Java 代码——那应该用 ServiceTask。SignalEvent 在跨流程广播场景下不可替代,但它是全局的、无目标的,容易被滥用成隐式的耦合通道。

2. 流程定义的结构骨架

一个 BPMN 文件由 <definitions> 根元素包裹,里面是 <process>,<process> 里按顺序放流程节点与连线。下面的骨架包含一个服务任务和一个用户任务:

<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
                  xmlns:camunda="http://camunda.org/schema/1.0/bpmn"
                  targetNamespace="http://plumephp.com/workflow">
  <bpmn:process id="orderApproval" name="订单审批" isExecutable="true">
    <bpmn:startEvent id="start" name="提交订单">
      <bpmn:outgoing>flow1</bpmn:outgoing>
    </bpmn:startEvent>
    <bpmn:serviceTask id="checkCredit" name="信用校验"
                      camunda:class="com.plumephp.CreditCheckDelegate">
      <bpmn:incoming>flow1</bpmn:incoming>
      <bpmn:outgoing>flow2</bpmn:outgoing>
    </bpmn:serviceTask>
    <bpmn:sequenceFlow id="flow1" sourceRef="start" targetRef="checkCredit"/>
    <bpmn:sequenceFlow id="flow2" sourceRef="checkCredit" targetRef="approve"/>
    <bpmn:userTask id="approve" name="经理审批"
                   camunda:candidateGroups="managers">
      <bpmn:incoming>flow2</bpmn:incoming>
    </bpmn:userTask>
  </bpmn:process>
</bpmn:definitions>

id 是引擎内部的稳定标识,重命名时不要改 id,否则运行中的实例会失去关联。name 是给业务方看的显示名,可以随时改。isExecutable="true" 表示这个流程可以被启动,设计阶段可以先设为 false。

注意 incoming 与 outgoing 是可选的,引擎实际按 sequenceFlow 的 sourceRef 与 targetRef 建图。但 Camunda Modeler 在保存时会自动补全它们,手工编辑 XML 时删掉反而容易造成模型与引擎理解不一致,建议保留。

3. 事件:开始、结束、中间与边界

开始事件决定实例如何被创建:无类型开始事件表示只能通过 API 手动启动,定时开始事件表示按 cron 自动创建,消息开始事件表示收到特定消息时创建。结束事件决定实例如何终结:普通结束事件结束当前分支,终止结束事件会杀掉整个流程实例(包括并行分支)。

<bpmn:startEvent id="dailyStart" name="每日触发">
  <bpmn:timerEventDefinition>
    <bpmn:timeCycle xsi:type="bpmn:tFormalExpression">0 0 2 * * ?</bpmn:timeCycle>
  </bpmn:timerEventDefinition>
</bpmn:startEvent>

边界事件是 BPMN 最实用的设计之一,它挂在某个活动上,捕获该活动执行期间发生的事件。三类最常用:

  • 定时边界事件:给用户任务设置 24 小时超时,超时后走催办或自动通过分支。
  • 错误边界事件:捕获服务任务抛出的 BPMN Error,转入异常处理分支而不是让实例失败。
  • 消息边界事件:等待外部消息(比如支付回调),超时未到则走取消分支。
<bpmn:boundaryEvent id="approveTimeout" attachedToRef="approve" cancelActivity="true">
  <bpmn:timerEventDefinition>
    <bpmn:timeDuration xsi:type="bpmn:tFormalExpression">PT24H</bpmn:timeDuration>
  </bpmn:timerEventDefinition>
</bpmn:boundaryEvent>

cancelActivity="true" 表示超时后取消原任务,false 表示原任务继续存在(非中断型),后者常用来做「同时等待审批和超时提醒」。定时表达式支持 ISO 8601 时长(PT24H)、ISO 8601 循环(R3/PT1H 表示重复 3 次)和 cron 表达式三种形式,混用时容易写错,建议团队统一用 ISO 8601 时长。

4. 任务:服务、用户、脚本与业务规则

服务任务是自动执行的工作单元,Camunda 支持四种实现方式:camunda:class 指定 Java 类、camunda:delegateExpression 指定 Spring Bean、camunda:expression 写内联表达式、camunda:type="external" 交给外部任务模式。生产项目推荐 delegateExpression,因为可以注入依赖、方便单元测试。

用户任务是需要人参与的工作单元,核心属性是 camunda:assignee(指定处理人)、camunda:candidateUsers(候选处理人列表)、camunda:candidateGroups(候选组)、camunda:dueDate(截止时间)。这些属性支持表达式,比如 ${order.owner} 或 ${managersOf(order.department)},这让审批人的动态计算变得自然。

脚本任务(ScriptTask)能直接写 Groovy 或 JavaScript,但强烈不建议承载业务逻辑:它没有类型检查、没有单元测试、调试困难,且换引擎时不兼容。业务规则任务(BusinessRuleTask)用来调用 DMN 决策表,这是它唯一被推荐的用途。

还有两类少用但值得知道的节点。手工任务(ManualTask)表示「引擎不管、由人在线下完成」的步骤,只做记录用;接收任务(ReceiveTask)是等待消息的简化写法,语义上等价于一个消息中间事件,但很多团队用它来表示「等待外部系统回调」。

5. 网关:排他、并行、包容与事件

排他网关(ExclusiveGateway)是 if-else,按顺序评估出边条件,走第一条为真的分支,可以设置默认分支避免「无路可走」异常。它的菱形符号里带 X。

<bpmn:exclusiveGateway id="amountCheck" default="flowSmall"/>
<bpmn:sequenceFlow id="flowLarge" sourceRef="amountCheck" targetRef="manualApprove">
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">
    ${amount > 100000}
  </bpmn:conditionExpression>
</bpmn:sequenceFlow>
<bpmn:sequenceFlow id="flowSmall" sourceRef="amountCheck" targetRef="autoApprove"/>

并行网关(ParallelGateway)是 fork-join:到达时所有出边同时激活,所有入边都到达后才继续。要注意并行网关不等待「所有分支都执行完」以外的语义,分支内的异常不会自动回滚其他分支,需要配合补偿事件。

包容网关(InclusiveGateway)是「排他 + 并行」的混合:所有条件为真的分支都激活,等待所有激活的分支汇合。它的行为依赖运行时才知道哪些分支被激活,是死锁的常见来源。事件网关(EventBasedGateway)后接多个中间捕获事件,谁先到达走谁,适合「等待用户响应或超时」的竞态场景。

一个必须记住的约束:并行网关与包容网关的 join 必须与 fork 一一对应。如果 fork 出三条分支但 join 只连了两条入边,第三条分支会永久挂起,实例永远不结束。这类问题在图上不明显,建议用 lint 工具(Camunda Modeler 有基础校验,Flowable 提供 flowable-bpmn-lint)在 CI 里检查。

6. 子流程与调用活动

嵌入式子流程(SubProcess)把一组节点打包,有自己的作用域,内部变量对外不可见。它的主要用途是「给异常处理划范围」:子流程上的错误边界事件能捕获内部任意节点抛出的错误。

调用活动(CallActivity)调用另一个独立的流程定义,通过 calledElement 指定被调流程的 id。它与嵌入式子流程的关键差异是:调用活动是独立实例,有独立的历史记录,可以被单独查询与运维,且支持跨流程复用。

<bpmn:callActivity id="callPayment" calledElement="paymentProcess"
                   camunda:calledElementBinding="latest">
  <bpmn:extensionElements>
    <camunda:in source="orderId" target="bizId"/>
    <camunda:out source="payResult" target="paymentResult"/>
  </bpmn:extensionElements>
</bpmn:callActivity>

camunda:in 与 camunda:out 是父子流程之间的参数映射,这是复用流程定义时必须显式声明的契约。calledElementBinding="latest" 表示总用最新版本,若需要固定版本则用 version 或 versionTag。生产环境建议用 versionTag 而不是 latest,因为 latest 会让「改子流程」变成一次隐式的全局变更。

7. 多实例与会签

多实例(MultiInstance)让一个活动按集合长度重复执行,是「会签」「逐条处理」的标准实现。两种模式:并行多实例(所有实例同时创建,等所有实例完成后继续)与串行多实例(一个完成后才创建下一个,适合有序审批链)。

<bpmn:userTask id="countersign" name="会签">
  <bpmn:multiInstanceLoopCharacteristics isSequential="false"
      camunda:collection="${approvers}" camunda:elementVariable="approver">
    <bpmn:completionCondition xsi:type="bpmn:tFormalExpression">
      ${nrOfCompletedInstances / nrOfInstances >= 0.6}
    </bpmn:completionCondition>
  </bpmn:multiInstanceLoopCharacteristics>
</bpmn:userTask>

completionCondition 是多实例最有价值的特性:${nrOfCompletedInstances/nrOfInstances >= 0.6} 表示 60% 通过即结束会签,这就是「过半数通过」的语义。不写完成条件时,必须全部实例完成才会继续,任何一个人不处理就会卡住整个流程。

多实例有三个隐藏成本。第一,集合大小决定实例数,1000 人的会签会创建 1000 个执行记录,写入放大严重。第二,nrOfCompletedInstances 只统计已完成实例,被驳回后重新提交会重置计数,需要自己维护「通过/驳回」的变量。第三,多实例的变量作用域是每个实例独立的,主流程变量需要显式用 setVariable 上提。

8. 补偿事件与事务子流程

补偿事件(CompensateEvent)用来做业务级回滚。你在一个服务任务上挂补偿边界事件并指定补偿处理器,当后续流程需要回滚时,抛出一个补偿中间事件,引擎会按相反顺序调用已执行活动的补偿处理器。

<bpmn:serviceTask id="charge" name="扣款"
                  camunda:delegateExpression="${chargeDelegate}">
  <bpmn:boundaryEvent id="compensateCharge" attachedToRef="charge">
    <bpmn:compensateEventDefinition/>
  </bpmn:boundaryEvent>
</bpmn:serviceTask>
<bpmn:serviceTask id="refund" name="退款" isForCompensation="true"
                  camunda:delegateExpression="${refundDelegate}"/>
<bpmn:association associationDirection="One"
    sourceRef="compensateCharge" targetRef="refund"/>

补偿处理器必须标 isForCompensation="true",且不能有普通入边,只能通过 association 关联到补偿边界事件。补偿的执行顺序是「已成功完成的活动,按完成顺序的逆序」,未完成的活动不补偿。

事务子流程(TransactionSubProcess)把一组活动包在一个事务语义里,配合取消结束事件与补偿,形成「要么全成功、要么全部补偿」的边界。它的行为与 Saga 与分布式事务补偿 里讲的编排式 Saga 等价,只是表达方式换成了图形。要注意补偿本身也可能失败,BPMN 规范没有规定补偿失败怎么办,实践中需要给补偿处理器配重试与人工兜底。

9. 流程变量与作用域

流程变量是引擎与业务代码之间唯一的数据通道。它有两层作用域:流程实例级(execution.getVariables() 可见全部)与执行级(setVariableLocal 只在当前执行可见)。多实例、子流程、调用活动都会创建新的执行作用域,变量查找是「由内向外」逐层向上找。

// 设置到当前执行作用域(多实例里只影响本实例)
execution.setVariableLocal("approveResult", "PASS");
// 设置到流程实例作用域(所有分支可见)
execution.setVariable("orderStatus", "APPROVED");

变量有类型,Camunda 会把它们序列化后存进 ACT_RU_VARIABLE 与 ACT_HI_VARINST。支持的类型包括 String、Integer、Long、Double、Date、Boolean、Bytes、Serializable 和 JSON(Camunda 7.18+ 原生支持 JSON 类型)。强烈建议只用前七种与 JSON,不要存 Java 序列化对象,因为类结构变更后反序列化会失败。

变量大小要控制。一个常见反模式是把整个订单对象(含明细列表)塞进流程变量,每次节点流转都重新序列化写入。正确做法是只存业务主键,需要详情时由 Delegate 回查数据库。经验阈值:单个变量不超过 4 KB,一个实例的变量总量不超过 100 KB。

10. 表达式语言与 Bean 绑定

Camunda 使用统一 EL(Unified Expression Language),表达式写在 ${} 里。它能访问流程变量、调用 Spring Bean 的方法、做算术与逻辑运算:

<bpmn:sequenceFlow id="flow1" sourceRef="gateway" targetRef="escalate">
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">
    ${amount > 50000 &amp;&amp; riskLevel != 'LOW'}
  </bpmn:conditionExpression>
</bpmn:sequenceFlow>

在 XML 里 && 必须写成 &amp;&amp;,< 必须写成 &lt;,这是最常见的语法错误来源。表达式里调用 Bean 方法需要在流程引擎配置里注册 Bean 的解析器(Spring 环境下自动支持),比如 ${approvalService.nextApprover(orderId)}。

表达式应该保持简单。如果条件超过三个运算符,或者需要多行逻辑,就应该抽成一个 Bean 方法,让表达式变成 ${riskService.needManualApprove(orderId)}。这样条件逻辑可以被单元测试覆盖,而不用启动整个流程引擎去验证。

11. Camunda 7 与 Camunda 8 的取舍

维度Camunda 7Camunda 8 (Zeebe)
架构嵌入式 Java 引擎独立网关 + 分区日志
状态存储关系数据库内置日志流 + Elasticsearch 导出
通信方式Java API 为主gRPC / REST 为主
外部任务支持唯一任务模式
吞吐上限受数据库写入限制水平扩展,万级 TPS
人工任务引擎原生由 Tasklist 组件提供
历史数据同库 ACT_HI 表导出到 Elasticsearch
变量大小受数据库字段限制默认 4 MB,可配置
版本状态7.20 为长期支持版8.x 持续演进

迁移建议:新项目如果没有「必须嵌入现有 Java 应用」的约束,直接选 8;已有大量 7 的项目不必急迁,官方提供了迁移工具但人工任务与历史数据的迁移仍需评估。8 的学习成本主要在「外部任务模式」取代了 Java Delegate,以及流程变量通过 gRPC 传递带来的序列化约束。

还有一个容易被低估的差异:Camunda 8 的 Zeebe 是「按分区键路由」的,同一个流程实例必须路由到同一分区,所以它的水平扩展有上限(分区数固定)。Camunda 7 虽然受数据库限制,但扩展方式是「加应用节点、共用数据库」,运维模型更熟悉。

12. 服务任务与 Java Delegate

Java Delegate 是 Camunda 7 最直接的扩展点,实现 JavaDelegate 接口即可:

@Component("chargeDelegate")
public class ChargeDelegate implements JavaDelegate {

    private final PaymentClient paymentClient;

    public ChargeDelegate(PaymentClient paymentClient) {
        this.paymentClient = paymentClient;
    }

    @Override
    public void execute(DelegateExecution execution) {
        String orderId = (String) execution.getVariable("orderId");
        BigDecimal amount = (BigDecimal) execution.getVariable("amount");

        try {
            PaymentResult result = paymentClient.charge(orderId, amount);
            execution.setVariable("paymentId", result.id());
        } catch (InsufficientBalanceException e) {
            // 转成 BPMN Error,交给错误边界事件处理
            throw new BpmnError("BALANCE_ERROR", e.getMessage());
        }
    }
}

要点有三:抛 BpmnError 会触发错误边界事件(业务流程可处理),抛其他异常会让任务重试并最终变成技术故障(需要运维介入)。用 setVariable 写回结果而不是修改外部对象,因为变量才是引擎持久化的东西。Delegate 里不要做耗时超过秒级的操作,长任务应该异步化。

Delegate 应该设计成幂等的。引擎在崩溃恢复时会重新执行未提交的任务,同一个 Delegate 可能被调用两次。如果 Delegate 内部调用外部支付接口,必须带幂等键(通常用 businessKey 加节点 id),否则会产生重复扣款,细节见 重试幂等与补偿设计 。

13. 外部任务模式

外部任务模式把「执行」从引擎里拿出去:流程到达外部任务节点时,任务进入队列,你的 Worker 通过 REST 或 gRPC 拉取、执行、回传结果。这让 Worker 可以用任意语言写,也让引擎不必关心业务依赖。

curl -X POST "http://localhost:8080/engine-rest/external-task/fetchAndLock" \
  -H "Content-Type: application/json" \
  -d '{
    "workerId": "payment-worker-1",
    "maxTasks": 5,
    "usePriority": true,
    "asyncResponseTimeout": 30000,
    "topics": [{"topicName": "charge", "lockDuration": 60000}]
  }'

回传成功用 complete,业务失败用 bpmnError(触发边界事件),技术失败用 failure(触发重试)。lockDuration 是租约,超时未回传任务会被释放给其他 Worker,所以 Worker 必须实现幂等。

client.complete(lockedTask, Collections.singletonMap("payResult", Variables.stringValue("OK")));

asyncResponseTimeout 让请求变成「长轮询」:没有任务时服务端挂起请求最多 30 秒,有任务立即返回。这比客户端定时轮询节省大量空请求,是外部任务模式的性能关键参数。Worker 数量按 topics 分组独立伸缩,付款 Worker 和通知 Worker 可以有不同的并发度。

14. 异步延续与作业执行器

Camunda 7 的作业执行器(Job Executor)负责异步任务、定时器与重试。默认所有服务任务都是同步执行的(在调用 complete 的线程里跑完),这会让 HTTP 请求的响应时间被业务流程绑架。解决办法是给服务任务加 camunda:asyncBefore="true" 或 camunda:asyncAfter="true",把执行推到作业执行器。

<bpmn:serviceTask id="charge" name="扣款"
                  camunda:asyncBefore="true"
                  camunda:failedJobRetryTimeCycle="R5/PT30S"
                  camunda:delegateExpression="${chargeDelegate}"/>

asyncBefore="true" 表示到达该节点时先写一条作业记录,由作业执行器异步执行,事务边界也因此改变:前一个节点的事务已提交,这个节点独立成事务。这对长流程非常重要,因为它把「一个大事务」拆成了「多个小事务」,避免了长事务锁表。

failedJobRetryTimeCycle 定义重试节奏,R5/PT30S 表示最多重试 5 次、每次间隔 30 秒。超过次数后作业进入死信(ACT_RU_JOBDEF 对应的失败作业表),需要在 Cockpit 里手工处理或走运维流程。生产环境必须给所有异步节点配这个参数,否则默认重试策略可能不符合业务预期。

15. REST API 与流程启动

Camunda 7 自带完整的 REST API,/engine-rest 是根路径。启动实例时通过 businessKey 关联业务单据,通过 variables 传参:

curl -X POST "http://localhost:8080/engine-rest/process-definition/key/orderApproval/start" \
  -H "Content-Type: application/json" \
  -d '{
    "businessKey": "ORDER-20261007-001",
    "variables": {
      "orderId": {"value": "ORDER-20261007-001", "type": "String"},
      "amount": {"value": 88000, "type": "Double"}
    },
    "withVariablesInReturn": true
  }'

businessKey 是引擎外部的业务标识,官方建议每个流程实例都有,且要建唯一索引,用它做幂等启动的判据。查询实例用 /process-instance?businessKey=...,完成任务用 /task/{id}/complete,投递消息用 /message。

REST API 的鉴权在社区版里需要自己实现(企业版有原生认证),常见做法是前置一层网关做鉴权与审计,参见 API 网关设计 。另外要注意 REST API 的变量类型是显式声明的,不声明类型时引擎会按字符串处理,${amount > 100000} 这类比较会因字符串比较而出错。

16. 数据库表结构与历史数据

Camunda 7 的状态全在关系库里,表名有清晰前缀:

前缀含义示例
ACT_RE_流程定义与部署ACT_RE_PROCDEF 存定义与版本
ACT_RU_运行时数据ACT_RU_EXECUTION 存执行树
ACT_HI_历史数据ACT_HI_PROCINST 存实例历史
ACT_GE_通用数据ACT_GE_BYTEARRAY 存 BPMN XML
ACT_ID_身份数据ACT_ID_GROUP 存候选组

运行时表的数据量随活跃实例数增长,历史表则只增不减,是数据库膨胀的主因。Camunda 提供四种历史级别:none 不记录、activity 只记活动、audit 记活动加变量加任务、full 额外记录变量每次变更。多数项目的正确选择是 audit,full 的数据量是它的 3 到 10 倍,只有强合规场景才需要。生产环境必须按流程定义配置 camunda:historyTimeToLive(比如 P90D),并让引擎自带的历史清理批处理跑在业务低峰期。

-- 查询卡在某个用户任务超过 3 天的实例
SELECT p.BUSINESS_KEY_, p.PROC_DEF_ID_, t.NAME_, t.CREATE_TIME_
FROM ACT_RU_TASK t
JOIN ACT_RU_EXECUTION e ON e.ID_ = t.EXECUTION_ID_
JOIN ACT_HI_PROCINST p ON p.ID_ = e.PROC_INST_ID_
WHERE t.CREATE_TIME_ < DATE_SUB(NOW(), INTERVAL 3 DAY)
ORDER BY t.CREATE_TIME_ ASC;

ACT_RU_EXECUTION 是理解 Camunda 数据模型的关键:它是一棵树,根节点是流程实例,子节点是并行分支、多实例实例、子流程作用域。ACT_RU_TASK 通过 EXECUTION_ID_ 指向具体执行,一个执行可以有多个任务(多实例)。排查问题时先定位执行树,再看任务,最后看变量,这条路径能覆盖 80% 的问题。

17. 与 DMN 的协作

DMN(Decision Model and Notation)是决策建模标准,与 BPMN 同属 OMG 规范。两者的分工是:BPMN 管「流程怎么走」,DMN 管「这个条件下该走哪条路」。把决策逻辑放进 DMN 决策表的好处是它可以用表格表达,业务方能直接维护。

在 Camunda 里,BusinessRuleTask 通过 camunda:decisionRef 引用一个已部署的 DMN 决策:

<bpmn:businessRuleTask id="riskDecision" name="风险评估"
    camunda:decisionRef="riskLevel"
    camunda:resultVariable="riskLevel"
    camunda:mapDecisionResult="singleEntry"/>

resultVariable 把决策结果写入流程变量,mapDecisionResult 决定如何映射多输出(singleEntry 取第一个输出、collectEntries 收集成列表)。DMN 决策表的编写、命中策略与 FEEL 表达式细节见 规则引擎与决策表 。

把决策抽到 DMN 的一个实际收益是「可测试性」:决策表可以脱离流程单独跑测试用例(输入 → 期望输出),而如果条件写在排他网关上,你必须启动整个流程实例才能验证。对于风险评分、费率计算、审批路由这类规则密集的逻辑,这个收益非常明显。

18. 建模规范与版本管理

一个能长期维护的 BPMN 项目需要下面几条规范:

  • 命名:流程 id 用大驼峰(OrderApproval),节点 id 用「动词 + 名词」(checkCredit),显示名用中文短语。
  • 粒度:单个流程定义的节点数不超过 30,超过则拆成调用活动。
  • 分层:主流程只画业务阶段,技术细节下沉到被调用流程或外部服务。
  • 版本:用 versionTag 标记里程碑版本,避免业务方看到 47 个版本无从选择。
  • 变量:流程变量用统一前缀(biz_、sys_)区分业务变量与系统变量,避免命名冲突。
  • 评审:BPMN 文件纳入 Git,改动走 PR 评审,部署时用 CI 校验 XML 合法性。
# 部署前校验 BPMN XML 合法性
xmllint --noout --schema BPMN20.xsd src/main/resources/bpmn/*.bpmn
# 用 Camunda 的 REST 接口做部署(幂等:同名同版本不会重复部署)
curl -X POST -F "file=@orderApproval.bpmn" \
  "http://localhost:8080/engine-rest/deployment/create" \
  -F "deployment-name=orderApproval-v12" \
  -F "enable-duplicate-filtering=true"

enable-duplicate-filtering=true 是 CI 里的关键参数:它会跳过内容未变的资源,避免每次构建都产生一个新版本。没有它,一次无关的构建也会让版本号加一,很快就没有人分得清哪个版本是「真的」新版本。

19. 运行中实例的版本兼容

这是 BPMN 项目最容易被忽略的问题。Camunda 的规则是「流程实例绑定启动时的那份定义」:部署新版本后,运行中的老实例继续走老定义,只有新启动的实例走新定义。这个规则听起来很安全,但带来两个问题。

第一,如果你删掉了某个节点,老实例走到那里会报错。Camunda 会阻止删除正在被实例使用的流程定义(除非强制级联),但节点级别的改动不会被阻止。所以规范是「只增不删」:废弃的节点保留但断掉入边,或者用 versionTag 区分。

第二,跨版本修改业务语义会造成混乱。比如老实例还在用「审批金额 > 5 万需要总监」的老规则,新实例用「> 10 万」的新规则,同一个业务系统里两套规则并存。这需要业务方明确接受,并在审计时能说清每个实例用的是哪版规则。

如果需要把老实例迁移到新定义,Camunda 提供 Process Instance Modification API:

curl -X POST "http://localhost:8080/engine-rest/process-instance/{id}/modification" \
  -H "Content-Type: application/json" \
  -d '{
    "instructions": [
      {"type": "cancelActivityInstance", "activityInstanceId": "..."},
      {"type": "startBeforeActivity", "activityId": "newApprove"}
    ],
    "annotation": "手工迁移到新流程版本"
  }'

这个 API 是运维工具而不是常规手段,每次调用都应该有审批记录与原因说明。它的典型用途是「某实例卡在一个已废弃的节点上,需要手工推进到新节点」。

20. 权衡取舍

选择收益代价
用 Camunda 7 嵌入式与 Java 应用同事务,调试方便受数据库写入限制,升级到 8 困难
用 Camunda 8高吞吐、云原生、弹性伸缩需运维 Zeebe 集群,变量序列化受限
Java Delegate类型安全、易测试只能用 JVM 语言,逻辑与引擎耦合
外部任务模式多语言、Worker 独立伸缩需自己实现轮询与幂等
用调用活动拆流程复用、独立运维参数映射繁琐,跨流程调试成本高
用多实例做会签声明式,语义清晰大集合(上千人)会拖垮引擎
用 ScriptTask 写逻辑改起来快无类型检查、无测试、不可迁移
历史级别设 full变量变更可追溯数据量放大 3 到 10 倍

21. 常见坑清单

  1. 修改运行中实例引用的流程定义 id,导致实例找不到节点而卡死,正确做法是只改 name 或部署新版本。
  2. 并行网关的分支里抛异常但没配错误边界事件,实例永久停在网关等待,需要人工干预。
  3. 多实例不设 completionCondition,一个审批人离职就卡住整条流程。
  4. 在 ScriptTask 里写业务逻辑,换引擎或升级版本时语法不兼容,且无法单元测试。
  5. 流程变量存放大对象(比如整个订单 JSON),每次持久化都写一遍,数据库迅速膨胀。
  6. 忘记设置 historyTimeToLive,历史表一年后到千万级,查询和清理都变慢。
  7. 定时边界事件用绝对时间表达式,夏令时或时区配置错误导致提前或延后触发。
  8. 外部任务 Worker 未实现幂等,锁超时后任务被重复拉取,造成重复扣款。
  9. 用 businessKey 做幂等启动但没建唯一索引,并发下产生两个实例。
  10. 把 Camunda 7 的 camunda:class 直接迁移到 8,8 不支持内嵌 Delegate,必须改成外部任务。
  11. 表达式里写 && 而没转义成 &amp;&amp;,XML 解析失败但错误信息指向行号之外的位置。
  12. 服务任务默认同步执行,HTTP 接口响应时间被整条流程绑架,忘记加 asyncBefore。
  13. 部署时没开 enable-duplicate-filtering,每次构建都产生新版本,版本号迅速失控。

22. 小结

BPMN 与 Camunda 的价值不在「能画图」,而在「流程定义成为可版本化、可审计、业务方可读的一等资产」。代价是引入了一门建模语言,需要团队主动约束元素子集、制定命名与分层规范,否则图形会退化成比代码更难维护的资产。

落地路线上,建议先做一个只有服务任务与排他网关的最小流程,把部署、启动、查询、监控四个动作跑通;再加上用户任务与边界定时事件,验证人工审批与超时;最后引入调用活动与补偿事件,处理跨流程复用与回滚。每一步都补上对应的观测手段,参考 工作流可观测与调试 。

如果流程里有大量审批节点,下一站应该读 人工任务与审批流表单 ,那里会把会签、加签、转办、委托、表单绑定这些实战细节讲透;如果流程需要做业务级回滚,则应该读 Saga 与分布式事务补偿 ,把补偿的幂等与顺序问题想清楚。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「工作流引擎」更多文章

  1. 工作流成本优化
  2. 执行器与资源隔离
  3. 调度、回填与补数