首页 / 资讯中心 / 文章详情

Flowable BPMN 2.0可执行语义与运行期排障实战

Flowable BPMN 2.0可执行语义与运行期排障实战 ★ FEATURED ARTICLE
翻到第三篇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 version1.0 encodingUTF-8? definitions xmlnshttp://www.omg.org/spec/BPMN/20100524/MODEL xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xmlns:flowablehttp://flowable.org/bpmn targetNamespacehttp://demo.local/bpmn process idleaveApply name请假申请 isExecutabletrue !-- 节点、连线写在这里 -- /process /definitionsdefinitions是根process是它的子元素。命名空间有三个必须写对BPMN 2.0 的默认命名空间、xsi因为conditionExpression要用xsi:typetFormalExpression、以及flowable扩展命名空间。第三个最容易漏漏了之后所有flowable:assignee、flowable:class这类扩展属性都会被当成非法属性忽略任务分配不上人服务任务也不执行但部署阶段一句错都不报排查起来极其费时间。targetNamespace是个 URI 字符串不做网络校验随便写都行但团队内部最好统一成一个固定值方便按命名空间过滤流程定义。process上的id就是流程定义 keyname是显示名。这两者的地位不对等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_自动加一。所以你反复部署同一个文件不会报错只会得到一个 version2、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 idusertask_approve name主管审批 flowable:assignee${approver} extensionElements flowable:taskListener eventcreate classcom.demo.listener.ApproveTaskCreateListener/ flowable:taskListener eventcomplete delegateExpression${completeListener}/ /extensionElements /userTask这里有个容易混淆的点监听器可以用class必须有无参构造由引擎 new 出来也可以用delegateExpression从容器里拿 Bean能注入依赖。只要监听器里需要用到数据库或者别的 Bean就必须走delegateExpression用class拿到的实例是引擎自己创建的Spring 容器管不到它注入全是 null。这个坑我踩过一次日志里一直 NPE最后发现是监听器里的 mapper 字段没被注入。3. 事件体系定时器、边界、错误与信号的落地用法3.1 定时器事件与异步执行器的配合定时器是 BPMN 里最容易“看起来配了但其实没跑”的元素。写法有三种对应三种语义!-- 等待一段固定时长 -- intermediateCatchEvent idtimer_wait timerEventDefinitiontimeDurationPT30M/timeDuration/timerEventDefinition /intermediateCatchEvent !-- 固定时间点触发支持 ISO 8601 和 cron -- timerEventDefinitiontimeDate2024-06-01T09:00:00/timeDate/timerEventDefinition !-- 循环触发R3 表示重复 3 次R 表示无限 -- timerEventDefinitiontimeCycleR3/PT10H/timeCycle/timerEventDefinitionISO 8601 的时长写法新手经常写错PT30M是 30 分钟P1D是一天PT1H30M是一小时三十分钟。注意M在日期段是月、在时间段是分钟写P1M是一个月不是一分钟这种细节错了不报错只是时间不对。定时器触发靠的是异步执行器扫ACT_RU_TIMER_JOB表。也就是说流程定义里写了定时器但引擎配置里asyncExecutorActivatefalse或者 Spring Boot 里flowable.async-executor-activate没开这个定时器永远不会触发流程会安安静静地卡在那儿ACT_RU_EXECUTION里有个等待状态的执行、ACT_RU_TIMER_JOB里有一条DUEDATE_已经过期的记录但就是没人处理它。我第一次遇到这个问题时查了半天 XML最后发现是执行器没开。定时器还可以用表达式动态算时间timeDuration${approveTimeout}/timeDuration变量里放PT2H这样的字符串。这种写法在需要按业务配置调整超时时长时很好用但要注意变量必须在进入该节点之前就已经存在于流程变量里否则表达式求值会抛异常而且是在事务里抛整个节点推进都会回滚。3.2 边界事件与中断/非中断语义边界事件挂在活动上监听这个活动运行期间发生的“外部事情”。最关键的一个属性是cancelActivity它决定事件触发后原来的活动是继续还是被干掉。userTask idtask_approve name主管审批 flowable:assignee${approver}/ boundaryEvent idbd_timeout attachedToReftask_approve cancelActivitytrue timerEventDefinitiontimeDurationPT48H/timeDuration/timerEventDefinition /boundaryEvent sequenceFlow idflow_timeout sourceRefbd_timeout targetReftask_escalate/cancelActivitytrue是中断型超时之后原来的用户任务从ACT_RU_TASK里被删掉流程沿边界事件的出口继续走通常接到一个“升级处理”的任务上。cancelActivityfalse是非中断型原任务还在待办列表里同时边界事件的出口也会走一条并行分支出来相当于“催办”人可以继续办系统另外发个提醒。这两种语义在业务上的差别极大选错就是事故审批单已经超时升级给上级了下级还能点通过数据就乱了。边界事件可以挂的类型不少定时器、错误、信号、消息、条件、补偿。挂多个也允许同一个任务上既挂 48 小时超时又挂 72 小时超时是完全合法的引擎按触发先后各自处理。但同一个任务上挂两个相同类型的定时器边界事件时要留意哪个先到因为中断型触发后任务就没了另一个定时器会被清掉。3.3 错误事件与信号事件的选择错误事件Error针对的是“当前这条流程内部出了业务异常”信号事件Signal针对的是“广播一件事谁关心谁接”。这两者的语义差别决定了用法。错误事件通常是这么配的先在definitions下声明错误码再在服务任务上挂错误边界事件。error idERR_BIZ errorCodeBIZ_001/ serviceTask idsvc_call name调用外部接口 flowable:delegateExpression${callDelegate}/ boundaryEvent idbd_err attachedToRefsvc_call cancelActivitytrue errorEventDefinition errorRefERR_BIZ/ /boundaryEvent服务任务的 Java 实现里抛BpmnError(BIZ_001)引擎就会捕获并沿错误边界事件走。注意是BpmnError不是普通RuntimeException普通异常不会走错误边界事件而是直接把当前事务标记为失败流程状态回滚。这个区别是新手最容易搞错的地方我见过有人抛了IllegalArgumentException然后疑惑为什么没进错误处理分支。信号事件是全局广播runtimeService.signalEventReceived(SOME_SIGNAL)会把所有订阅了这个信号的执行都推进一步。这个能力很强大但也危险 —— 生产环境里一个错误的广播可能同时推进上百个流程实例的几百个执行。所以我的习惯是能用消息事件就用消息事件信号只用在真正需要“一对多广播”的场景比如“系统参数变更所有等待中的流程重新计算”。消息事件Message的语义是点对点需要指定接收方粒度更可控。4. 网关分支排他、并行、包容、事件网关的语义差异4.1 排他网关的默认流与条件判定顺序排他网关ExclusiveGateway是最常用的语义是“多条出口里选一条”。写法上有个default属性指定默认流exclusiveGateway idgw_check name天数判断 defaultflow_reject/ sequenceFlow idflow_pass sourceRefgw_check targetReftask_pass conditionExpression xsi:typetFormalExpression ![CDATA[${days 3}]] /conditionExpression /sequenceFlow sequenceFlow idflow_reject sourceRefgw_check targetReftask_leader/判定顺序是按 XML 里sequenceFlow的书写顺序逐个求值第一个为 true 的胜出。所以有多个条件时顺序本身就是业务逻辑的一部分别指望引擎帮你做优先级排序。default属性非常重要。如果所有条件都不满足又没有默认流引擎会抛No outgoing sequence flow of the exclusive gateway could be selected这个异常会打断整个流程推进。我一般强制规定团队里所有排他网关必须配默认流默认流指向一个“异常兜底”的节点或者直接结束让流程至少不会卡在一个无解的状态上。这个约定看起来啰嗦但在变量缺失、类型不匹配这类情况下能救命。CDATA 包裹表达式也是个必须养成的习惯。因为、这些符号在 XML 里是保留字符不包 CDATA 的话解析器直接报错。有人喜欢写成lt;能跑但可读性差接手的人容易看漏。4.2 并行网关的汇聚等待与“卡死”并行网关ParallelGateway不需要条件进来就 fork 出所有出口分支每个分支一个执行execution。汇聚点时它会等所有入线都到达才继续。这个“等都到达”的机制是靠统计入线数量实现的所以一旦有分支没到达汇聚点就永远等下去。这就是并行网关最常见的坑parallelGateway idgw_fork/ parallelGateway idgw_join/ sequenceFlow idf1 sourceRefgw_fork targetReftask_a/ sequenceFlow idf2 sourceRefgw_fork targetRefsub_b/ sequenceFlow idj1 sourceReftask_a targetRefgw_join/ sequenceFlow idj2 sourceRefsub_b targetRefgw_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 idt1 name直属主管审批 flowable:assignee${directLeader}/ !-- 候选组组内任意一人可认领 -- userTask idt2 name部门会签 flowable:candidateGroupsdept_manager,hr_bp/ !-- 候选人多选一 -- userTask idt3 name技术评审 flowable:candidateUsers1001,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:classcom.demo.XxxDelegate无外部依赖的纯逻辑引擎 new 实例无法注入容器 Bean委托表达式flowable:delegateExpression${xxxDelegate}需要注入 Service/MapperBean 必须实现 JavaDelegate 或 ActivityBehavior方法表达式flowable:expression${svc.doIt(execution)}一行能调完的简单动作参数只能是 execution 或流程变量外部任务flowable:typeexternal跨语言、跨进程处理需要外部客户端轮询ACT_RU_EXT_TASK实际项目里九成以上的服务任务用delegateExpression因为它天然贴合 Spring 的依赖注入执行类里的Autowired都能用。expression适合那种“调一下对个状态”的轻动作比如${notifyService.send(execution.getVariable(userId))}但表达式写复杂了就成了不可调试的黑盒我在评审时看到超过三个方法调用链的expression一律要求改成 Delegate。flowable:class那个坑我前面提过再强调一次引擎用反射创建实例不走 Spring 容器。用它写出来的 Delegate 里所有注入字段都是 null只有在完全不依赖外部组件的时候才考虑。服务任务的返回值可以用flowable:resultVariable收进流程变量serviceTask idsvc_calc name计算可休天数 flowable:delegateExpression${leaveCalcDelegate} flowable:resultVariableavailableDays/Delegate 的execute方法里通过execution.setVariable设置也行用resultVariable更直观一点。投射到流程变量的值最好保持基本类型或可序列化的简单对象塞一个复杂 DTO 进去会让ACT_GE_BYTEARRAY里的序列化数据变得难以排查跨版本升级时还容易反序列化失败。5.3 子流程与调用活动的变量传递嵌入子流程SubProcess写在当前流程定义内部共享主流程的变量作用域本质上只是节点的分组方便把一块逻辑折叠起来看。调用活动CallActivity才是真正“调用另一个流程定义”的机制calledElement指定被调流程的 key默认调用最新版本。callActivity idcall_check name调用风控校验 calledElementriskCheck flowable:inheritVariablestrue flowable:fallbackToDefaultTenantfalse extensionElements flowable:in sourceorderId targetbizOrderId/ flowable:out sourceriskLevel targetorderRiskLevel/ /extensionElements /callActivityinheritVariablestrue表示把父流程的全部变量带进去默认就是 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是字符串5UEL 会做类型转换大多数情况下能正确比较但如果变量是空值表达式会静默返回 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:asynctrue往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起步需要审计追溯再上fullfull的写入量在高峰时会明显拖慢流程推进这点要提前评估。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监听器实例来源改成委托表达式历史表查不到数据historyLevelnone或流程被删除ACT_HI_PROCINST调整历史级别用deleteReason区分流程定义版本混乱多模块重复部署同名 keyACT_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:asynctrue和flowable:exclusivetrue。异步节点会在事务提交后才被执行好处是把长耗时节点从主事务里摘出去坏处是异常不会立刻反馈给发起方得靠 job 的重试机制和死信表ACT_RU_DEADLETTER_JOB兜底。exclusive保证同一流程实例的异步任务串行执行避免并发冲突代价是牺牲一点并行度。我的建议是涉及同一实例内状态变更的异步节点一律exclusivetrue纯外部调用的可以放开。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 写法、对应的运行期表记录吃透剩下的那些冷门元素遇到再查都来得及。反倒是变量作用域和事务边界这两块基础知识必须在动手写第一个流程之前就搞清楚因为它们踩坑之后的表现都很隐蔽 —— 流程不报错数据就是不对查起来比报错难受十倍。
阅读完成 · 觉得有帮助?
咨询建站