“撤回到已审批的任意节点”是我在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> <groupId>org.activiti</groupId> <artifactId>activiti-spring-boot-starter</artifactId> <version>6.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, Map<String, 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. 获取当前活跃任务 List<Task> 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 id="exclusiveGateway1" name="审批结果判断" /> <sequenceFlow id="flowPass" sourceRef="exclusiveGateway1" targetRef="task4"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${approveResult == 'PASS'}]]> </conditionExpression> </sequenceFlow> <sequenceFlow id="flowReject" sourceRef="exclusiveGateway1" targetRef="task5"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${approveResult == 'REJECT'}]]> </conditionExpression> </sequenceFlow>如果流程是从 task4 一路走过来的,approveResult 等于 PASS 已经写进流程变量里。现在把任务撤回 task2 重新走一遍,网关判断时 approveResult 还是 PASS,任务提交后根本不会给审批人重新选择的机会,直接冲到 task4。所以在调用撤回 Service 时,调用方必须把“重新提交后需要重置的变量”一并传进来:
Map<String, 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 使用 ProcessInstanceModificationBuilder:
runtimeService.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 的旧 Execution,ActivityInstance 没有正确切换。引擎对这类运行数据的容错率比想象中低,所以宁可在测试环境多跑几轮,也别拿生产数据试错。
把这套逻辑真正落地以后,Activiti 的“撤回到已审批的任意节点”就不再是个玄学问题。关键是守住一条底线:利用官方 API 做活动迁移,业务状态同步放在同一事务里,边界场景提前拒绝而不是冒险硬闯。做到这三点,这个功能在线上会稳很多。