引言
BPMN 2.0 是工作流领域唯一被广泛接受的图形化标准。它的价值在于把「流程」这件事从代码里解放出来,让业务分析师、合规人员、实施顾问能在同一张图上对话。但 BPMN 的规范厚度也是它的风险:超过 40 种元素、十余种事件类型,如果团队没有建模规范,最终产出的图会比代码更难维护。
Camunda 是这个标准最成功的工程实现。它的定位很清晰:把引擎嵌入你的 Java 应用(7.x),或者把引擎做成独立的云原生服务(8.x,即 Zeebe),业务代码通过 Delegate、External Task 或 REST 与引擎交互。选 Camunda 的团队通常有两个特征:流程规则由业务方主导,以及流程里有大量人工审批节点。
工程上真正的难点不在「怎么画图」,而在三件事:元素子集的约束(用少了表达力不够,用多了没人看得懂)、运行中实例的版本兼容(流程定义改了,老实例怎么办)、以及人工任务与自动任务的边界(哪些逻辑该进引擎,哪些该留在服务里)。这三件事没有标准答案,但有一批被反复验证过的做法。
本文先从「该用哪些元素」这个最实际的问题切入,给出一个可落地的元素子集,再逐类讲解语义,然后用完整的 XML 与 Java 代码演示服务任务、边界事件、多实例审批、补偿的写法,接着讲流程变量、表达式、作业执行器这些运行机制,最后讲 Camunda 7 与 8 的取舍、数据库表结构与运维要点。想先看整体选型框架的读者,可以从 工作流引擎全景与选型 开始。
目录
- 该用哪些 BPMN 元素
- 流程定义的结构骨架
- 事件:开始、结束、中间与边界
- 任务:服务、用户、脚本与业务规则
- 网关:排他、并行、包容与事件
- 子流程与调用活动
- 多实例与会签
- 补偿事件与事务子流程
- 流程变量与作用域
- 表达式语言与 Bean 绑定
- Camunda 7 与 Camunda 8 的取舍
- 服务任务与 Java Delegate
- 外部任务模式
- 异步延续与作业执行器
- REST API 与流程启动
- 数据库表结构与历史数据
- 与 DMN 的协作
- 建模规范与版本管理
- 运行中实例的版本兼容
- 权衡取舍
- 常见坑清单
- 小结
1. 该用哪些 BPMN 元素
规范给了 40 多个元素,实战中 90% 的流程只需要 15 个左右。建议把下面的子集作为团队默认工具箱,超出子集必须评审:
| 类别 | 推荐元素 | 谨慎使用 |
|---|---|---|
| 事件 | StartEvent、EndEvent、TimerBoundaryEvent、MessageBoundaryEvent、ErrorBoundaryEvent | SignalEvent、EscalationEvent |
| 任务 | ServiceTask、UserTask、CallActivity | ScriptTask、BusinessRuleTask |
| 网关 | ExclusiveGateway、ParallelGateway | InclusiveGateway、EventBasedGateway |
| 结构 | SubProcess、MultiInstance | TransactionSubProcess、AdHocSubProcess |
| 其他 | SequenceFlow、DataObject | ComplexGateway |
约束的理由很实际: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 && riskLevel != 'LOW'}
</bpmn:conditionExpression>
</bpmn:sequenceFlow>
在 XML 里 && 必须写成 &&,< 必须写成 <,这是最常见的语法错误来源。表达式里调用 Bean 方法需要在流程引擎配置里注册 Bean 的解析器(Spring 环境下自动支持),比如 ${approvalService.nextApprover(orderId)}。
表达式应该保持简单。如果条件超过三个运算符,或者需要多行逻辑,就应该抽成一个 Bean 方法,让表达式变成 ${riskService.needManualApprove(orderId)}。这样条件逻辑可以被单元测试覆盖,而不用启动整个流程引擎去验证。
11. Camunda 7 与 Camunda 8 的取舍
| 维度 | Camunda 7 | Camunda 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. 常见坑清单
- 修改运行中实例引用的流程定义 id,导致实例找不到节点而卡死,正确做法是只改 name 或部署新版本。
- 并行网关的分支里抛异常但没配错误边界事件,实例永久停在网关等待,需要人工干预。
- 多实例不设 completionCondition,一个审批人离职就卡住整条流程。
- 在 ScriptTask 里写业务逻辑,换引擎或升级版本时语法不兼容,且无法单元测试。
- 流程变量存放大对象(比如整个订单 JSON),每次持久化都写一遍,数据库迅速膨胀。
- 忘记设置 historyTimeToLive,历史表一年后到千万级,查询和清理都变慢。
- 定时边界事件用绝对时间表达式,夏令时或时区配置错误导致提前或延后触发。
- 外部任务 Worker 未实现幂等,锁超时后任务被重复拉取,造成重复扣款。
- 用 businessKey 做幂等启动但没建唯一索引,并发下产生两个实例。
- 把 Camunda 7 的
camunda:class直接迁移到 8,8 不支持内嵌 Delegate,必须改成外部任务。 - 表达式里写
&&而没转义成&&,XML 解析失败但错误信息指向行号之外的位置。 - 服务任务默认同步执行,HTTP 接口响应时间被整条流程绑架,忘记加
asyncBefore。 - 部署时没开
enable-duplicate-filtering,每次构建都产生新版本,版本号迅速失控。
22. 小结
BPMN 与 Camunda 的价值不在「能画图」,而在「流程定义成为可版本化、可审计、业务方可读的一等资产」。代价是引入了一门建模语言,需要团队主动约束元素子集、制定命名与分层规范,否则图形会退化成比代码更难维护的资产。
落地路线上,建议先做一个只有服务任务与排他网关的最小流程,把部署、启动、查询、监控四个动作跑通;再加上用户任务与边界定时事件,验证人工审批与超时;最后引入调用活动与补偿事件,处理跨流程复用与回滚。每一步都补上对应的观测手段,参考 工作流可观测与调试 。
如果流程里有大量审批节点,下一站应该读 人工任务与审批流表单 ,那里会把会签、加签、转办、委托、表单绑定这些实战细节讲透;如果流程需要做业务级回滚,则应该读 Saga 与分布式事务补偿 ,把补偿的幂等与顺序问题想清楚。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。