Activiti BPMN流程设计与可执行建模
BPMN 流程图不是给领导看的静态图片,而是一份会被引擎解析和执行的模型。节点 ID、连线方向、变量表达式、候选组、边界事件和多实例完成条件,都会直接决定运行时产生哪些 Execution、Task、Job 和历史记录。
真正的流程设计不是把现有 if/else 画成菱形,而是先澄清业务角色、事实、权限、状态、异常和一致性边界,再把适合由流程引擎协调的部分翻译成 BPMN。流程图负责“过程如何推进”,业务表负责“业务事实是什么”,权限服务负责“谁真的有权操作”,三者不能互相替代。
学习目标
完成本页后,你应该能够:
- 解释 BPMN XML 为什么是可执行模型,而不是普通图片。
- 区分 Event、Task、Gateway、SubProcess、Call Activity 和 Sequence Flow。
- 从业务语言提取角色、动作、事实、状态、异常和审计要求。
- 为节点 ID、流程 key、变量和候选组建立稳定契约。
- 正确选择 User Task、Service Task、Receive Task 和异步任务。
- 正确选择排他、并行、包容与事件型网关。
- 设计 Timer、Error、Message 等事件和边界事件。
- 区分嵌入子流程、调用活动和多实例任务。
- 写出可执行 BPMN、Java Delegate 和模型测试 Demo。
- 识别万能流程图、任意跳转、大变量、硬编码审批人等反模式。
- 设计商业流程评审、版本兼容、监控和线上排查证据。
一、为什么要先建模再编码
“合同提交后需要审批”不是可实现需求。至少还缺少:
- 谁能提交,提交时业务状态必须是什么?
- 主管、法务、财务是固定人、角色、部门负责人还是动态人员集合?
- 三个部门串行还是并行,谁拒绝后是否立即结束?
- 金额或合同类型怎样影响分支?
- 审批人离职、休假或组织变更怎么办?
- 多人同时点审批,谁成功、谁得到幂等结果?
- 超过 24 小时如何提醒,超过 72 小时是否升级?
- 驳回到发起人还是上一节点,修改后是原实例继续还是重开?
- 流程结束、监听器失败、消息发送失败时业务状态怎样保持一致?
- 新流程发布后,旧实例和旧变量怎样兼容?
如果不先回答,问题不会消失,只会变成散落在 Controller、Listener、SQL、定时任务和前端按钮中的隐式规则。
flowchart TD
A["模糊业务需求"] --> B["识别角色、动作和业务事实"]
B --> C["明确主路径、分支和异常矩阵"]
C --> D["划分流程引擎、业务服务和权限边界"]
D --> E["映射为BPMN元素和变量契约"]
E --> F["模型评审与真值表测试"]
F --> G["部署、冒烟和生产观察"]二、BPMN文件到底包含什么
一个 BPMN 文件通常包含两类内容:
- 语义模型:
process、Event、Task、Gateway、Sequence Flow 等,决定怎样执行。 - 图形布局:BPMN DI 中节点坐标和连线拐点,决定建模器怎样显示。
引擎真正执行的是语义模型。没有 DI 时,XML 仍可能可执行,但建模器可能无法正确显示布局;只有一张 PNG 图则无法执行,因为没有节点 ID、连接和表达式语义。
flowchart TD
A["BPMN XML"] --> B["语义部分:可执行模型"]
A --> C["DI部分:图形坐标和连线"]
B --> D["引擎解析为ProcessDefinition"]
C --> E["Modeler或查看器渲染流程图"]核心根元素:
<?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="contract_approval"
name="合同审批"
isExecutable="true">
<!-- 可执行节点和连线 -->
</process>
</definitions>process id部署后通常成为processDefinitionKey,是长期版本线身份。name是面向人的名称,可以调整,但仍应经过变更审计。isExecutable="true"表示该模型用于执行,而不只是描述协作。activiti命名空间承载候选人、Delegate 等引擎扩展属性。
三、先掌握Token/Execution心智模型
可以把流程实例想成一个沿 Sequence Flow 移动的执行 Token。引擎内部用 Execution 表达执行路径;并行拆分会产生多个并发 Execution,汇聚会等待并合并相应路径。
flowchart TD
A["Start Event创建执行路径"] --> B["Execution进入User Task并等待"]
B --> C["用户完成Task"]
C --> D["Execution沿Sequence Flow继续"]
D --> E{"并行网关"}
E --> F["Execution A"]
E --> G["Execution B"]
F --> H{"汇聚"}
G --> H
H --> I["End Event结束"]这个模型能解释:
- User Task 产生待办并让 Execution 等待,不占用 Java 线程一直阻塞。
- Service Task 通常立即执行代码,除非配置异步边界。
- 并行 BPMN 是执行路径并发,不等于操作系统线程并发。
- 某分支没有到达 Join 时,流程会保留运行状态,而不是“引擎丢了任务”。
深入见:网关、Execution与分支汇聚原理。
四、Event:流程为什么开始、等待和结束
Event 表示发生的事情,而不是人工动作。
4.1 Start Event
常见启动方式:
| 类型 | 含义 | 场景 |
|---|---|---|
| None Start Event | 由API直接启动 | 用户提交申请 |
| Message Start Event | 收到特定业务消息后启动 | 接收外部工单事件 |
| Timer Start Event | 按时间规则启动 | 每月结算流程 |
| Signal Start Event | 收到广播信号启动 | 多流程共同响应事件,需谨慎 |
商业 API 发起审批通常用 None Start Event,并由业务 Service 在事务内保存业务单、校验幂等并启动实例。
4.2 Intermediate Catch Event
流程运行到这里后等待外部事件,例如消息、信号或定时器。等待期间不会让一个 Web 请求一直占线程;引擎保存订阅或 Job,事件到达时再恢复 Execution。
4.3 Boundary Event
边界事件附着在 Task/SubProcess 上,表示节点执行期间发生超时或错误时怎样处理。
flowchart TD
A["经理审批User Task"] --> B["正常完成后进入下一节点"]
A --> C["边界Timer:24小时到期"]
C --> D["发送催办或升级任务"]边界事件分为中断和非中断:
- 中断边界事件:触发后取消被附着 Activity,再走异常路径。
- 非中断边界事件:触发后额外产生一条路径,原 Activity 继续等待。
催办通常适合非中断 Timer;超时自动取消可能用中断 Timer。模型符号和引擎支持以当前 Activiti 版本为准。
4.4 End Event
普通 End Event 结束到达它的当前执行路径。在并行结构中,一条路径到达普通 End 不一定终止整个流程实例;Terminate End Event 通常会终止所在作用域中的其他活动路径。
因此“任一审批拒绝就结束全部并行任务”不能只画普通 End,必须明确终止范围并测试其他 Task/Execution 是否被取消。
五、Task:谁真正执行工作
5.1 User Task
User Task 表示需要人处理。到达后通常写入运行时任务表,等待认领或完成。
<userTask id="legalReview"
name="法务审核"
activiti:candidateGroups="LEGAL_REVIEWER"/>它只表达流程候选关系,业务 Service 仍要校验:
- 当前登录用户是否属于候选组或就是 assignee。
- 是否拥有该合同/部门的数据权限。
- Task 是否仍处于可处理状态。
- 请求是否重复,审批意见是否满足必填规则。
前端“看不到按钮”不是权限控制。
5.2 Service Task
Service Task 让引擎调用应用代码,例如生成发布记录、调用风险服务、发送命令。
常见配置方式会随版本不同,包括:
<serviceTask id="freezeBudget"
name="冻结预算"
activiti:delegateExpression="${budgetFreezeDelegate}"/>Java Delegate 示例:
@Component("budgetFreezeDelegate")
public class BudgetFreezeDelegate implements JavaDelegate {
private final BudgetService budgetService;
public BudgetFreezeDelegate(BudgetService budgetService) {
this.budgetService = budgetService;
}
@Override
public void execute(DelegateExecution execution) {
String businessKey = execution.getProcessInstanceBusinessKey();
String operationId = execution.getProcessInstanceId()
+ ":" + execution.getCurrentActivityId();
budgetService.freezeIdempotently(businessKey, operationId);
}
}Delegate 必须幂等,因为事务重试、Job 重试、网络超时和人工补偿都可能再次触发。operationId 应在下游建立唯一约束,而不是只在内存 Set 中去重。
5.3 Receive Task
Receive Task 表示流程主动停下,等待外部 API/消息触发继续。适合外部系统回执,但必须设计 Correlation Key、重复消息、超时和消息先到/订阅后建的竞态。
5.4 Script Task
脚本任务灵活,但会引入脚本引擎依赖、沙箱、资源限制、供应链和任意代码执行风险。生产优先使用受版本控制和测试的 Delegate;若允许在线脚本,发布权限应按代码发布级别治理。
5.5 Manual Task与Business Rule Task
Manual Task 在可执行引擎中通常不会产生可办理 User Task;它更多表达引擎外人工动作。Business Rule Task 依赖规则引擎集成。不要看到名称就假设 Activiti 会自动提供表单、规则管理和人员待办。
六、Gateway:流程怎样选择和同步
| 需求 | 应选择 | 不应选择 |
|---|---|---|
| 金额决定唯一审批级别 | 排他网关 | 并行网关 |
| 法务和财务都必须审核 | 并行网关 | 两条条件重叠的排他网关 |
| 按风险选择一个或多个部门 | 包容网关 | 固定全部激活的并行网关 |
| 等待支付或超时谁先到 | 事件型网关 | 不断轮询变量的排他网关 |
网关的完整执行、汇聚和排障见:网关、Execution与分支汇聚原理。
七、Sequence Flow:流程真正怎样连接
Sequence Flow 连接同一 Process/子流程范围内的节点。它可以带条件表达式:
<sequenceFlow id="toDirector"
sourceRef="amountGateway"
targetRef="directorReview">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[
${approvalLevel == 'DIRECTOR'}
]]></conditionExpression>
</sequenceFlow>节点在画布上靠得近不代表有连接;线条穿过节点也不代表执行经过该节点。唯一权威是 XML 中 sourceRef、targetRef 和引擎解析模型。
节点和连线 ID 必须稳定:
task1、gateway2没有业务含义,日志和迁移难读。- 修改 Activity ID 会影响运行实例迁移、历史报表和监控聚合。
- 同一版本内 ID 必须唯一。
- 建议使用英文稳定 ID,加中文业务名称。
示例:
ID: securityReview
Name: 安全审核八、SubProcess和Call Activity怎么选
8.1 Embedded SubProcess
嵌入子流程属于当前 ProcessDefinition,适合组织局部复杂度和定义统一边界事件。
优点:同一版本、变量作用域关联直观;缺点:不能被多个流程独立复用和发布。
8.2 Event SubProcess
事件子流程由事件触发,可中断或非中断当前作用域。适合统一处理取消、异常或补偿,但执行语义复杂,必须验证与主流程并发路径的关系。
8.3 Call Activity
Call Activity 调用另一个独立流程定义,适合可复用的标准流程,例如“统一法务审查”。
<callActivity id="callLegalReview"
name="调用法务审查子流程"
calledElement="legal_review"/>风险:
calledElement按 key 解析时可能调用到最新子流程版本。- 父流程与子流程版本组合需要可追踪。
- 输入/输出变量映射不清会污染父作用域。
- 子流程挂起、失败或升级会影响多个父流程。
8.4 选型表
| 需求 | Embedded SubProcess | Call Activity |
|---|---|---|
| 只为当前流程折叠复杂度 | 适合 | 过重 |
| 多个流程复用 | 不适合 | 适合 |
| 独立部署版本 | 不支持 | 支持 |
| 与父流程同版本发布 | 天然一致 | 需要组合版本治理 |
| 局部变量隔离 | 有作用域但仍在同定义 | 需要显式输入输出设计 |
不要仅为了让图“看起来小”就拆 Call Activity。独立发布、复用和团队边界才是更重要的依据。
九、多实例任务:动态多人审批
多人会签不是画很多固定并行线。Multi-instance 会根据集合为同一个 Activity 创建多个实例。
<userTask id="expertReview"
name="专家会审"
activiti:assignee="${expertUserId}">
<multiInstanceLoopCharacteristics
isSequential="false"
activiti:collection="expertUserIds"
activiti:elementVariable="expertUserId">
<completionCondition><![CDATA[
${nrOfCompletedInstances == nrOfInstances}
]]></completionCondition>
</multiInstanceLoopCharacteristics>
</userTask>常见内置计数变量语义:
nrOfInstances:总实例数。nrOfActiveInstances:当前活动实例数。nrOfCompletedInstances:已完成实例数。loopCounter:当前实例索引。
具体名称和可见范围以当前 Activiti 版本为准。
9.1 并行会签
isSequential="false" 通常一次生成多个并行任务,适合专家同时评审。要考虑任务数量峰值、撤销剩余任务、审批意见冲突和同一人重复出现。
9.2 串行会签
isSequential="true" 一次只激活一个实例,按集合顺序处理。组织人员集合如果无稳定排序,实际审批顺序可能不可预测。
9.3 completionCondition
全票完成:
${nrOfCompletedInstances == nrOfInstances}达到半数即结束的示意思路:
${nrOfCompletedInstances * 2 >= nrOfInstances}但“完成”不等于“同意”。实际会签需要单独统计批准和拒绝结果,并定义剩余活动 Task 是否被取消。不要把完成数量误当通过票数。
十、Listener不是万能业务层
Activiti 常见 Task Listener 和 Execution Listener,可在创建、分配、完成、节点开始/结束等生命周期执行代码。
适合:
- 记录必要的流程审计事件。
- 计算简单候选人。
- 发布事务内领域事件到 Outbox。
- 做稳定、短时、幂等的流程技术动作。
不适合:
- 把全部业务状态机藏在 Listener 中。
- 同步调用多个慢外部接口。
- 吞掉异常继续提交不一致状态。
- 依赖前端参数决定权限。
- 在多个 Listener 重复更新同一业务字段。
Listener 通常与引擎命令处于同一事务。抛异常会导致完成任务或推进流程回滚;吞异常则可能让流程继续但业务动作没完成。必须明确失败策略。
十一、同步和异步边界
同步 Service Task 在当前 API 请求和引擎事务中执行:
flowchart TD
A["用户完成审批Task"] --> B["Execution进入Service Task"]
B --> C["当前线程调用Delegate"]
C --> D["Delegate成功"]
D --> E["创建下一任务并提交事务"]优点是事务结果直接;缺点是慢调用拖长锁和响应时间,外部超时也可能导致整体回滚。
异步边界通常会创建 Job,由 Job Executor 后续推进:
flowchart TD
A["审批完成到达异步边界"] --> B["持久化Job并提交当前事务"]
B --> C["Job Executor获取任务"]
C --> D["执行Service Task"]
D --> E{"执行结果"}
E -->|"成功"| F["推进到下一节点"]
E -->|"失败"| G["记录异常并按策略重试"]异步提高故障隔离,但带来最终一致性、重复执行、Job 重试和监控要求。它不是简单加一个属性就自动可靠。
十二、流程变量怎样设计
流程变量只保存推进流程真正需要的小型上下文:
| 适合放变量 | 不适合放变量 |
|---|---|
approvalLevel=HIGH | 完整合同对象JSON |
sensitive=true | 附件二进制 |
applicantId=U1001 | 医疗明细列表 |
reviewerIds=[...] | 可通过业务ID查询的重复大对象 |
ruleVersion=2026-07 | 密码、Token、私钥 |
大对象会造成:
- Runtime/History变量表膨胀。
- 每次序列化、反序列化和查询成本增加。
- Java序列化类版本变化后难以读取旧变量。
- 敏感信息扩散到日志、历史和备份。
- 业务查询绕开正式业务表。
推荐保存业务 ID,在业务 Service 中按权限读取最新数据;若审批必须基于提交时快照,则在业务审计表保存不可变快照和版本,流程变量只保存快照 ID。
十三、业务状态和流程状态怎样划分
流程引擎表回答:
- 当前 Execution 在哪个 Activity。
- 有哪些 User Task、Job 和变量。
- 流程历史经过哪些节点。
业务表回答:
- 合同金额、主体、版本、附件和业务合法性。
- 当前业务状态,例如
APPROVING、APPROVED、REJECTED。 - 审批意见、操作者、幂等请求和审计快照。
- 数据权限、报表和外部系统同步状态。
flowchart TD
A["业务表:合同事实和业务状态"] --> C["businessKey/processInstanceId关联"]
B["Activiti:Execution、Task和History"] --> C
D["审批记录表:每次业务动作"] --> C不能通过当前 Activity ID 直接替代所有业务状态,因为同一业务状态可能包含多个并行 Activity,流程结束后运行时记录会清理,业务查询也不应强依赖引擎内部表。
十四、审批人分配怎样设计
14.1 固定assignee
activiti:assignee="user001"只适合测试。人员离职、调岗后模型需要重新发布,不适合生产。
14.2 候选组
activiti:candidateGroups="LEGAL_REVIEWER"流程保存稳定角色代码,实际人员由组织权限系统解析。待办查询和完成接口都要验证用户当前组关系。
14.3 表达式解析动态人员
activiti:assignee="${departmentManagerId}"业务 Service 在启动或前置节点计算 departmentManagerId。必须处理为空、离职、多人、代理和组织变更。
14.4 自定义人员解析服务
复杂系统可以通过白名单 Bean 查询组织:
activiti:assignee="${approvalAssigneeResolver.resolve(execution)}"这会让模型依赖应用 Bean、数据库和组织服务。要设置超时、缓存、降级和兼容期,并防止普通模型作者调用任意 Bean。
14.5 任务创建时快照还是实时解析
- 创建 Task 时确定 assignee:轨迹稳定,但人员离职后需转办。
- 查询待办时实时按候选组:组织变更立即生效,但审计要记录真正办理人。
两者可以结合:角色候选关系实时解析,认领后固定 assignee 并记录处理快照。
十五、驳回、撤回和任意跳转不是一个动作
驳回
当前审批人根据业务规则拒绝,可能结束流程、退回发起人修改或返回固定复核节点。
撤回
通常由发起人在下游尚未处理时撤销申请。要校验当前节点、并发任务、外部副作用和权限。
退回上一步
“上一步”在并行、循环、子流程中不一定唯一。根据历史最后节点动态跳转可能得到错误目标。
任意跳转
改变当前 Activity 会影响 Execution、Task、并行 Token、Job、历史和业务状态。它不是改一个 ACT_RU_TASK.TASK_DEF_KEY_ 字段。
推荐策略:
- 常见驳回路径明确画在 BPMN 中,语义可读、可测试。
- 复杂人工纠错由受控管理员操作,记录原因、前后节点、业务状态和补偿。
- 不向普通用户开放任意 Activity ID。
- 动态状态变更 API 能力随 Activiti 版本不同,必须以当前版本测试。
十六、超时、催办和升级怎样建模
需求:“24 小时未审批就催办,72 小时未审批就升级给上级,但原审批仍可处理。”
可以用两个非中断 Timer Boundary Event,或者将提醒交给独立 SLA 服务。选择取决于提醒是否属于流程语义、任务量和调度治理能力。
flowchart TD
A["审批Task"] --> B["正常审批完成"]
A --> C["24小时非中断Timer"]
C --> D["发送催办"]
A --> E["72小时非中断Timer"]
E --> F["创建升级通知或管理员任务"]必须明确:
- 计时从 Task 创建还是业务提交开始。
- 节假日和工作日历怎样计算。
- 应用停机期间错过 Timer 怎样补触发。
- 多次催办频率和幂等键。
- 任务完成后 Timer Job 是否清理。
- 升级是通知、转办还是新增并行任务。
十七、错误、重试和补偿怎样设计
三类失败不要混在一起:
| 失败 | 示例 | 处理 |
|---|---|---|
| 业务拒绝 | 法务认为合同条款不合规 | 明确BPMN业务分支,不应作为系统异常重试 |
| 瞬时技术失败 | 下游接口超时、数据库连接短暂失败 | 异步Job有限重试、退避、幂等 |
| 永久技术失败 | 参数非法、目标不存在、权限配置错误 | 停止自动重试,告警和人工修复 |
补偿不是数据库回滚。外部系统已冻结预算、发送短信或创建账户后,当前事务回滚不能自动撤销远程副作用,需要可幂等的反向操作和审计。
十八、完整商业BPMN Demo
下面演示合同审批主链:主管审批后按金额决定是否进入并行法务/财务审核,最后归档。为了保持示例可读,省略 BPMN DI 坐标和部分扩展。
<?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="contract_approval"
name="合同审批"
isExecutable="true">
<startEvent id="submitStart" name="提交合同"/>
<userTask id="managerReview"
name="直属主管审批"
activiti:candidateGroups="CONTRACT_MANAGER"/>
<exclusiveGateway id="managerResultGateway"
name="主管审批结果"
default="toRejectedEnd"/>
<exclusiveGateway id="amountGateway"
name="判断是否需要联合审核"
default="toArchiveDirectly"/>
<parallelGateway id="jointReviewSplit"
name="发起联合审核"/>
<userTask id="legalReview"
name="法务审核"
activiti:candidateGroups="LEGAL_REVIEWER"/>
<userTask id="financeReview"
name="财务审核"
activiti:candidateGroups="FINANCE_REVIEWER"/>
<parallelGateway id="jointReviewJoin"
name="等待联合审核完成"/>
<serviceTask id="archiveContract"
name="归档合同"
activiti:delegateExpression="${contractArchiveDelegate}"/>
<endEvent id="approvedEnd" name="审批完成"/>
<endEvent id="rejectedEnd" name="审批拒绝"/>
<sequenceFlow id="f1" sourceRef="submitStart" targetRef="managerReview"/>
<sequenceFlow id="f2" sourceRef="managerReview" targetRef="managerResultGateway"/>
<sequenceFlow id="toAmountGateway"
sourceRef="managerResultGateway"
targetRef="amountGateway">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[
${managerApproved == true}
]]></conditionExpression>
</sequenceFlow>
<sequenceFlow id="toRejectedEnd"
sourceRef="managerResultGateway"
targetRef="rejectedEnd"/>
<sequenceFlow id="toJointReview"
sourceRef="amountGateway"
targetRef="jointReviewSplit">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[
${jointReviewRequired == true}
]]></conditionExpression>
</sequenceFlow>
<sequenceFlow id="toArchiveDirectly"
sourceRef="amountGateway"
targetRef="archiveContract"/>
<sequenceFlow id="toLegal" sourceRef="jointReviewSplit" targetRef="legalReview"/>
<sequenceFlow id="toFinance" sourceRef="jointReviewSplit" targetRef="financeReview"/>
<sequenceFlow id="legalDone" sourceRef="legalReview" targetRef="jointReviewJoin"/>
<sequenceFlow id="financeDone" sourceRef="financeReview" targetRef="jointReviewJoin"/>
<sequenceFlow id="jointDone" sourceRef="jointReviewJoin" targetRef="archiveContract"/>
<sequenceFlow id="archiveDone" sourceRef="archiveContract" targetRef="approvedEnd"/>
</process>
</definitions>这个模型仍需补“法务/财务拒绝”的明确路径。不能因为 Demo 展示主链,就默认商业规则已完整。
十九、业务发起Demo
@Service
public class ContractProcessApplicationService {
private static final BigDecimal JOINT_REVIEW_THRESHOLD =
new BigDecimal("100000.00");
private final ContractRepository contractRepository;
private final RuntimeService runtimeService;
public ContractProcessApplicationService(
ContractRepository contractRepository,
RuntimeService runtimeService) {
this.contractRepository = contractRepository;
this.runtimeService = runtimeService;
}
@Transactional
public String submit(Long contractId, String operatorId) {
Contract contract = contractRepository.findByIdForUpdate(contractId)
.orElseThrow(() -> new IllegalArgumentException("合同不存在"));
if (!contract.canSubmitBy(operatorId)) {
throw new IllegalStateException("无权提交或状态不允许");
}
if (contract.getProcessInstanceId() != null) {
return contract.getProcessInstanceId();
}
boolean jointReviewRequired = contract.getAmount()
.compareTo(JOINT_REVIEW_THRESHOLD) >= 0;
Map<String, Object> variables = new HashMap<>();
variables.put("applicantId", operatorId);
variables.put("jointReviewRequired", jointReviewRequired);
variables.put("ruleVersion", "CONTRACT-2026-07");
ProcessInstance instance = runtimeService.startProcessInstanceByKey(
"contract_approval",
contractId.toString(),
variables);
contract.markApproving(
instance.getProcessInstanceId(),
instance.getProcessDefinitionId());
return instance.getProcessInstanceId();
}
}要点:
- 使用数据库行锁或唯一约束防止同一合同重复启动。
businessKey使用稳定业务 ID。- 业务规则由服务端可信数据计算。
- 保存 processInstanceId 和 processDefinitionId,便于版本排查。
- 金额留在业务表,流程变量只保存决策结果和规则版本。
二十、从业务语言到BPMN的详细步骤
步骤1:写一句流程目标
例如:“保证高金额合同在归档前经过主管、法务和财务授权,并形成可审计记录。”
如果目标是“把所有业务都放进 Activiti”,说明边界尚未想清楚。
步骤2:列业务角色
| 角色 | 能做什么 | 不能做什么 |
|---|---|---|
| 申请人 | 提交、在允许阶段撤回、查看本人单据 | 审批自己的高风险合同 |
| 主管 | 审批本部门合同 | 处理无数据权限的部门合同 |
| 法务 | 审查条款 | 修改合同金额 |
| 财务 | 审查预算和付款条件 | 绕过法务结论 |
| 流程管理员 | 故障纠正和转办 | 无审计地修改业务结论 |
步骤3:列业务事实
金额、合同类型、数据等级、部门、风险结果等。标记来源、类型、是否可变和决策时点。
步骤4:画Happy Path
先只画正常提交、审批、结束,不立即塞入全部撤回和超时。
步骤5:补分支真值表
| managerApproved | jointReviewRequired | 预期路径 |
|---|---|---|
| false | 任意 | 拒绝结束 |
| true | false | 直接归档 |
| true | true | 法务+财务并行后归档 |
步骤6:补异常矩阵
| 节点 | 业务拒绝 | 技术失败 | 超时 | 人员不可用 |
|---|---|---|---|---|
| 主管审批 | 拒绝结束 | 接口重试/保留原任务 | 催办升级 | 候选组转办 |
| 法务审批 | 终止还是等待财务要明确 | 同上 | 催办 | 代理人 |
| 归档Service Task | 不适用 | 异步重试/人工处理 | 超时告警 | 不适用 |
步骤7:定义变量契约
记录名称、Java 类型、产生节点、消费节点、是否敏感、版本兼容期。
步骤8:定义业务状态映射
不要让前端直接根据 Activity ID 猜状态。明确在哪个业务动作更新 APPROVING/APPROVED/REJECTED。
步骤9:定义权限和幂等
每个可操作节点都要说明服务端权限来源、requestId、重复提交结果和并发更新策略。
步骤10:生成路径测试
主路径、每条网关分支、并行部分完成、超时、Delegate失败、重复审批和新旧版本都要有测试。
二十一、流程评审应该有哪些人
| 角色 | 关注点 |
|---|---|
| 业务负责人 | 节点、角色、例外和SLA是否符合规则 |
| 开发 | API、变量、事务、幂等和版本兼容 |
| 测试 | 路径组合、边界值、并发和故障注入 |
| 安全/合规 | 权限分离、敏感变量、审计和保留 |
| 运维/SRE | Job、容量、监控、告警、发布和恢复 |
| 数据/DBA | 表增长、历史归档、索引和对账 |
只让流程设计器作者自审,容易验证“图能保存”,却没有验证商业正确性和生产可运行性。
二十二、模型静态检查清单
- [ ] process key稳定、
isExecutable=true。 - [ ] 每个节点/连线ID唯一且具有业务含义。
- [ ] Start到所有正常End可达,没有孤立节点。
- [ ] 每个User Task有明确候选/分配策略和无人处理兜底。
- [ ] 排他条件互斥完整,并有安全Default Flow。
- [ ] 并行/包容Split和Join结构成对、路径不无故绕过。
- [ ] Service Task实现存在、幂等、超时和失败策略明确。
- [ ] Timer时区、工作日和重复触发规则明确。
- [ ] 边界事件中断/非中断语义正确。
- [ ] Call Activity版本和变量映射明确。
- [ ] Multi-instance集合为空、重复人员和完成条件有定义。
- [ ] Listener/表达式只调用白名单能力。
- [ ] 没有密码、Token、病历或大对象流程变量。
二十三、动态测试应该验证什么
部署成功只证明模型能解析。动态测试至少验证:
- 每种业务输入启动到正确第一任务。
- 候选人能查到,非候选人无法越权完成。
- 每条排他分支都能命中,异常值走安全默认线。
- 并行任务只完成一部分时 Join 保持等待。
- 所有已激活分支完成后下游只生成一次。
- Timer 到期和任务先完成两种竞态结果。
- Delegate 第一次失败、重试成功和永久失败。
- 同一 requestId 重复提交不产生重复审批记录。
- 流程结束后运行时 Task/Execution 清理,历史完整。
- 新定义发布后旧实例仍按旧定义可继续执行。
二十四、模型测试Demo
@SpringBootTest
@Transactional
class ContractApprovalProcessTest {
@Autowired
private RuntimeService runtimeService;
@Autowired
private TaskService taskService;
@Test
void highAmountContractShouldCreateTwoParallelReviews() {
Map<String, Object> variables = new HashMap<>();
variables.put("jointReviewRequired", Boolean.TRUE);
ProcessInstance instance = runtimeService.startProcessInstanceByKey(
"contract_approval",
"contract-test-001",
variables);
Task manager = taskService.createTaskQuery()
.processInstanceId(instance.getId())
.taskDefinitionKey("managerReview")
.singleResult();
assertNotNull(manager);
taskService.complete(manager.getId(), Map.of(
"managerApproved", Boolean.TRUE,
"jointReviewRequired", Boolean.TRUE));
List<Task> reviews = taskService.createTaskQuery()
.processInstanceId(instance.getId())
.list();
Set<String> keys = reviews.stream()
.map(Task::getTaskDefinitionKey)
.collect(Collectors.toSet());
assertEquals(Set.of("legalReview", "financeReview"), keys);
}
}测试代码中的 Map.of 需要 Java 9+。如果项目基线是 JDK 8,应使用 HashMap,不能把文档 Demo 直接复制后误以为 Activiti API 不兼容。
二十五、版本设计必须考虑旧实例
V2 删除了 V1 节点 legalReview,但 V1 仍有 500 个实例停在这个节点。部署 V2 不会把它们自动改到新节点;应用也必须继续支持 V1 任务完成和后续 Delegate。
模型变更分类:
| 变更 | 风险 |
|---|---|
| 只改节点显示名称 | 通常较低,但审计文本变化 |
| 新增仅新实例使用的可选分支 | 中等,要测试变量默认值 |
| 修改变量名称/类型 | 高,旧实例契约可能失效 |
| 修改Activity ID | 高,历史、监控和迁移映射受影响 |
| 改并行分支数量/汇聚结构 | 很高,旧Execution无法自动映射 |
| 删除Delegate/Listener | 很高,旧实例运行到该节点失败 |
详细版本原理见:部署、版本、缓存与生产发布全过程。
二十六、流程图过大怎么办
不要只按节点数量机械拆分。先判断复杂度来源:
- 多个业务子域混在一起:按领域拆 Call Activity。
- 主流程中塞了大量技术调用:把技术实现下沉到幂等 Service。
- 每种组织例外都画一条线:使用稳定角色/规则输出,避免组织细节污染模型。
- 驳回到任意历史节点:收敛为少数受支持业务动作。
- 一个 Process 承担所有业务类型:按真正生命周期拆流程,不靠数百个网关判断类型。
可读性原则:
- 主流程从上到下或从左到右保持一致。
- 一张图只表达一个业务层级。
- Split/Join 命名成对。
- 异常路径使用清晰 End/Boundary Event。
- 复杂说明放文档,不把长段文字塞节点。
本知识库 Mermaid 坚持竖向小图,是为了避免页面溢出;真实 BPMN Modeler 可以横向布局,但仍要控制跨线和嵌套。
二十七、常见反模式
27.1 万能审批流程
一个模型通过 businessType 和几十个网关承载请假、合同、报销、采购。结果是任何业务变更都影响全局,变量和权限契约互相污染。
27.2 前端决定审批人和结果
前端传 assignee=admin、approved=true,服务端不重新校验。攻击者可以绕过权限或修改请求。
27.3 每个业务动作都放Listener
业务逻辑隐藏在几十个 Listener,流程图看不出副作用,事务失败也难以定位。
27.4 把工作流当业务数据库
完整表单、附件、领域状态全放变量,报表直接查 ACT 表。升级、归档、权限和性能都失控。
27.5 任意跳转修复所有问题
管理员输入 Activity ID 强制跳转,却没有处理并行 Token、Task、Job、业务状态和审计。
27.6 只测Happy Path
模型能走通一次就上线,未测试全部条件、超时、并发、重复请求和旧实例。
二十八、商业场景一:医疗数据资产发布
主链:提交资产 → 敏感性识别 → 按风险激活安全/合规/质量审核 → 数据负责人终审 → 发布目录。
设计要点:
- 原始医疗数据不进入流程变量。
- 变量保存
sensitive、crossBorder、qualityRisk和规则版本。 - 审核依据快照存业务审计表,避免后续数据变化无法解释当时决定。
- 发布目录 Service Task 使用
processInstanceId + activityId作为幂等操作键。 - 任一拒绝是否取消其他并行任务必须明确。
- 审核人只能查看经脱敏且授权的数据范围。
二十九、商业场景二:合同审批
设计要点:
- 金额使用 BigDecimal 在业务服务中计算审批级别。
- 法务和财务职责不同,适合两个明确 User Task。
- 盖章和归档是外部副作用,必须幂等、可补偿、可对账。
- 合同正文和附件在文档系统保存,变量只存 contractId、版本和决策字段。
- 合同修改后应产生新业务版本,明确原审批是否失效。
三十、商业场景三:工单SLA
设计要点:
- 创建工单后按类别路由到支持组。
- 24小时非中断Timer催办,72小时升级。
- 用户补充信息可用消息/Receive Task恢复。
- 工单关闭前验证解决结果,重复回调幂等。
- 监控每个 Activity 停留时长,而不是只看流程总时长。
三十一、流程模型发布前后的证据
发布前保存:
- BPMN 文件 Hash、Git commit 和评审记录。
- 变量契约、角色矩阵和分支真值表。
- 静态校验报告和动态测试结果。
- 旧版本运行实例数和兼容矩阵。
- 回退和存量实例处置方案。
发布后记录:
- deploymentId、definitionId、key、version、tenantId。
- 冒烟实例 businessKey 和完整执行结果。
- 新版本实例启动量、错误率和节点停留时间。
- Job 重试、表达式异常和对账差异。
三十二、流程卡住的建模排查
flowchart TD
A["锁定businessKey和processInstanceId"] --> B["确认实例绑定definitionId"]
B --> C["下载该版本BPMN而非看最新图"]
C --> D["查询当前Task、Execution、Job和Event Subscription"]
D --> E["在模型中定位Activity ID"]
E --> F["检查出线条件、并行到达和等待事件"]
F --> G["检查变量名称、类型、值和作用域"]
G --> H["对齐应用异常、事务回滚和业务状态"]没有Task但流程未结束
可能停在 Service Task 异步 Job、Receive Task、Timer Catch Event、子流程或网关汇聚。User Task 表为空不能证明流程结束。
有多个Task
可能是并行网关、多实例或模型重复进入同一节点。先看 Task Definition Key 和 Execution,而不是随便保留一个删除其他任务。
图上应该走A,实际走B
确认实例真实定义版本、流程变量时点和类型。不要用当前业务表值代替当时变量快照。
End到了但业务未完成
普通 End 可能只结束并行中的一条路径;也可能流程结束历史已写入,但业务回写/外部消息跨事务失败。检查 Execution 是否仍存在和一致性方案。
三十三、可观测性设计
建议按低基数维度统计:
- processKey、definitionVersion、activityId。
- Task 创建量、完成量、拒绝量和停留时长分位数。
- 网关各 Sequence Flow 命中数。
- Job 成功、失败、重试和最老年龄。
- 流程实例启动、完成、取消和最老运行年龄。
- 业务状态与流程状态对账差异。
不要把 processInstanceId、businessKey、userId 作为 Prometheus Label。它们适合放结构化日志和 Trace,以免指标高基数爆炸。
三十四、常见误区与后果
| 误区 | 正确理解 | 后果 |
|---|---|---|
| BPMN就是流程图片 | XML语义决定执行,DI只决定显示 | 图能看但不能运行或实际连线不同 |
| User Task自带业务权限 | 候选关系不等于完整数据权限 | 越权审批 |
| Service Task失败引擎会自动解决 | 同步会回滚,异步也需重试/幂等/告警 | 重复副作用或永久卡住 |
| End Event一定结束整个实例 | 普通End可能只结束当前路径 | 并行任务仍在 |
| 并行网关就是多线程 | 它表达Execution路径并发 | 错误容量和事务设计 |
| 多人审批就画很多并行线 | 动态人员更适合Multi-instance | 模型无法维护 |
| Call Activity只是折叠图 | 它调用独立定义并引入版本组合 | 子流程升级影响多个父流程 |
| 流程变量可以放全部表单 | 变量不是业务数据库 | 表膨胀、序列化和敏感数据风险 |
| 上一步永远唯一 | 并行/循环/子流程中可能有多个历史路径 | 动态退回目标错误 |
| 新模型只需测试新实例 | 应用仍要处理旧定义实例 | 旧Delegate/变量运行失败 |
三十五、面试标准回答
BPMN为什么不是普通流程图
BPMN部署的是可执行XML模型,process key、Activity ID、Sequence Flow、条件表达式、候选人和事件都会被引擎解析,并在运行时生成Execution、Task、Job和历史。图形DI只决定显示。模型设计必须同时考虑业务语义、变量契约、事务、权限、版本和故障路径,不能只追求图能打开。
User Task和Service Task有什么区别
User Task到达后创建人工待办,让Execution持久化等待用户认领或完成;Service Task由引擎调用Delegate/表达式自动执行。同步Service Task会拉长当前事务,异步Service Task通过Job解耦但需要幂等、重试和告警。User Task的候选关系也不能替代业务层权限校验。
SubProcess和Call Activity怎么选
Embedded SubProcess属于当前定义,适合折叠局部复杂度并与父流程同版本发布;Call Activity调用独立ProcessDefinition,适合多个流程复用和独立版本,但必须治理父子定义版本组合、变量映射和子流程故障。是否需要独立生命周期比“图太大”更重要。
多人会签怎么设计
动态审批人集合通常用Multi-instance User Task,而不是画固定并行线。要决定并行或串行、人员集合来源和稳定顺序、空集合、重复人员、同意票与完成数区别、completionCondition以及提前结束后剩余任务怎样处理。固定职责不同的法务和财务则更适合并行网关加两个任务。
流程变量为什么不能放完整业务对象
流程变量用于分支和执行上下文,不是业务数据库。大对象会导致运行时和历史表膨胀、序列化兼容问题、敏感数据扩散和查询困难。业务事实、附件、表单和审批快照应保存在业务表,变量只存业务ID、稳定枚举和必要决策字段。
三十六、学习实验与验收
- 编写最小可执行 BPMN,部署后查询定义 key 和 version。
- 去掉 DI,观察执行与建模器显示的区别。
- 创建 User Task,证明 Task 等待时不占用一个持续 Java 请求线程。
- 创建同步 Service Task,注入异常并观察任务完成事务回滚。
- 改成异步边界,观察 Job 产生、失败和重试。
- 建立并行法务/财务审核,证明只完成一个时 Join 等待。
- 建立三人 Multi-instance,会签并验证全票和比例条件。
- 给 User Task 添加非中断 Timer,验证催办不取消原任务。
- 部署 V2 改 Activity ID,分析对历史、监控和旧实例的影响。
- 建立业务表、审批记录和流程变量清单,说明每个字段为什么放在那里。
验收时必须能回答:
- BPMN语义和DI分别做什么?
- Execution为什么不是Task或Java Thread?
- 普通End与Terminate End的作用域区别是什么?
- User Task候选组为什么不能替代业务权限?
- Service Task何时同步、何时异步,失败怎样处理?
- Embedded SubProcess和Call Activity的版本边界是什么?
- Multi-instance完成数量为什么不等于同意数量?
- 流程变量、业务表和审批快照怎样划分?
- 为什么模型发布还要兼容旧Delegate和旧变量?
- 卡住时为什么必须先找实例绑定的definitionId?
