Activiti工作流引擎实战:退回、跳过、强制结束与运行中流程图实现

发布时间:2026/10/19 10:02:09
Activiti工作流引擎实战:退回、跳过、强制结束与运行中流程图实现
1. 工作流引擎选型与整体设计思路1.1 为什么在众多方案里挑了Activiti做过审批流、工单流转、OA系统的人绕不开一个核心问题流程引擎到底选谁。市面上常见的有Activiti、Flowable、Camunda、JBPM这几家底层都脱胎于早期的PVMProcess Virtual Machine思想但各自演进路线不同。我当年选Activiti主要基于三个现实考量。第一是API的直观程度。Activiti的RuntimeService、TaskService、RepositoryService、HistoryService这几大服务接口划分得非常清晰基本上看名字就知道该用哪个。新手接手项目时学习曲线相对平缓不用一上来就啃一堆抽象概念。第二是BPMN 2.0规范的贴合度。Activiti对BPMN 2.0的支持相当完整尤其是BpmnModel这套对象模型允许我们在代码层面动态构建、修改流程图而不是只能依赖可视化设计器导出的静态XML。这一点对于需要运行时动态调整流程的场景至关重要。第三是社区沉淀。Activiti在国内的实践案例多遇到问题搜索时能找到大量真实踩坑记录这对项目推进的隐性帮助非常大。需要说明的是Flowable是Activiti团队分叉出去的后续项目很多API设计一脉相承。如果你现在新起项目Flowable在部分特性上更活跃但如果是维护存量系统或者团队已有Activiti经验继续用Activiti完全没问题。本文的讨论以Activiti 6/7这条线为主核心思路对Flowable同样适用。1.2 这套方案要解决的核心痛点标题里点出的几个能力——退回、跳过、强制结束、BpmnModel操作、运行中流程图、流程变量、候选人操作——其实对应了真实业务里最容易被产品经理追问的几个问题这个审批能不能打回去让上一步重填某个节点领导说不用审了能不能直接跳过去流程卡住了管理员能不能强制终止用户想看看现在流程走到哪了能不能画个图这个任务该谁处理能不能指定多个人抢单这些需求Activiti原生API并没有全部提供开箱即用的方法。比如退回标准BPMN里没有回退到上一节点这种语义需要我们自己通过RuntimeService的createChangeActivityStateBuilder或者操作BpmnModel来实现。跳过和强制结束同理都需要对引擎内部机制有理解才能做得干净。所以这篇文章不是简单的API罗列而是围绕这些原生不直接支持但业务必须要的能力把实现路径、原理和坑点讲透。1.3 整体技术架构与依赖准备先把环境交代清楚避免读者在版本问题上浪费时间。我实测比较稳的组合是组件版本说明JDK8 / 11Activiti 6对JDK 8友好7开始建议11Activiti6.0.0 或 7.x6.0.0最经典7.x API有调整Spring Boot2.x配合activiti-spring-boot-starter数据库MySQL 5.7引擎自带25张表需提前建库连接池Druid/HikariCP生产环境务必配置Maven依赖的核心坐标以6.0.0为例dependency groupIdorg.activiti/groupId artifactIdactiviti-spring-boot-starter-basic/artifactId version6.0.0/version /dependency dependency groupIdorg.activiti/groupId artifactIdactiviti-image-generator/artifactId version6.0.0/version /dependencyactiviti-image-generator这个包单独拎出来说因为运行中流程图的生成依赖它。很多人只引了starter结果画图时找不到类白白排查半天。提示Activiti启动时会自动检查并创建表结构databaseSchemaUpdate设为true时。生产环境建议设为false用官方SQL脚本手动初始化避免误操作。配置上application.yml里至少要配好数据源和async-executorspring: datasource: url: jdbc:mysql://localhost:3306/activiti_demo?useUnicodetruecharacterEncodingutf8 username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver activiti: database-schema-update: true async-executor-activate: true history-level: fullhistory-level设成full很关键。默认是audit只记录流程实例和活动不记录变量和任务细节。一旦你要做流程变量历史查询或者退回时恢复历史变量audit级别会让你抓瞎。代价是历史表数据量增长快需要配套清理策略。2. 核心API能力拆解与实操要点2.1 七大Service接口的职责边界Activiti的API设计遵循单一职责每个Service管一块。新手最容易混淆的是RuntimeService和TaskService我见过有人用TaskService去启动流程结果报错还找不到原因。这里用一张表把边界划清楚Service核心职责典型方法RepositoryService流程定义部署、查询、删除createDeployment()、createProcessDefinitionQuery()RuntimeService流程实例运行态启动、变量、信号startProcessInstanceByKey()、setVariable()TaskService用户任务查询、完成、候选人complete()、claim()、addCandidateUser()HistoryService历史数据已结束流程、活动记录createHistoricProcessInstanceQuery()ManagementService引擎管理作业、表、定时器createJobQuery()、executeJob()IdentityService用户与组可选很多项目自己管createUser()、createGroup()FormService表单用得少多数项目自研表单getTaskFormData()理解这张表后面所有操作都能对号入座。比如获取流程变量运行态用RuntimeService.getVariables()历史态用HistoryService.createHistoricVariableInstanceQuery()两者数据来源不同别搞混。2.2 BpmnModel代码操作流程图的入口BpmnModel是Activiti对BPMN文件的Java对象映射。一个.bpmn文件解析后就是一个BpmnModel对象里面装着Process、FlowElement、SequenceFlow、UserTask等元素。为什么必须掌握它因为退回、跳过、动态加签这些操作本质上都是在改流程图的节点和连线关系。获取BpmnModel的标准姿势Autowired private RepositoryService repositoryService; public BpmnModel getBpmnModel(String processDefinitionId) { return repositoryService.getBpmnModel(processDefinitionId); }拿到之后你可以遍历所有节点BpmnModel model getBpmnModel(processDefinitionId); Process process model.getMainProcess(); CollectionFlowElement elements process.getFlowElements(); for (FlowElement element : elements) { if (element instanceof UserTask) { UserTask task (UserTask) element; System.out.println(用户任务 task.getId() - task.getName()); } }这里有个关键细节getBpmnModel返回的是流程定义的模型是只读的严格说是缓存对象改了会影响后续所有实例。如果你要基于它做动态修改必须先深拷贝否则会污染整个流程定义。我踩过这个坑——改了模型后所有新启动的实例都跟着变了排查了很久才发现是缓存被污染。深拷贝的简单做法是序列化再反序列化或者用BpmnXMLConverter转成XML字符串再转回来BpmnXMLConverter converter new BpmnXMLConverter(); byte[] xmlBytes converter.convertToXML(bpmnModel); BpmnModel copy converter.convertToBpmnModel(new ByteArrayInputStream(xmlBytes), true, false);2.3 流程变量的读写与作用域流程变量是流程流转的血液网关判断、任务分配、表单回显都靠它。Activiti的变量有作用域概念分全局变量Execution级别和本地变量Task级别。设置全局变量runtimeService.setVariable(processInstanceId, amount, 5000); runtimeService.setVariables(processInstanceId, map);设置任务本地变量taskService.setVariableLocal(taskId, approveComment, 同意);区别在于全局变量在整个流程实例内可见本地变量只在当前任务可见任务结束后本地变量会随任务归档到历史表。实操心得审批意见这类只属于某个节点的数据用本地变量金额、申请人这类全程要用的用全局变量。混用会导致网关判断时取不到值。读取时也有讲究// 运行态读取 MapString, Object vars runtimeService.getVariables(processInstanceId); // 历史态读取流程已结束 ListHistoricVariableInstance list historyService .createHistoricVariableInstanceQuery() .processInstanceId(processInstanceId) .list();注意流程结束后RuntimeService查不到变量了必须走HistoryService。很多人在流程结束后还想用runtimeService.getVariable结果返回null以为是数据丢了其实是查错了地方。3. 退回、跳过、强制结束的完整实现3.1 退回从当前节点回到指定历史节点退回是审批流里最高频的需求。BPMN规范里没有退回这个动作实现思路是找到目标节点用changeState把当前执行流搬过去。Activiti提供了RuntimeService.createChangeActivityStateBuilder()这是退回的核心工具public void rollbackToTask(String processInstanceId, String currentActivityId, String targetActivityId) { runtimeService.createChangeActivityStateBuilder() .processInstanceId(processInstanceId) .moveActivityIdTo(currentActivityId, targetActivityId) .changeState(); }moveActivityIdTo的意思是把当前停在currentActivityId的执行流移动到targetActivityId。移动后目标节点会重新生成一个待办任务。但这里有几个必须处理的细节第一目标节点必须是历史走过的节点。如果目标节点在当前流程路径上从未执行过changeState会报错或产生不可预期的结果。所以退回前要先查历史活动ListHistoricActivityInstance history historyService .createHistoricActivityInstanceQuery() .processInstanceId(processInstanceId) .activityType(userTask) .orderByHistoricActivityInstanceStartTime().asc() .list();第二退回后原任务的变量要处理。如果原任务有本地变量退回后这些变量不会自动带过去。通常做法是退回前把关键变量提升为全局变量或者手动复制。第三多实例节点退回要特殊处理。如果当前节点是会签多实例moveActivityIdTo的行为会比较复杂可能需要先deleteTask再重建。这块建议单独测试。我封装过一个通用的退回方法把历史节点列表返回给前端让用户选择退回到哪一步public ListMapString, Object getRollbackNodes(String processInstanceId) { ListHistoricActivityInstance list historyService .createHistoricActivityInstanceQuery() .processInstanceId(processInstanceId) .activityType(userTask) .finished() .orderByHistoricActivityInstanceEndTime().desc() .list(); ListMapString, Object result new ArrayList(); for (HistoricActivityInstance h : list) { MapString, Object node new HashMap(); node.put(activityId, h.getActivityId()); node.put(activityName, h.getActivityName()); node.put(assignee, h.getAssignee()); node.put(endTime, h.getEndTime()); result.add(node); } return result; }3.2 跳过让流程越过当前节点继续走跳过和退回方向相反——退回是往回走跳过是往前跨。典型场景某个审批节点领导说不用审了直接进入下一环节。实现思路有两种各有适用场景。方案一用changeState移动到下一个节点。先通过BpmnModel找到当前节点的出口连线确定下一个节点ID然后runtimeService.createChangeActivityStateBuilder() .processInstanceId(processInstanceId) .moveActivityIdTo(currentActivityId, nextActivityId) .changeState();方案二直接complete当前任务但用变量控制网关跳过。这种方式适合流程设计时就预留了跳过分支的情况通过设置变量让排他网关走另一条路。方案一更灵活但需要自己解析流程图找下一节点。找下一节点的代码public String findNextActivityId(String processDefinitionId, String currentActivityId) { BpmnModel model repositoryService.getBpmnModel(processDefinitionId); Process process model.getMainProcess(); FlowElement current process.getFlowElement(currentActivityId); if (current instanceof FlowNode) { ListSequenceFlow outgoing ((FlowNode) current).getOutgoingFlows(); if (!outgoing.isEmpty()) { return outgoing.get(0).getTargetRef(); } } return null; }提示如果当前节点后面是网关直接跳到网关的下一个用户任务会更符合业务预期。实际项目中我通常会把跳过做成跳到指定节点让操作人自己选目标而不是机械地跳下一个。3.3 强制结束管理员终止流程实例流程卡死、业务取消、数据错误时需要强制结束。Activiti提供了deleteProcessInstance但直接删会丢失历史数据不推荐。更好的做法是先记录原因再删除或者用changeState把流程导向结束节点。删除并保留历史runtimeService.deleteProcessInstance(processInstanceId, 管理员强制终止业务取消);第二个参数是删除原因会记录到历史表。删除后HistoryService仍能查到这条流程实例状态是DELETED。如果业务要求流程正常结束而不是删除可以找到结束节点把执行流移过去public void forceEnd(String processInstanceId, String processDefinitionId) { BpmnModel model repositoryService.getBpmnModel(processDefinitionId); Process process model.getMainProcess(); String endEventId null; for (FlowElement element : process.getFlowElements()) { if (element instanceof EndEvent) { endEventId element.getId(); break; } } if (endEventId ! null) { ListExecution executions runtimeService.createExecutionQuery() .processInstanceId(processInstanceId).list(); for (Execution execution : executions) { if (execution.getActivityId() ! null) { runtimeService.createChangeActivityStateBuilder() .moveActivityIdTo(execution.getActivityId(), endEventId) .changeState(); } } } }两种方式的选择要留痕、要审计用删除原因要流程状态干净、后续统计方便用导向结束节点。我个人倾向后者因为删除的流程在报表里是异常终止而导向结束节点是正常完成数据更整洁。4. 运行中流程图与候选人操作实战4.1 生成带高亮的运行中流程图用户最想看到的功能之一打开流程详情一张图显示当前走到哪了走过的节点高亮。Activiti的ProcessDiagramGenerator就是干这个的。核心代码Autowired private ProcessEngine processEngine; Autowired private RepositoryService repositoryService; Autowired private RuntimeService runtimeService; Autowired private HistoryService historyService; public InputStream generateDiagram(String processInstanceId) { HistoricProcessInstance hpi historyService .createHistoricProcessInstanceQuery() .processInstanceId(processInstanceId).singleResult(); BpmnModel bpmnModel repositoryService.getBpmnModel(hpi.getProcessDefinitionId()); ListHistoricActivityInstance history historyService .createHistoricActivityInstanceQuery() .processInstanceId(processInstanceId) .orderByHistoricActivityInstanceStartTime().asc() .list(); ListString highLightedActivities new ArrayList(); ListString highLightedFlows new ArrayList(); for (HistoricActivityInstance h : history) { highLightedActivities.add(h.getActivityId()); } ProcessDiagramGenerator generator processEngine.getProcessEngineConfiguration() .getProcessDiagramGenerator(); return generator.generateDiagram( bpmnModel, png, highLightedActivities, highLightedFlows, 宋体, 宋体, 宋体, null, 1.0, true ); }几个实操要点中文字体必须指定否则生成的图里中文全是方框。上面代码里的宋体就是干这个的服务器上要确保有这个字体Linux环境可能需要额外安装。高亮连线需要自己计算。highLightedFlows要传的是SequenceFlow的ID不是节点ID。判断一条连线是否走过可以看它的源节点和目标节点是否都在历史活动里。性能问题。每次请求都重新生成图开销不小。生产环境建议加缓存key用processInstanceId 最后活动时间流程没变化就直接返回缓存的图片流。高亮连线的计算逻辑public ListString getHighLightedFlows(BpmnModel bpmnModel, ListHistoricActivityInstance history) { ListString flows new ArrayList(); SetString finishedActivities new HashSet(); for (HistoricActivityInstance h : history) { finishedActivities.add(h.getActivityId()); } Process process bpmnModel.getMainProcess(); for (FlowElement element : process.getFlowElements()) { if (element instanceof SequenceFlow) { SequenceFlow flow (SequenceFlow) element; if (finishedActivities.contains(flow.getSourceRef()) finishedActivities.contains(flow.getTargetRef())) { flows.add(flow.getId()); } } } return flows; }4.2 候选人认领、查询与动态加签候选人机制解决的是这个任务该谁做的问题。Activiti支持三种分配方式直接指派assignee、候选人candidateUser、候选组candidateGroup。候选人模式下任务处于待认领状态谁先claim谁负责。添加候选人taskService.addCandidateUser(taskId, user001); taskService.addCandidateUser(taskId, user002); taskService.addCandidateGroup(taskId, managerGroup);查询某用户的待办包括直接指派和候选人ListTask tasks taskService.createTaskQuery() .taskCandidateOrAssigned(user001) .list();认领任务taskService.claim(taskId, user001);认领后assignee变成user001其他候选人就看不到这个任务了除非用taskCandidateUser查但通常业务上认领后就该独占。动态加签是候选人机制的进阶用法。场景审批过程中当前审批人想拉一个人一起审。实现方式是给任务动态添加候选人public void addSigner(String taskId, String userId) { taskService.addCandidateUser(taskId, userId); }如果要实现加签后必须等加签人处理完才能继续那就不是简单加候选人而是要修改流程结构——在BpmnModel里动态插入一个用户任务节点。这种操作复杂度高我一般建议用**会签多实例**在设计阶段就解决而不是运行时动态改图。注意claim操作有并发问题。两个人同时点认领可能都成功。Activiti内部用乐观锁处理但业务层最好加一层判断认领前先查assignee是否为空不为空就提示已被认领。4.3 候选人查询的常见坑taskCandidateOrAssigned这个方法看起来方便但有个性能陷阱它生成的SQL包含OR条件在任务量大时可能走不了索引。我实测过任务表超过10万条时这个查询会明显变慢。优化思路是拆成两次查询再合并ListTask assigned taskService.createTaskQuery() .taskAssignee(userId).list(); ListTask candidate taskService.createTaskQuery() .taskCandidateUser(userId).list(); // 合并去重另外候选组查询要注意用户和组的关联关系。Activiti的IdentityService可以管用户组但很多项目用自己的用户体系。这时需要自己维护用户-组映射查询时把用户所属的所有组传进去ListString groups getUserGroups(userId); ListTask tasks taskService.createTaskQuery() .taskCandidateGroupIn(groups) .list();5. 常见问题与排查技巧实录5.1 退回后流程走不动了这是最高频的问题。表现是退回成功目标节点生成了任务但完成这个任务后流程不往下走或者报找不到出口连线。根因通常是changeState移动执行流时没有正确处理并行网关或排他网关的分支。比如流程有并行分支退回时只移动了其中一条执行流另一条还停在原地导致流程状态不一致。排查步骤查ACT_RU_EXECUTION表看这个流程实例下有几条执行流ACT_ID_分别是什么。对比BpmnModel里的节点结构确认执行流数量和位置是否符合预期。如果有多条执行流退回时要对每条都做moveActivityIdTo或者先合并再移动。避坑技巧退回操作尽量只在线性流程或简单分支上使用。如果流程里有复杂的并行网关建议用作废当前流程、重新发起代替退回虽然粗暴但稳定。5.2 流程变量在退回后丢失前面提过本地变量不随退回传递。除此之外还有一个隐蔽原因changeState会触发执行流的销毁和重建某些监听器里的变量设置逻辑可能没被执行。解决方案退回前把需要保留的变量统一提升为全局变量MapString, Object localVars taskService.getVariablesLocal(taskId); runtimeService.setVariables(processInstanceId, localVars);退回后如果需要恢复原任务的本地变量再手动setVariableLocal回去。5.3 运行中流程图中文乱码前面提过字体问题这里补充一个细节不同操作系统的字体名称不一样。Windows上是宋体Linux上可能是SimSun或者需要指定字体文件路径。更稳妥的做法是加载字体文件InputStream fontStream new ClassPathResource(fonts/simsun.ttf).getInputStream(); Font font Font.createFont(Font.TRUETYPE_FONT, fontStream);然后在generateDiagram时传入字体名称。这样不依赖服务器环境部署到哪都一致。5.4 候选人任务查不到用户明明被加为候选人了但taskCandidateUser查不出来。常见原因有三个候选人加在了错误的表。addCandidateUser写的是ACT_RU_IDENTITYLINK表如果直接操作数据库而没走API可能类型字段TYPE_不对应该是candidate。任务已经认领。认领后assignee有值taskCandidateUser默认只查未认领的。要查全部用taskCandidateOrAssigned。用户ID大小写或空格问题。这个最坑肉眼看不出来建议查询前trim一下。5.5 常见问题速查表问题现象可能原因排查方向解决方式退回后流程卡住并行分支执行流不一致查ACT_RU_EXECUTION对每条执行流分别移动变量退回后丢失本地变量不传递查变量作用域退回前提升为全局变量流程图中文乱码字体未指定检查生成代码指定字体或加载字体文件候选人查不到认领状态/类型错误查ACT_RU_IDENTITYLINK用taskCandidateOrAssigned强制结束后历史丢失用了deleteProcessInstance查历史表改用导向结束节点流程图生成慢无缓存看请求频率加缓存按最后活动时间失效5.6 几个我踩过的坑坑一getBpmnModel返回的对象被意外修改。前面提过这个对象是缓存的。我有次在代码里直接改了它的节点名称结果所有实例的流程图都变了。后来养成习惯拿到BpmnModel第一件事就是深拷贝。坑二changeState在事务里调用失败不回滚。changeState内部有自己的事务逻辑如果外层事务回滚流程状态可能已经变了。建议把退回操作放在独立事务里或者用编程式事务精确控制。坑三历史级别设成audit导致退回找不到历史节点。audit级别不记录HistoricActivityInstance的详细信息退回时查历史节点会查不全。这个坑很隐蔽因为流程能正常跑只有退回功能出问题。所以前面强调history-level要设full。坑四候选人加签后忘记发通知。技术上任务加上了但被加签的人不知道。业务上要在addCandidateUser之后触发消息通知这个不是Activiti的职责但很容易漏。6. 一些延伸思考与个人体会6.1 关于动态改流程的边界标题里的BpmnModel操作本质是运行时动态改流程。这个能力很强大但也很危险。我的经验是能用设计期解决的绝不留到运行期。比如加签优先用多实例会签比如跳过优先用网关分支。动态改图只用在真正无法预判的场景比如退回任意节点这种产品硬需求。原因很简单动态改图后流程的实际走向和设计器里画的图不一致后续维护的人会一脸懵。而且Activiti对动态修改的支持并非无懈可击复杂场景下容易出诡异问题。6.2 关于版本升级Activiti 6到7的API有一些破坏性变更比如部分changeState相关的方法签名调整了。如果项目要升级退回、跳过这些功能是重点回归对象。我的建议是升级前把本文涉及的每个功能都写一个集成测试升级后跑一遍比人工点界面靠谱得多。6.3 关于性能流程引擎的性能瓶颈通常在历史表和任务查询上。历史表数据量大了之后HistoricActivityInstanceQuery会变慢。我一般会做两件事一是定期归档历史数据到冷表二是给ACT_HI_ACTINST的PROC_INST_ID_和ACT_ID_加索引。任务查询方面taskCandidateOrAssigned的优化前面说过了。另外待办列表分页时orderByTaskCreateTime().desc()配合索引效果最好别用orderByTaskId那个没索引。最后分享一个小技巧如果你要频繁生成运行中流程图可以把生成的PNG存到文件系统或对象存储数据库里只存URL。流程每次流转时更新一次图用户请求时直接返回URL比实时生成快一个数量级。这个优化在流程实例多、用户访问频繁的系统里效果特别明显。