“撤回到已审批的任意节点”是我在Activiti工作流项目里被业务方提过最多次的需求之一。流程正在走当前节点已经到第4环业务突然说第2环数据填错了要回去改或者领导审完认为某个历史审批环节需要补充说明这时候任务得从当前节点“倒着”回到一个已经走过的节点再让流程继续跑下去。Activiti本身基于BPMN2.0实现sequenceFlow只支持正向流转引擎并没有直接提供“退回任意已审批节点”的开箱API所以这个能力必须靠二次开发完成。这篇文章完整梳理一遍我在真实项目里验证过的实现方案包含原理、代码、版本差异和一堆踩坑记录正在被这个需求折磨的同学可以直接参考。1. 需求场景与整体设计思路1.1 “撤回任意已审批节点”到底在解决什么问题先统一一下概念免得后续理解有偏差。假设一条审批链发起人提交 - 直属主管审批 - 财务复审 - 总经理终审。现在流程已经走到“总经理终审”总经理看完全部材料以后认为需要退回到“直属主管审批”这个已经办结的历史节点让主管针对某个问题重新出具意见。这时候流程实例还在运行但任务状态要从当前节点迁移回历史节点并且从历史节点重新出发继续走完后面所有步骤。这里必须区分两种容易混的说法发起人撤回流程还在审批过程中发起人不想继续了直接把整个流程实例取消掉。这种用 runtimeService.deleteProcessInstance 就能解决不是本文要讲的内容。审批打回/退回流程实例和关联数据都保留只是把当前激活的任务节点从当前位置移动到本流程实例已经走过的某个历史节点然后从那个节点重新开始流转。本文讲的“撤回到已审批的任意节点”特指第二种。它是个典型的“逆向操作”但目标并不是取消流程而是让流程换一个分支起点继续向前。1.2 为什么不能“删除流程重发起”我确实见过一些项目组用这种最粗暴的办法把现有流程实例删掉再用同一个业务主键发起一个新流程。表面上看流程“退回去”了但实际埋下的雷非常严重。第一历史审批记录全部丢失。ACT_HI_PROCINST、ACT_HI_TASKINST 里对应这个业务的数据都没了以后想追查“谁在什么时间审批过什么内容”完全没有依据审计层面直接挂掉。第二流程变量和业务状态很难还原。一个流程走到第4环节时中间环节产生的流程变量可能有十几二十个重新发起新实例时能不能全部还原很多团队根本说不清漏掉一个就可能导致后面的网关分支判断错乱。第三流程实例ID会变化。业务表里关联的 PROC_INST_ID_ 字段全部要跟着改改漏一张表历史数据和当前流程就对不上后续查询、报表会乱得一塌糊涂。第四审批链路事实被破坏。“谁同意过、谁驳回过”这些已经发生的审批事实不是“重来一遍”就能假装没发生的谁撒过谎一眼就能看出来。所以正确的方向必须是在原流程实例内部做“状态修改”把执行流搬回目标节点而不是销毁重建。1.3 一套可落地的方案拆成三件事把撤回需求拆开看其实就三件事合法性校验流程实例必须还在运行中目标节点必须是这个流程实例的“历史已办节点”当前必须有活跃的用户任务。活动迁移取消当前节点对应的活动实例在目标节点上重新生成新的活动实例和用户待办任务。附加信息同步流程变量、撤回原因、新的处理人、审计日志全部要在同一个事务里完成。第三件事非常关键因为撤回不是“把任务挪个位置”那么简单。任务挪回目标节点以后这个节点的审批人要变审批意见要清空或者重置后续排他网关依赖的变量条件也可能要重新设置。如果这些没有一起处理好撤回去的任务大概率会卡死在下一跳原因还特别难查。2. 核心概念与原理铺垫2.1 流程实例、执行实例、活动实例三者什么关系要把后面的代码看明白必须先理清三个概念ProcessInstance流程实例、Execution执行实例、ActivityInstance活动实例。ProcessInstance 是一次业务流程运行时的总容器包含所有状态和数据。你可以把它理解成一部电影的“制片人”整个片子的资源调配都归它管。Execution 是流程实例内部为了处理并发而存在的一个个执行分支。简单线性流程一个 Execution 就够了。但一旦遇到并行网关流程会从网关处分裂出多个 Execution每个分支各自往下走互不干扰。ActivityInstance 是某一个 Execution 停留在具体节点时产生的实例。比如“用户任务挂在节点A上等待处理”在引擎底层其实是一个活动实例停留在节点A上。撤回的本质一句话把当前活动实例从它所在的节点上移走并且在同一流程实例内让一个Execution重新跑到目标节点生成一个新的活动实例。理解了这个你就明白了为什么不能简单地调 taskService.complete(taskId)——complete 只会按当前节点向外的 sequenceFlow 往前走而 BPMN 的 sequenceFlow 是单向的模型里根本没有从节点4指回节点2的连线所以 complete 永远不会把你带向历史节点。2.2 查询“哪些节点已经审批过”的正确方法撤回前必须确认两件事目标节点确实被本流程实例“走完过”而且当前不是正在处理中。这里有个经验细节判断“审批过”优先查历史任务表而不是历史活动表。historyService.createHistoricTaskInstanceQuery() 查出来的是人类任务业务上“审批过”的含义就是有人在这条任务上完成过操作。而 ACTIVITI 的历史活动表 ACT_HI_ACTINST 会记录网关、开始事件、定时器等非任务节点的实例如果拿它做判断很容易把网关节点误判成“已经走过的人为节点”。同样一个节点在长流程里可能反复出现多次。用 count 大于 0 只能说明“曾经有过一次”不能说明“最近一次已经结束”。假如目标节点当前正有一个未完成任务在审批中历史表里也可能存在它的结束记录这时候撤回过去就会出问题因为目标节点上本来就有任务引擎会再叠加生成一个新任务。所以校验要双向历史完成记录 0 且 当前活跃任务 0。long historicalFinishedCount historyService .createHistoricTaskInstanceQuery() .processInstanceId(processInstanceId) .taskDefinitionKey(targetNodeId) .finished() .count(); long activeTaskCount taskService.createTaskQuery() .processInstanceId(processInstanceId) .taskDefinitionKey(targetNodeId) .count(); if (historicalFinishedCount 0) { throw new BizException(目标节点未审批过不能撤回); } if (activeTaskCount 0) { throw new BizException(目标节点存在未完成任务请先处理再撤回); }2.3 活动迁移API内部到底做了什么Activiti 6 提供的 ChangeActivityStateBuilder 看起来只是一句链式调用但底层做了两步操作。第一步取消源活动实例。引擎会找到当前活动对应的 Execution将它的 ACTIVITY_ID 置空或者标记为结束并清理对应任务与执行实例的关联关系。这个动作对应 ACT_RU_EXECUTION 与 ACT_RU_TASK 的数据修正。第二步启动目标活动。引擎会在目标节点所属的 Execution 上创建新的 ActivityInstance如果目标是 UserTask同时创建新的 TaskEntity补全 ACT_RU_TASK、ACT_RU_IDENTITYLINK 等数据然后触发相关监听器。这两步被引擎包在同一个事务里要么全部成功要么全部回滚原子性有保障。这也是我强烈建议所有人优先用官方API、不要自己写SQL去改五张运行表的根本原因。自己手工改 ACT_RU_EXECUTION、ACT_RU_TASK、ACT_RU_ACTINST 之间的关联很容易把引擎缓存和表数据改不一致轻则任务显示混乱重则整个流程实例卡死。3. 实操撤回任意已审批节点的完整实现3.1 环境与依赖准备这份方案基于 Java 8 的 Spring Boot 项目引擎使用 Activiti 6.0.0。Activiti 7 和同源的 Flowable API 也基本一致同样适用。Maven 依赖如下dependency groupIdorg.activiti/groupId artifactIdactiviti-spring-boot-starter/artifactId version6.0.0/version /dependency如果你是 Activiti 5 的老项目API 是 ProcessInstanceModificationBuilder我在 3.5 节单独说明。数据库层面建议在历史任务表和运行任务表上给 PROC_INST_ID_ 建索引撤回接口会反复查历史数据索引缺失在数据量大的表上会带来明显的慢查询。3.2 撤回服务统一入口封装我习惯把撤回逻辑全部收口到一个独立 Service不让 Controller 直接操作引擎API。这样多个业务线复用同一个能力权限校验、日志落库、幂等控制都只写一遍。下面是一段经过项目验证的核心代码Service public class WorkflowWithdrawService { Autowired private RuntimeService runtimeService; Autowired private TaskService taskService; Autowired private HistoryService historyService; Transactional(rollbackFor Exception.class) public WithdrawResult withdrawToNode(String processInstanceId, String targetNodeId, String withdrawOperator, String reason, MapString, Object variables) { // 1. 流程实例必须存在且未结束 ProcessInstance processInstance runtimeService.createProcessInstanceQuery() .processInstanceId(processInstanceId) .singleResult(); if (processInstance null) { throw new BizException(流程不存在或已结束不能撤回); } // 2. 目标节点必须是历史已完成的任务节点 long historicalFinishedCount historyService .createHistoricTaskInstanceQuery() .processInstanceId(processInstanceId) .taskDefinitionKey(targetNodeId) .finished() .count(); if (historicalFinishedCount 0) { throw new BizException(目标节点未审批过不能撤回); } // 3. 目标节点当前不能有未完成任务 long activeTaskCount taskService.createTaskQuery() .processInstanceId(processInstanceId) .taskDefinitionKey(targetNodeId) .count(); if (activeTaskCount 0) { throw new BizException(目标节点存在未完成任务请先处理再撤回); } // 4. 获取当前活跃任务 ListTask currentTasks taskService.createTaskQuery() .processInstanceId(processInstanceId) .list(); if (currentTasks.isEmpty()) { throw new BizException(当前没有活跃任务无法撤回); } if (currentTasks.size() 1) { throw new BizException(当前存在多个并行任务该场景不支持直接撤回); } Task currentTask currentTasks.get(0); // 5. 执行活动迁移 runtimeService.createChangeActivityStateBuilder() .processInstanceId(processInstanceId) .moveActivityIdTo(currentTask.getTaskDefinitionKey(), targetNodeId) .changeState(); // 6. 同步流程变量 if (variables ! null) { variables.forEach((k, v) - runtimeService.setVariable(processInstanceId, k, v)); } // 7. 处理新任务 Task newTask taskService.createTaskQuery() .processInstanceId(processInstanceId) .taskDefinitionKey(targetNodeId) .singleResult(); if (newTask ! null) { if (StringUtils.hasText(withdrawOperator)) { taskService.setAssignee(newTask.getId(), withdrawOperator); } taskService.setVariable(newTask.getId(), withdrawReason, reason); taskService.setVariable(newTask.getId(), withdrawTime, new Date()); } return new WithdrawResult(newTask ! null ? newTask.getId() : null); } }第 5 步里moveActivityIdTo的第一个参数是当前任务的 taskDefinitionKey第二个参数是目标节点的 taskDefinitionKey。注意这里的约束这个写法只适合当前流程实例在同一时刻只有一个活跃任务的场景。如果并行网关让流程同时挂起多个任务上面的代码会直接抛异常拒绝撤回后面 4.1 节专门讲并行分支的应对方案。3.3 目标节点必须校验为 UserTask当 “历史已办记录” 校验通过后还有一个非常容易踩的坑目标节点必须是 UserTask 类型。如果它是一个接收任务、服务任务或者结束事件撤回后引擎不会生成人工任务流程可能会沿着目标节点的出口线继续往下执行甚至直接跑完整个流程业务完全失控。所以建议在撤回前增加一个流程定义级的类型校验Autowired private RepositoryService repositoryService; private boolean isUserTaskNode(String processDefinitionId, String nodeId) { BpmnModel bpmnModel repositoryService.getBpmnModel(processDefinitionId); FlowElement element bpmnModel.getFlowElement(nodeId); return element instanceof UserTask; }校验失败直接抛异常告诉调用方目标节点不支持人工审批不能作为撤回终点。这个检查在一次联调里帮我拦下过好几个不合理的需求非常值。3.4 撤回去之后分支走向变量必须重置很多人把“任务回到目标节点”当成整个需求做完这是大错特错的。撤回去之后目标节点重新提交流程要继续往下走而往哪走一般由排他网关决定。网关的 conditionExpression 通常需要读取某个流程变量一旦这个变量还是当初继续向后走时留下的值就可能导致流程直接又按旧路线冲出去而不是让审批人重新选择。看一个真实的 BPMN 片段exclusiveGateway idexclusiveGateway1 name审批结果判断 / sequenceFlow idflowPass sourceRefexclusiveGateway1 targetReftask4 conditionExpression xsi:typetFormalExpression ![CDATA[${approveResult PASS}]] /conditionExpression /sequenceFlow sequenceFlow idflowReject sourceRefexclusiveGateway1 targetReftask5 conditionExpression xsi:typetFormalExpression ![CDATA[${approveResult REJECT}]] /conditionExpression /sequenceFlow如果流程是从 task4 一路走过来的approveResult 等于 PASS 已经写进流程变量里。现在把任务撤回 task2 重新走一遍网关判断时 approveResult 还是 PASS任务提交后根本不会给审批人重新选择的机会直接冲到 task4。所以在调用撤回 Service 时调用方必须把“重新提交后需要重置的变量”一并传进来MapString, Object vars new HashMap(); vars.put(approveResult, null); vars.put(approveComment, null); withdrawService.withdrawToNode(processInstanceId, targetNodeId, operator, reason, vars);变量设置建议放在活动迁移成功之后避免迁移中途异常导致目标节点已经生成变量却根本没更新两者状态不一致。3.5 Activiti 5 老项目的适配方案还在用 Activiti 5 的项目确实不少API 名不同但思想完全一致。Activiti 5 使用 ProcessInstanceModificationBuilderruntimeService.createProcessInstanceModificationBuilder(processInstanceId) .cancelActivityInstance(currentActivityInstanceId) .startBeforeActivity(targetNodeId) .execute();cancelActivityInstance 的参数是当前活动实例ID通常要从历史活动表或运行活动表查询startBeforeActivity 表示在目标节点之前开始新的执行后面的流程继续向前走。老项目升级到 6.x 时主要注意 moveActivityIdTo 和 startBeforeActivity 的语义差异代码迁移后要回归验证一次“撤回-重新提交-再撤回”的完整链路。4. 高频踩坑场景与排查实录4.1 并行分支场景一个撤回请求对应多个活跃任务一旦流程模型里出现并行网关同一时刻就可能存在多个活跃任务。用 3.2 节代码会直接因为 currentTasks.size() 1 抛异常。要支持并行分支需要换思路。第一种业务允许的话先合并再撤回。让并行分支上的所有任务都被处理完流程汇聚到网关之后的单线节点再做撤回。这种最干净输出结果完全在预期内。第二种精确定位 Execution 再移动。拿到第一个活跃任务对应的 ExecutionID用moveExecutionToActivityId(executionId, targetNodeId)把它移到目标节点然后把其他活跃分支上的任务通过取消活动实例的方式清除。这个操作复杂度很高需要非常清楚 ACT_RU_EXECUTION 表的结构而且对多实例分支、嵌套子流程的兼容性很难保证。第三种干脆在业务上进行约定并行结构不提供直接撤回。功能上明确限制调用方先完成当前并行分支再走撤回。很多企业系统其实只是顺序审批链这条限制完全够用而且大幅降低维护成本。我实际交付的项目里采用第三种方案的比例很高因为业务方对“任意节点”的执念主要停留在“能不能退到前面某个环节”而不是“并行状态下的复杂迁移”。所以先把顺序链路做好比一味追求复杂API要实在得多。4.2 多实例会签节点撤回任务数量对不上会签节点本质是“多实例活动”节点下会分裂出多个子执行分支每个分支一个任务审批人分别为不同的人或角色。撤回到多实例目标节点时直接 moveActivityIdTo 往往只生成一个任务而不是还原成原本“4个人都要签”的状态。有个项目就是吃了这个亏原本一个节点需要4个部门负责人会签业务要求撤回这个节点重新会签结果急匆匆用标准 API 一试目标节点上只有一个任务其他3个人根本看不到待办。查 ACT_RU_EXECUTION 时发现多实例的子执行数量根本不是4个因为迁移并没有重新初始化多实例需要的 collection 和 number of instances 变量。处理方案有几个层级流程建模时尽量避免“撤回目标指向多实例节点”在做流程设计评审时明确这一点。如果确实必须撤回多实例节点建议在目标节点设计一个“父级节点”业务上先撤回到父级节点再通过子流程或普通任务触发扬起新一轮会签事件。实在要强行支持就得自定义 Command在多实例节点上重新计算实例数量、重新创建所有子执行和子任务工作量非常大不太推荐。4.3 流程恰好走完运行表查不到实例但历史表有 END_TIME_有一个容易忽略的时间窗口流程在最后一个节点被提交流转后如果流程定义最后一条路通向结束事件引擎会立即结束流程实例。此时用 runtimeService.createProcessInstanceQuery().singleResult() 查 null但历史表 ACT_HI_PROCINST 里可能已经写入了 END_TIME_。遇到这种状态不能撤回因为运行数据已经被清理。代码里最好加一个历史表兜底HistoricProcessInstance historicProc historyService .createHistoricProcessInstanceQuery() .processInstanceId(processInstanceId) .singleResult(); if (historicProc ! null historicProc.getEndTime() ! null) { throw new BizException(流程已结束不能撤回); }这个判断可以和 runtimeService 的查询一起做哪个先发现结束都不放过。4.4 撤回后监听器重复触发通知发了不止一遍UserTask 节点如果配了 assignmentListener 或 completeListener撤回重新生成任务时这些监听器会再次执行。最常见的症状是审批人手机上连续收到两条待办提醒短信或者日志里同一个自定义事件被打了两次。排查步骤先看 ACT_RU_TASK 和 ACT_HI_TASKINST确认新任务的 ID 与 CREATE_TIME_ 记录是否合理。打开流程定义的 XML定位该节点绑定的监听器。如果是任务创建类监听器在代码里增加幂等判断同一流程实例、同一节点、同一处理人名下若已存在提醒记录则直接跳过。这个幂等判断也可以下沉到消息发送层统一做一个“同业务键只发送一次”的去重表。推荐后者因为所有节点监听器都能复用不需要在每个流程节点的代码里各自写一套。4.5 并发撤回乐观锁与幂等两个操作员分别从手机上点“撤回”请求同时到达后端就可能在同一个流程实例上产生竞争。Activiti 的任务表 ACT_RU_TASK 自带 REV_ 乐观锁字段引擎更新任务时如果发现版本号对不上会抛异常但业务代码层面仍然需要兜底。我习惯在撤回 Service 里建一张业务日志表记录流程实例ID、撤回前节点、撤回后节点、操作人、操作时间并用“流程实例ID 目标节点ID 最近一次撤回时间”做重复请求的防抖。实际场景里同一个流程被两个操作员同时撤回目标节点的情况虽然不常见一旦发生没有幂等保护就会出现任务被迁移来迁移去的诡异现象很难复盘。4.6 性能隐患与数据清理撤回动作会在历史表里追加一批记录同时在运行表里生成新的任务行。频繁使用时要留意三点历史任务表查询一定要走 processInstanceId 索引。没有索引时count 类的历史查询在高并发下会把数据库拖垮。撤回过程中旧的执行分支如果没有被正确清理ACT_RU_EXECUTION 表会残留大量 inactive 记录时间一长影响查询性能和后续迁移。撤回业务尽量控制在短事务内中间不要夹杂远程RPC调用、发消息等耗时操作避免数据库连接长时间被占用。5. 工程化经验与最后的几点提醒5.1 流程设计阶段就为“可撤回”留好余地经历过两次线上事故以后我的个人建议是不要所有流程都支持撤回任意已审批节点。流程建模阶段就和业务方确认哪些节点允许被撤回、撤回后哪些变量需要重置、目标节点会不会出现并行或会签。把这些规则写进流程配置文件由配置中心下发比在代码里用一串 if-else 硬编码要灵活得多。业务方嘴上说“任意”心里想要的其实是“关键历史节点可以回退”。把白名单做出来90% 的复杂度都可以在设计期消解掉剩下真正需要的撤回场景用本文这套方案完全能撑住。5.2 撤回操作必须有独立审计以弥补引擎日志的不足ACT_HI_* 系列表记录的只是流程节点的流转结果不记录“是谁、因为什么原因、从哪个节点发起了撤回”。这种信息对于内部审计非常重要一定要自己维护一张撤回日志表。推荐字段流程实例ID、流程定义ID、撤回前节点ID、撤回后目标节点ID、撤回操作人、撤回原因、关联业务单号、撤回时间、撤回前后关键流程变量快照。有了这张表未来排查“撤回后任务去哪了”“撤回后网关为什么走错分支”这类问题会轻松非常多不需要靠翻引擎日志去猜。5.3 回归测试要做完整链路撤回功能上线前建议至少压一遍这样的场景从节点A正常走到节点B从B撤回A再提交走到C再从C撤回B再提交走完整条流程。这个循环我建议多做两轮因为第一轮通常能过第二轮开始才会暴露“旧 Execution 没清除干净”“变量没重置”等潜在问题。我在交付一个项目时遇到过第二轮撤回后节点任务直接不生成的现象后来排查发现是第一次撤回时目标节点上残留了一个 inactive 的旧 ExecutionActivityInstance 没有正确切换。引擎对这类运行数据的容错率比想象中低所以宁可在测试环境多跑几轮也别拿生产数据试错。把这套逻辑真正落地以后Activiti 的“撤回到已审批的任意节点”就不再是个玄学问题。关键是守住一条底线利用官方 API 做活动迁移业务状态同步放在同一事务里边界场景提前拒绝而不是冒险硬闯。做到这三点这个功能在线上会稳很多。
阅读完成 · 觉得有帮助?