翻到第三篇,BPMN 2.0 这块我得单独拎出来写。前两篇把 Flowable 的工程搭建、几个核心 Service 接口,以及初始化出来的一堆表大致过了一遍,很多人跑到这一步会产生一种错觉:流程能走通了,剩下就是把图拖得好看一点。真到线上出问题才发现,拖拽只是表象,真正决定流程怎么流转、在哪一步卡死、卡死之后从哪张表里把人捞出来的,全是 BPMN 2.0 规范里那些看上去不起眼的元素定义。同样是“审批”这两个字,用排他网关和用并行网关写出来,跑起来是两套完全不同的执行语义;一个 cancelActivity 属性的差别,能让超时之后的分支继续留在原地,也能让它直接消失。
这篇我按“能不能被执行”这条主线拆 BPMN 2.0:先说清楚规范里哪些元素 Flowable 真的会执行、哪些纯粹是画给人看的说明,再逐类拆事件、网关、任务、子流程的实际语义和 XML 写法,最后落到变量作用域、运行期表结构和排查手段上。适合已经能把一个最简流程跑起来、准备把业务里的分支和异常路径真正落地的人;如果你连 ProcessEngine 都还没建起来,建议先把前两篇补完再回来。代码示例我用一个请假审批的模拟流程串起来,XML 片段可以直接粘到自己工程里改。
1. BPMN 2.0 在 Flowable 里到底扮演什么角色
1.1 从图形约定到可执行语义的跨越
BPMN 2.0 本质上是一份建模规范,它做的最关键的一件事,是把“图形元素”和“XML 元素”做了一一映射。你用建模器画出来的每一个方框、每一条箭头、每一个带时钟的小圆圈,在.bpmn20.xml里都有对应的标签。Flowable 的引擎压根不读图,它只读 XML,读完之后生成一个ProcessDefinition对象,运行时再按 XML 里的元素语义一步步推进 execution。图只是 XML 的渲染结果,改图不改 XML 是无效的,反过来手写 XML 不画图也完全能跑。
这个认知很重要,因为很多人调试流程时喜欢盯着图看,图上看一切正常,实际跑起来却走错分支,原因往往是 XML 被建模器改坏了,或者建模器写进去的扩展属性引擎不认。我在实际项目里的习惯是,线上出问题第一件事不是开图,是把ACT_RE_PROCDEF里那条记录的RESOURCE_NAME_对应的 XML 导出来,或者从ACT_GE_BYTEARRAY里把DEPLOYMENT_ID_关联的字节流捞出来看原文。这就是唯一真相,图、文档、需求评审记录都只是参考。
规范里还有个isExecutable属性,写在<process>上。它决定这份流程定义能不能被startProcessInstanceByKey启动。有些团队会用它做“文档流程”和“可执行流程”的区分,把需求评审阶段的流程图部署进去但不允许启动,等确认之后再改成 true 重新部署。这个用法挺实用,但要注意改属性等于换了一个新版本,旧版本的历史实例还是挂在老版本上。
1.2 Flowable 对规范的支持边界在哪里
BPMN 2.0 规范本身非常庞大,包含协作图、编排图、会话图这些偏向跨组织建模的部分。Flowable 只实现了其中面向“可执行流程”的那一块,也就是私有流程(private process)。规范里的编排图(Choreography)、会话图(Conversation)在 Flowable 里是没有运行时语义的,你就算写进去也不会被执行,顶多当注释放着。
在实际可用的子集里,支持程度也分几档。第一档是完全可执行的核心元素:开始/结束事件、中间捕获抛出事件、各类网关、用户任务、服务任务、脚本任务、手工任务、接收任务、业务规则任务、子流程、调用活动、边界事件、补偿。第二档是部分支持或者需要特定配置的,比如事件子流程、多实例的活动、条件事件在特定场景下的触发时机。第三档是纯装饰性的,比如泳道(Lane)、文本注释(TextAnnotation)、数据对象(DataObject),它们会被解析进模型,但不影响执行路径,泳道只是把节点分组显示,不会自动变成任务分配规则。
注意:建模器里那些“看起来很高级”的元素,如果没在引擎的 BpmnXML 解析器白名单里,部署的时候不一定报错,但运行时行为可能和你预期完全不一样。我的做法是,一份流程定义里只用那些自己亲手跑通过的元素,新元素先在测试环境的独立流程定义里单独验证一次再往主流程里加。
支持边界的另一个维度是版本。Flowable 6.x 早期版本对某些事件类型的支持是逐步补齐的,比如升级事件(Escalation)在较新的版本里才完整。你手上是哪一版,最靠谱的方式是去看引擎源码里那一堆*ParseHandler类,类存在并且注册在BpmnParser里,就说明它会被解析成模型,模型里有的东西运行时才有对应行为。这比翻文档快得多。
2. 拆开一份 BPMN XML:命名空间、根节点与部署身份
2.1 definitions 与 process 两个根节点
一份流程定义文件的最小骨架长这样,我把它贴出来,后面所有片段都是往里填:
<?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:flowable="http://flowable.org/bpmn" targetNamespace="http://demo.local/bpmn"> <process id="leaveApply" name="请假申请" isExecutable="true"> <!-- 节点、连线写在这里 --> </process> </definitions>definitions是根,process是它的子元素。命名空间有三个必须写对:BPMN 2.0 的默认命名空间、xsi(因为conditionExpression要用xsi:type="tFormalExpression")、以及flowable扩展命名空间。第三个最容易漏,漏了之后所有flowable:assignee、flowable:class这类扩展属性都会被当成非法属性忽略,任务分配不上人,服务任务也不执行,但部署阶段一句错都不报,排查起来极其费时间。
targetNamespace是个 URI 字符串,不做网络校验,随便写都行,但团队内部最好统一成一个固定值,方便按命名空间过滤流程定义。process上的id就是流程定义 key,name是显示名。这两者的地位不对等:id是程序里startProcessInstanceByKey("leaveApply")的入参,一旦上线就基本不能改,改了等于换了一条全新的流程定义线;name随便改,只影响列表显示。
2.2 流程定义 key、版本与部署资源名
部署动作本身很简单:
Deployment dep = repositoryService.createDeployment() .addClasspathResource("processes/leaveApply.bpmn20.xml") .name("请假流程-20240501") .deploy();引擎在部署时会做两件事:把资源存到ACT_GE_BYTEARRAY,然后解析 XML 往ACT_RE_PROCDEF写一条记录。写记录的时候有个关键规则 —— 同一个 key 如果已经存在,VERSION_自动加一。所以你反复部署同一个文件不会报错,只会得到一个 version=2、3、4 的版本链条,startProcessInstanceByKey默认启动的是 version 最高的那个。
这条规则带来的第一个实操坑是:改错了想回滚,你不能“重新部署旧文件”就完事,那样只会产生一个 version 更大的新定义。真正的回滚是repositoryService.setProcessDefinitionCategory之类的元数据操作没用,得用suspendProcessDefinitionById把新版本挂起,让启动动作回落到老版本。第二个坑是资源文件名,.bpmn20.xml这个后缀不是随便起的,引擎的自动部署扫描器(classpath 扫描、Spring Boot 的flowable.check-process-definitions)认的就是**.bpmn20.xml和**.bpmn两种后缀的文件,起成xxx.bpmn2.xml大概率不会被自动扫到,只能手动addClasspathResource部署。
提示:手动部署和自动扫描混用的时候,
ACT_RE_PROCDEF里会出现同名不同版本的重复定义。我在一个项目里见过因为两个模块各自带了一份同名流程定义,部署顺序不同导致启动时用到的版本不可控。解决办法是从一开始就约定流程定义的 key 前缀带模块名,比如hr_leaveApply、fin_reimburse,物理上隔离掉。
2.3 flowable 扩展命名空间到底扩展了什么
标准 BPMN 2.0 只定义了“元素是什么”,没定义“人怎么分配”“类怎么调用”这些工程细节,所以各家引擎都在extensionElements里塞自己的东西。Flowable 的做法是把常用属性直接做成flowable:前缀的 XML 属性,把复杂配置塞进<extensionElements>子节点。
前者包括flowable:assignee、flowable:candidateUsers、flowable:candidateGroups、flowable:formKey、flowable:dueDate、flowable:priority、flowable:class、flowable:expression、flowable:delegateExpression、flowable:async、flowable:exclusive、flowable:resultVariable、flowable:skipExpression。这些是高频使用的,写在元素属性位置上。
后者包括执行监听器和任务监听器:
<userTask id="usertask_approve" name="主管审批" flowable:assignee="${approver}"> <extensionElements> <flowable:taskListener event="create" class="com.demo.listener.ApproveTaskCreateListener"/> <flowable:taskListener event="complete" delegateExpression="${completeListener}"/> </extensionElements> </userTask>这里有个容易混淆的点:监听器可以用class(必须有无参构造,由引擎 new 出来)也可以用delegateExpression(从容器里拿 Bean,能注入依赖)。只要监听器里需要用到数据库或者别的 Bean,就必须走delegateExpression,用class拿到的实例是引擎自己创建的,Spring 容器管不到它,注入全是 null。这个坑我踩过一次,日志里一直 NPE,最后发现是监听器里的 mapper 字段没被注入。
3. 事件体系:定时器、边界、错误与信号的落地用法
3.1 定时器事件与异步执行器的配合
定时器是 BPMN 里最容易“看起来配了但其实没跑”的元素。写法有三种,对应三种语义:
<!-- 等待一段固定时长 --> <intermediateCatchEvent id="timer_wait"> <timerEventDefinition><timeDuration>PT30M</timeDuration></timerEventDefinition> </intermediateCatchEvent> <!-- 固定时间点触发,支持 ISO 8601 和 cron --> <timerEventDefinition><timeDate>2024-06-01T09:00:00</timeDate></timerEventDefinition> <!-- 循环触发,R3 表示重复 3 次,R 表示无限 --> <timerEventDefinition><timeCycle>R3/PT10H</timeCycle></timerEventDefinition>ISO 8601 的时长写法新手经常写错:PT30M是 30 分钟,P1D是一天,PT1H30M是一小时三十分钟。注意M在日期段是月、在时间段是分钟,写P1M是一个月不是一分钟,这种细节错了不报错,只是时间不对。
定时器触发靠的是异步执行器扫ACT_RU_TIMER_JOB表。也就是说,流程定义里写了定时器,但引擎配置里asyncExecutorActivate=false(或者 Spring Boot 里flowable.async-executor-activate没开),这个定时器永远不会触发,流程会安安静静地卡在那儿,ACT_RU_EXECUTION里有个等待状态的执行、ACT_RU_TIMER_JOB里有一条DUEDATE_已经过期的记录,但就是没人处理它。我第一次遇到这个问题时查了半天 XML,最后发现是执行器没开。
定时器还可以用表达式动态算时间:<timeDuration>${approveTimeout}</timeDuration>,变量里放PT2H这样的字符串。这种写法在需要按业务配置调整超时时长时很好用,但要注意变量必须在进入该节点之前就已经存在于流程变量里,否则表达式求值会抛异常,而且是在事务里抛,整个节点推进都会回滚。
3.2 边界事件与中断/非中断语义
边界事件挂在活动上,监听这个活动运行期间发生的“外部事情”。最关键的一个属性是cancelActivity,它决定事件触发后原来的活动是继续还是被干掉。
<userTask id="task_approve" name="主管审批" flowable:assignee="${approver}"/> <boundaryEvent id="bd_timeout" attachedToRef="task_approve" cancelActivity="true"> <timerEventDefinition><timeDuration>PT48H</timeDuration></timerEventDefinition> </boundaryEvent> <sequenceFlow id="flow_timeout" sourceRef="bd_timeout" targetRef="task_escalate"/>cancelActivity="true"是中断型,超时之后原来的用户任务从ACT_RU_TASK里被删掉,流程沿边界事件的出口继续走,通常接到一个“升级处理”的任务上。cancelActivity="false"是非中断型,原任务还在待办列表里,同时边界事件的出口也会走一条并行分支出来,相当于“催办”,人可以继续办,系统另外发个提醒。这两种语义在业务上的差别极大,选错就是事故:审批单已经超时升级给上级了,下级还能点通过,数据就乱了。
边界事件可以挂的类型不少:定时器、错误、信号、消息、条件、补偿。挂多个也允许,同一个任务上既挂 48 小时超时又挂 72 小时超时是完全合法的,引擎按触发先后各自处理。但同一个任务上挂两个相同类型的定时器边界事件时,要留意哪个先到,因为中断型触发后任务就没了,另一个定时器会被清掉。
3.3 错误事件与信号事件的选择
错误事件(Error)针对的是“当前这条流程内部出了业务异常”;信号事件(Signal)针对的是“广播一件事,谁关心谁接”。这两者的语义差别决定了用法。
错误事件通常是这么配的:先在definitions下声明错误码,再在服务任务上挂错误边界事件。
<error id="ERR_BIZ" errorCode="BIZ_001"/> <serviceTask id="svc_call" name="调用外部接口" flowable:delegateExpression="${callDelegate}"/> <boundaryEvent id="bd_err" attachedToRef="svc_call" cancelActivity="true"> <errorEventDefinition errorRef="ERR_BIZ"/> </boundaryEvent>服务任务的 Java 实现里抛BpmnError("BIZ_001"),引擎就会捕获并沿错误边界事件走。注意是BpmnError不是普通RuntimeException,普通异常不会走错误边界事件,而是直接把当前事务标记为失败,流程状态回滚。这个区别是新手最容易搞错的地方,我见过有人抛了IllegalArgumentException,然后疑惑为什么没进错误处理分支。
信号事件是全局广播,runtimeService.signalEventReceived("SOME_SIGNAL")会把所有订阅了这个信号的执行都推进一步。这个能力很强大,但也危险 —— 生产环境里一个错误的广播可能同时推进上百个流程实例的几百个执行。所以我的习惯是,能用消息事件就用消息事件,信号只用在真正需要“一对多广播”的场景,比如“系统参数变更,所有等待中的流程重新计算”。消息事件(Message)的语义是点对点,需要指定接收方,粒度更可控。
4. 网关分支:排他、并行、包容、事件网关的语义差异
4.1 排他网关的默认流与条件判定顺序
排他网关(ExclusiveGateway)是最常用的,语义是“多条出口里选一条”。写法上有个default属性指定默认流:
<exclusiveGateway id="gw_check" name="天数判断" default="flow_reject"/> <sequenceFlow id="flow_pass" sourceRef="gw_check" targetRef="task_pass"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${days <= 3}]]> </conditionExpression> </sequenceFlow> <sequenceFlow id="flow_reject" sourceRef="gw_check" targetRef="task_leader"/>判定顺序是按 XML 里sequenceFlow的书写顺序逐个求值,第一个为 true 的胜出。所以有多个条件时,顺序本身就是业务逻辑的一部分,别指望引擎帮你做优先级排序。
default属性非常重要。如果所有条件都不满足又没有默认流,引擎会抛No outgoing sequence flow of the exclusive gateway could be selected,这个异常会打断整个流程推进。我一般强制规定团队里所有排他网关必须配默认流,默认流指向一个“异常兜底”的节点或者直接结束,让流程至少不会卡在一个无解的状态上。这个约定看起来啰嗦,但在变量缺失、类型不匹配这类情况下能救命。
CDATA 包裹表达式也是个必须养成的习惯。因为<=、<这些符号在 XML 里是保留字符,不包 CDATA 的话解析器直接报错。有人喜欢写成<=,能跑,但可读性差,接手的人容易看漏。
4.2 并行网关的汇聚等待与“卡死”
并行网关(ParallelGateway)不需要条件,进来就 fork 出所有出口分支,每个分支一个执行(execution)。汇聚点时,它会等所有入线都到达才继续。这个“等都到达”的机制是靠统计入线数量实现的,所以一旦有分支没到达,汇聚点就永远等下去。
这就是并行网关最常见的坑:
<parallelGateway id="gw_fork"/> <parallelGateway id="gw_join"/> <sequenceFlow id="f1" sourceRef="gw_fork" targetRef="task_a"/> <sequenceFlow id="f2" sourceRef="gw_fork" targetRef="sub_b"/> <sequenceFlow id="j1" sourceRef="task_a" targetRef="gw_join"/> <sequenceFlow id="j2" sourceRef="sub_b" targetRef="gw_join"/>如果sub_b这个子流程内部有个中断型边界事件触发了,那条分支的执行就被销毁,不会流到gw_join。此时task_a分支到达汇聚点,引擎一统计数据发现只到了 1 条,还差 1 条,于是挂起等待。整个流程实例就停在gw_join前面,ACT_RU_EXECUTION里能看到一条IS_ACTIVE_=1但没任务、没 job 的执行记录,俗称“卡死”。
排查这种问题的思路很固定:先看这个流程实例在ACT_RU_EXECUTION里还剩几个执行、PARENT_ID_关系是什么,再对照 XML 数入线数量,基本立刻能定位到哪条分支消失了。想避免的话,要么在支线里用非中断型边界事件,要么在并行分支里不要放会“吃掉”执行的结构(比如中断边界事件、终止结束事件),要么干脆换成包容网关或者用业务状态机兜底。
4.3 包容网关与事件网关的适用场景
包容网关(InclusiveGateway)是排他和并行的混合体:出口条件为 true 的分支都会走(这点像并行),汇聚时等所有“实际被激活的”入线到达(这点比并行聪明)。它解决了并行网关“一条分支不走也会一直等”的问题,代价是运行时判断逻辑更重,要记录哪些入线被激活过。
实际项目里我很少用包容网关,因为它对分支的追踪依赖运行期状态,流程一旦复杂,排查成本比并行网关高得多。更常见的做法是用排他网关做前提判断,再用并行网关做固定分支,宁可多写两个节点,也别搞太复杂的网关组合。
事件网关(EventGateway)是另一种思路:后面的每条出口都是一个捕获事件,谁先触发就走谁。典型场景是“多个结果里等第一个回来”:接口回调、人工审批、超时兜底三个事件挂在同一个事件网关上,谁先发生走谁,其余订阅被清理。写法上每个出口后面必须直接跟一个捕获事件(中间捕获事件或者接收任务),不能直接连普通任务。这个限制经常被忽略,导致部署时解析报错。
事件网关的好处是把“竞态”显式建模出来了,代码可读性比在 Java 里用一堆 Future 好很多。但要注意它的instantiate属性,默认 false 表示如果流程是从这个网关之前启动的,没问题;如果希望流程实例从这个网关后面以启动方式进入,需要设成 true 并配合开始事件,这个用法比较冷门,一般不用管。
5. 任务与子流程:用户任务、服务任务、调用活动
5.1 用户任务的分配策略与候选组
用户任务(UserTask)是流程和人的交界,分配策略决定了待办列表长什么样。三种写法:
<!-- 直接指定单人 --> <userTask id="t1" name="直属主管审批" flowable:assignee="${directLeader}"/> <!-- 候选组,组内任意一人可认领 --> <userTask id="t2" name="部门会签" flowable:candidateGroups="dept_manager,hr_bp"/> <!-- 候选人多选一 --> <userTask id="t3" name="技术评审" flowable:candidateUsers="1001,1002,1003"/>assignee存的是用户 ID 字符串,引擎不做校验,这个 ID 在不在你的用户表里它不管。候选组存在ACT_RU_IDENTITYLINK表里,TYPE_='candidate'。查询待办时用taskCandidateOrAssigned(userId)同时覆盖“分给我的”和“我所在组可以认领的”,只查taskAssignee会漏掉候选组那一批。
有个细节值得说:flowable:candidateGroups里可以用表达式${deptCodes},如果变量是个 List 或者逗号分隔的字符串,引擎会解析成多个候选组。这个能力在“按部门动态分配”时很省事,不用在监听器里手动调taskService.addCandidateGroup。但变量类型要留意,传个 Set 进去在某些版本里解析不出来,稳妥起见统一转成String.join(",", list)。
用户任务还有几个常配的属性:flowable:dueDate(到期时间,支持表达式和 ISO 时长)、flowable:priority(数字,越大越优先)、flowable:formKey(关联外部表单的标识,具体含义由你的前端决定)。到期时间在ACT_RU_TASK.DUE_DATE_里存着,配合定时查询可以做超期提醒,但它本身不会触发任何流程动作,要实现“超期自动升级”还是得用前面说的定时器边界事件。
5.2 服务任务的四种实现方式
服务任务(ServiceTask)是流程调用业务代码的标准入口,Flowable 支持的方式主要有四种,选型逻辑很清晰:
| 方式 | 写法 | 适用场景 | 关键限制 |
|---|---|---|---|
| Java 类 | flowable:class="com.demo.XxxDelegate" | 无外部依赖的纯逻辑 | 引擎 new 实例,无法注入容器 Bean |
| 委托表达式 | flowable:delegateExpression="${xxxDelegate}" | 需要注入 Service/Mapper | Bean 必须实现 JavaDelegate 或 ActivityBehavior |
| 方法表达式 | flowable:expression="${svc.doIt(execution)}" | 一行能调完的简单动作 | 参数只能是 execution 或流程变量 |
| 外部任务 | flowable:type="external" | 跨语言、跨进程处理 | 需要外部客户端轮询ACT_RU_EXT_TASK |
实际项目里九成以上的服务任务用delegateExpression,因为它天然贴合 Spring 的依赖注入,执行类里的@Autowired都能用。expression适合那种“调一下对个状态”的轻动作,比如${notifyService.send(execution.getVariable('userId'))},但表达式写复杂了就成了不可调试的黑盒,我在评审时看到超过三个方法调用链的expression一律要求改成 Delegate。
flowable:class那个坑我前面提过,再强调一次:引擎用反射创建实例,不走 Spring 容器。用它写出来的 Delegate 里所有注入字段都是 null,只有在完全不依赖外部组件的时候才考虑。
服务任务的返回值可以用flowable:resultVariable收进流程变量:
<serviceTask id="svc_calc" name="计算可休天数" flowable:delegateExpression="${leaveCalcDelegate}" flowable:resultVariable="availableDays"/>Delegate 的execute方法里通过execution.setVariable设置也行,用resultVariable更直观一点。投射到流程变量的值最好保持基本类型或可序列化的简单对象,塞一个复杂 DTO 进去会让ACT_GE_BYTEARRAY里的序列化数据变得难以排查,跨版本升级时还容易反序列化失败。
5.3 子流程与调用活动的变量传递
嵌入子流程(SubProcess)写在当前流程定义内部,共享主流程的变量作用域,本质上只是节点的分组,方便把一块逻辑折叠起来看。调用活动(CallActivity)才是真正“调用另一个流程定义”的机制,calledElement指定被调流程的 key,默认调用最新版本。
<callActivity id="call_check" name="调用风控校验" calledElement="riskCheck" flowable:inheritVariables="true" flowable:fallbackToDefaultTenant="false"> <extensionElements> <flowable:in source="orderId" target="bizOrderId"/> <flowable:out source="riskLevel" target="orderRiskLevel"/> </extensionElements> </callActivity>inheritVariables="true"表示把父流程的全部变量带进去,默认就是 true。这在变量多的时候很方便,但也是个隐患 —— 子流程能改到的变量范围太大,容易在子流程里不小心覆盖父流程的变量,然后主流程的分支判断就错了。我的建议是关掉自动继承,用<flowable:in>显式声明输入、<flowable:out>显式声明输出,父子之间只有这几个变量往来,边界清楚,出问题也好定位。
调用活动的变量作用域是个独立的流程实例,父流程会挂起等待子流程结束。子流程有自己的一条ACT_HI_PROCINST记录,SUPER_PROCESS_INSTANCE_ID_指向父实例。这个设计在做“主流程 + 多个可复用子流程”的架构时非常合适,比如把审批、风控、支付这些通用环节各自做成独立流程定义,主流程只负责编排。代价是查询时要多一层关联,看历史数据不能只看一张表。
6. 变量、表达式与监听器:流程里的“数据面”
6.1 变量作用域的三个层次
流程变量是控制流和业务数据的粘合剂,但作用域经常被搞混。大致分三层:流程实例级、执行(execution)级、任务(task)级。
// 流程实例级,整条流程都能读到 runtimeService.setVariable(processInstanceId, "amount", 100); // 当前执行所在作用域 runtimeService.setVariable(executionId, "amount", 100); // 仅当前执行可见,执行结束即消失 runtimeService.setVariableLocal(executionId, "tmpFlag", true); // 任务级,任务完成后销毁 taskService.setVariableLocal(taskId, "opinion", "同意");setVariable和setVariableLocal的区别是,前者会向上查找已经存在同名变量的作用域并更新它,找不到才在当前执行上创建;后者强制在当前作用域创建。并行网关 fork 出来的每个分支都是独立的执行,如果在分支里用setVariableLocal,另一个分支读不到这个变量;用setVariable则会写到共同的父作用域,两个分支都能看到。这个差异在多实例任务、并行分支里是实打实的坑。
任务级 local 变量的生命周期要特别注意。审批意见这种“每个任务各不相同”的数据,用setVariableLocal存最合适,任务完成后变量也一起销毁。如果误用setVariable,多个审批节点的意见会互相覆盖,最后只留最后一个 ——ACT_HI_VARINST里看历史也只剩一条记录,想追溯谁说了什么就没数据了。要追溯历史意见,其实更该用taskService.addComment,评论会进ACT_HI_COMMENT,天然按任务分组。
6.2 UEL 表达式与类型陷阱
Flowable 的表达式引擎是 UEL,在conditionExpression、assignee、serviceTask参数里到处都在用。基本语法不复杂,${}和#{}都能用,${}是立即求值,#{}是延迟求值,实际在 Flowable 里行为差别不大,团队里统一一种就行。
真正的问题在类型。流程变量存在数据库里,反序列化回来之后可能是String,而你的表达式写的是数字比较:${days > 3}。如果days是字符串"5",UEL 会做类型转换,大多数情况下能正确比较;但如果变量是空值,表达式会静默返回 false 而不是报错,于是流程安安静静地走了默认流,谁也没发现。更隐蔽的是布尔判断:${approved}在approved为 null 时不报错,返回 false,看起来一切正常。
我的处理习惯是所有可能为空的变量在表达式里显式兜底:${days != null && days > 3}。虽然啰嗦,但至少行为是可预期的。另一条经验是流程变量尽量用基本类型,日期统一存java.util.Date或者 ISO 字符串,别在流程变量里塞 Lambda、Stream 这些东西,序列化之后就是一团乱麻。
表达式还能引用 Spring Bean:${leaveService.calcDays(execution)}。这个能力让 XML 里能直接写业务逻辑,但会带来另一个问题 —— 流程定义和 Java 代码耦合,改个方法名就可能导致线上流程跑不动,而且报错信息是“Unknown property used in expression”,看不出是哪个 Bean。用这种方法时建议 Bean 名称加上明确前缀,方法保持幂等,别在表达式里做写操作。
6.3 监听器埋点与日志规范
监听器是我在项目里用得最多的扩展点,本质上是给流程执行的关键节点埋钩子。执行监听器挂在流程、活动、连线、网关上都行,事件有 start、end、take 三种;任务监听器挂在用户任务上,事件有 create、assignment、complete、delete、update、timeout。
public class ApproveTaskCreateListener implements TaskListener { @Override public void notify(DelegateTask task) { String procInstId = task.getProcessInstanceId(); // 记录任务创建时间、分配对象,方便后续 SLA 统计 } }用途大致三类:埋点统计(每个节点耗时)、数据补全(任务创建时把业务单据的摘要信息写进任务本地变量,列表页就不用再查业务表)、外部通知(任务创建时发消息)。第三类要特别注意事务边界 —— 监听器在流程事务里执行,如果你在这里面做远程调用,接口超时会把整个流程推进拖住,甚至导致事务回滚。稳妥做法是只往消息表里插一条待发送记录,由独立的定时任务异步发出去。
注意:监听器里不要调用
taskService.complete()或者runtimeService.trigger()这类会推进流程的 API,会造成递归或者执行状态错乱。曾经有个项目在 complete 监听器里又去 complete 当前任务,结果ACT_RU_TASK里出现了重复记录,排查了大半天。
日志方面,我一般要求监听器里输出的日志带上processInstanceId、taskId、节点 id 三个字段,方便用 ELK 之类的工具按流程实例串起来看。没有这三个字段的日志,在几百个并发实例的场景下基本等于没有。
7. 运行期数据与排查:从 ACT_RU_* 到 ACT_HI_*
7.1 一次节点流转写了哪些表
理解表结构比理解 API 更能解决问题。一次“完成任务”的动作,引擎大致会做这些写入:
ACT_RU_TASK里删掉当前任务记录,ACT_HI_TASKINST里更新这条任务的END_TIME_;ACT_RU_EXECUTION里当前执行的ACT_ID_指向下一个节点,如果经过并行网关则插入新执行、设置PARENT_ID_关系;ACT_HI_ACTINST插入新节点的活动实例;如果新节点是用户任务,往ACT_RU_TASK插入一条新任务,同时可能往ACT_RU_IDENTITYLINK插入候选组记录;如果有定时器边界事件,往ACT_RU_TIMER_JOB插入一条 job;如果节点带flowable:async="true",往ACT_RU_JOB插入一条异步 job。
排查时按这条链路倒着找就行。待办不出现,先查ACT_RU_TASK有没有记录,没有就看ACT_RU_EXECUTION卡在哪个ACT_ID_,再看ACT_RU_JOB和ACT_RU_TIMER_JOB有没有待处理的 job,最后看ACT_RU_EVENT_SUBSCR有没有没被触发的事件订阅。这四张表基本覆盖了所有“流程不动了”的情况。
以下是几条我常用的查询,直接抄:
-- 看某个业务单据的流程实例走到哪了 SELECT ID_, PROC_DEF_ID_, BUSINESS_KEY_, START_TIME_, END_TIME_, DELETE_REASON_ FROM ACT_HI_PROCINST WHERE BUSINESS_KEY_ = 'LEAVE-20240501-001'; -- 看实例当前的活动节点和活跃执行 SELECT ID_, PARENT_ID_, ACT_ID_, IS_ACTIVE_, IS_CONCURRENT_, IS_SCOPE_ FROM ACT_RU_EXECUTION WHERE PROC_INST_ID_ = '实例ID'; -- 看待办和候选人 SELECT t.ID_, t.NAME_, t.ASSIGNEE_, t.CREATE_TIME_ FROM ACT_RU_TASK t WHERE t.PROC_INST_ID_ = '实例ID'; SELECT * FROM ACT_RU_IDENTITYLINK WHERE TASK_ID_ = '任务ID' AND TYPE_ = 'candidate'; -- 看有没有卡住的定时器或异步任务 SELECT * FROM ACT_RU_TIMER_JOB WHERE PROCESS_INSTANCE_ID_ = '实例ID'; SELECT * FROM ACT_RU_JOB WHERE PROCESS_INSTANCE_ID_ = '实例ID';历史数据能不能查,取决于historyLevel。默认是audit,记录流程实例、活动实例、任务实例,但流程变量只记录一部分;full才记录全部变量变更;none什么都不记。生产环境我一般建议audit起步,需要审计追溯再上full,full的写入量在高峰时会明显拖慢流程推进,这点要提前评估。
7.2 常见问题速查表
| 现象 | 大概率原因 | 排查入口 | 处理方式 |
|---|---|---|---|
| 排他网关抛 No outgoing sequence flow | 条件全不满足且没配 default | 异常堆栈 + XML 条件 | 补 default 流,或修条件表达式 |
| 条件判断总是走默认分支 | 变量为 null 或类型不符 | 查ACT_HI_VARINST里变量值 | 表达式加非空判断,统一变量类型 |
| 流程停在并行网关不动 | 某分支被中断删除,join 等不到 | ACT_RU_EXECUTION数剩余执行 | 支线改非中断边界事件,或调整结构 |
| 定时器不触发 | 异步执行器未开启 / 定义被挂起 | ACT_RU_TIMER_JOB的DUEDATE_ | 开启执行器,检查定义状态 |
| 用户任务查不到 | 候选组 ID 与用户组不匹配 | ACT_RU_IDENTITYLINK | 核对组 ID,用taskCandidateOrAssigned |
| 服务任务不执行 | 漏了 flowable 命名空间 | 导出的 XML 原文 | 补xmlns:flowable |
| 监听器里字段全是 null | 用了class而非delegateExpression | 监听器实例来源 | 改成委托表达式 |
| 历史表查不到数据 | historyLevel=none或流程被删除 | ACT_HI_PROCINST | 调整历史级别,用deleteReason区分 |
| 流程定义版本混乱 | 多模块重复部署同名 key | ACT_RE_PROCDEF按 key 排序 | key 加模块前缀,统一部署入口 |
| 中文节点名乱码 | XML 声明缺 encoding 或库字符集不对 | 数据库连接与表字符集 | XML 首行声明 UTF-8,库用 utf8mb4 |
这张表里的每一条我都在不同项目里碰到过至少一次,其中“并行网关卡死”和“定时器不触发”是排查耗时最长的两类,因为现象都是“流程不动了”,但根因完全不在一个层面。
8. 几个我实际踩过的坑与参数建议
8.1 异步执行器的参数必须按量算,不能吃默认值
先说个具体数字。以 6.x 的默认配置为例,异步执行器核心线程数和最大线程数都是 8,队列 100;定时任务的获取间隔asyncExecutorDefaultTimerJobAcquireWaitTime默认 10 秒,单次获取条数asyncExecutorMaxTimerJobsPerAcquisition默认是 1。把这两个默认值乘一下:单个节点每分钟最多捞 6 条定时任务。如果你有 1000 个待触发的定时器,全部处理完要将近 167 分钟。这意味着一个“2 小时超时提醒”的功能,实际可能延迟到 4 小时才发出去,业务方会直接找上门。
调整思路是两个方向:降低获取间隔、提高单次获取条数。间隔降到 1 到 2 秒,单次获取提到 50 到 100,吞吐量立刻上一个量级。同时要保证线程池够用,asyncExecutorCorePoolSize至少要能覆盖“单批任务的平均执行耗时 × 每秒批次数”,这个只能压测出来,拍脑袋容易把数据库打满。我的经验值是在中等规模(日流程实例万级)下,核心线程 16、最大 32、队列 200、获取间隔 2 秒、单次获取 50 条,这个配置跑下来定时器延迟基本在秒级。
另一个相关配置是flowable:async="true"和flowable:exclusive="true"。异步节点会在事务提交后才被执行,好处是把长耗时节点从主事务里摘出去,坏处是异常不会立刻反馈给发起方,得靠 job 的重试机制和死信表(ACT_RU_DEADLETTER_JOB)兜底。exclusive保证同一流程实例的异步任务串行执行,避免并发冲突,代价是牺牲一点并行度。我的建议是:涉及同一实例内状态变更的异步节点一律exclusive="true",纯外部调用的可以放开。
8.2 流程定义变更与运行中实例的处理
流程定义改了、重新部署会产生新版本,这本身没问题,问题在于运行中的老实例还挂在老版本上。这时候业务方会问:“我改的那个审批节点,为什么已经在跑的流程没生效?”答案是引擎的设计就是这样,新版本只对新启动的实例生效,这是为了保证运行中流程的语义一致性。
处理方式有几种。最省事的是清空重来:测试环境直接把运行中实例全删了重新发起。生产环境不能这么干,得用流程实例迁移(Process Instance Migration),把老版本的实例迁到新版本,同时做节点映射:
ProcessInstanceMigrationBuilder builder = runtimeService.createProcessInstanceMigrationBuilder() .migrateToProcessDefinition(targetDefId) .addActivityMigrationMapping("oldTaskId", "newTaskId"); runtimeService.createProcessInstanceMigrationBuilder() .fromProcessInstance(procInstId) .migrateToProcessDefinition(targetDefId) .migrate();迁移不是万能的。老实例当前所在的节点如果在新版本里被删了,就必须提供映射关系,否则迁移会失败。而且迁移只改流程结构,不改变量,如果新版本的表达式依赖了新变量,迁移过去的老实例一样会报Unknown property。所以我的做法是:结构性的变更(增删节点、改网关条件)尽量放在业务低峰期,迁移前先跑一遍预检,把会因为变量缺失报错的实例挑出来,补数据再迁。补不了的,就让它在老版本上跑完,别硬迁。
还有个小细节值得记一下:挂起流程定义(suspendProcessDefinitionById)时,第三个参数includeProcessInstances决定是否连运行中实例一起挂起。这个参数设 true 之后,正在跑的实例会停在原地,等你激活再继续。有些人用它来做“紧急止血”,但忘了激活,结果第二天发现一堆流程停着不动。这种操作最好在变更记录里写清楚,别只靠脑子记。
最后聊个我自己的体会。BPMN 2.0 这套东西看文档的时候觉得元素太多记不住,但真正在项目里反复用的其实就那么十来个:用户任务、服务任务、排他网关、并行网关、定时器边界事件、错误边界事件、子流程。把这十来个元素的语义、XML 写法、对应的运行期表记录吃透,剩下的那些冷门元素遇到再查都来得及。反倒是变量作用域和事务边界这两块基础知识,必须在动手写第一个流程之前就搞清楚,因为它们踩坑之后的表现都很隐蔽 —— 流程不报错,数据就是不对,查起来比报错难受十倍。