工作流驳回为什么这么难?从执行树和历史轨迹讲起
很多系统把“驳回”做成一个按钮:当前审批人选择目标节点,填写原因,流程就回去了。真正困难的部分却发生在按钮之后——正在运行的执行令牌要怎样移动,当前任务以什么状态结束,并行分支和会签实例是否一起取消,历史轨迹怎样解释重复经过的节点,业务单据又应该回到什么状态。
Flowable 能提供运行时状态迁移能力,但不会替业务系统定义“中国式审批”的驳回语义。可靠的实现必须同时处理执行树、任务实例、历史活动、审批轮次、业务状态和幂等控制。尤其发生连续驳回时,同一个 activityId 会反复产生新的 taskId;如果仍把流程历史当作一条可以倒序查找的链表,目标节点和轨迹展示很快就会出错。
一、核心结论与问题边界
一句话回答:驳回不是倒放流程,而是重构运行时状态
正常流转按照 BPMN Sequence Flow 向前推进,驳回却通常跳过模型中的正常连线,把当前活动迁移到此前某个活动。它至少完成四件事:
- 结束或删除当前任务,并记录技术删除原因;
- 删除、收缩或重建相关 Execution;
- 在目标活动创建新的 Execution 和新的待办;
- 追加历史记录,而不是恢复或覆盖旧任务。
所以,“从总经理退回财务经理”不是把财务经理两小时前完成的 taskId 重新打开,而是在财务经理对应的 activityId 上创建第二轮任务。旧任务属于历史,新任务属于运行时,两者必须通过轮次和驳回操作记录关联。
先分清三种经常混用的标识
| 标识 | 含义 | 驳回时的用途 |
|---|---|---|
| taskId | 一次具体用户任务实例 | 校验操作者、意见和幂等;驳回后不复用 |
| executionId | 当前令牌或作用域执行实例 | 精确移动某条活动路径 |
| activityId | BPMN 模型中的节点标识 | 指定目标节点;同一节点可多次或并发出现 |
“上一任务”“上一节点”和“上一审批人”也不是同一个概念。上一任务可能是同一会签节点的另一个实例,上一节点可能是自动服务任务,上一审批人可能来自另一条已经汇聚的分支。产品必须先定义驳回目标的业务口径,技术实现才有确定答案。
执行树为什么不是待办链表
Flowable 的流程实例由 Execution 树承载。最简单的顺序流程可能只有根 Execution 和当前叶子 Execution;遇到并行网关、多实例、子流程或调用活动后,树中还会出现并发作用域、多实例根和嵌套作用域。User Task 只是挂在叶子 Execution 上的一项工作,不持有“前一个任务”的链式指针。
一个并行会签的运行时结构可以抽象为:
- ProcessInstance / root execution;
- Multi-instance scope execution;
- execution-A → task-A,execution-B → task-B,execution-C → task-C。
若把 task-B 驳回到前置节点,平台必须决定只移动 execution-B、结束整个多实例 Activity Body,还是先收拢 A/B/C 三个执行再进入目标节点。三种产品语义都可能合理,却会产生完全不同的执行树。

图 1:驳回会结束当前任务、调整执行树并在目标活动创建新任务,不会复活旧 taskId。
二、关键概念与能力差异
截至 2026 年 8 月,Flowable 最新源码怎样执行状态迁移
截至 2026 年 8 月 2 日,Flowable Engine 最新正式版是 8.0.0。官方源码中的状态迁移入口是 RuntimeService.createChangeActivityStateBuilder()。核心调用链如下:
ChangeActivityStateBuilder → ChangeActivityStateBuilderImpl.changeState() → ChangeActivityStateCmd.execute() → DynamicStateManager.moveExecutionState() → DefaultDynamicStateManager / AbstractDynamicStateManager。
ChangeActivityStateBuilder 提供了按 Execution 或 Activity 迁移的多组 API:moveExecutionToActivityId、moveActivityIdTo、moveExecutionsToSingleActivityId、moveSingleExecutionToActivityIds,以及跨父流程、子流程和调用活动的专用方法。它表达的是通用“运行时状态改变”,不是一个预设好的“驳回服务”。
ChangeActivityStateCmd 会校验迁移指令和流程实例信息,再把工作交给 DynamicStateManager。AbstractDynamicStateManager 解析活动对应的活动 Execution,删除旧 Execution 及其关联数据,然后在目标 FlowElement 处继续执行。源码使用类似“Change activity to …”的删除原因记录技术迁移痕迹。
这说明两个事实:第一,驳回不是直接更新运行时表中的 ACT_ID_;第二,Flowable 负责维护引擎内部一致性,但“谁能退、退到哪里、业务状态是什么”仍由平台实现。
精确驳回为什么优先使用当前 executionId
下面是一个简化的、以当前任务 Execution 为粒度的迁移示例:
- runtimeService.createChangeActivityStateBuilder()
- .processInstanceId(processInstanceId)
- .moveExecutionToActivityId(currentExecutionId, targetActivityId)
- .processVariable("rejectRound", nextRound)
- .changeState()
currentExecutionId 必须来自仍然有效的当前任务,不能从旧历史任务中取值。targetActivityId 则应经过模型可达性、作用域和业务权限校验。
moveActivityIdTo(currentActivityId, targetActivityId) 在单实例、单活动路径中很方便;但 Flowable 源码解析 activityId 时,会收集流程实例下所有当前 activityId 相同的活动 Execution,并对多实例结构做分组处理。并行多实例或循环重入时,“这个节点”可能不止一个活动实例。因此,要精确驳回某一条路径,executionId 通常比 activityId 更明确;若产品要求把多条分支合并后一起退回,则应显式使用多 Execution 到单活动的迁移能力。
三、模型架构与运行机制
当前任务怎样结束,目标任务怎样产生
驳回前,当前任务 D1 和它的 Execution 属于 ACT_RU_* 运行时数据。执行状态迁移后,D1 不再是待办;相应 Execution 可能被删除、收缩或替换。目标节点会生成新的任务,例如 C2。即使 C1 曾经正常审批完成,C2 也必须使用新的 taskId,通常还会有新的 executionId。
不要做下面三件事:
- 把历史任务 C1 的结束时间清空,再插回运行时任务表;
- 直接修改 ACT_RU_EXECUTION.ACT_ID_ 指向目标节点;
- 删除 ACT_HI_TASKINST、ACT_HI_ACTINST 中“不好看”的历史记录。
这些操作绕过了监听器、实体关系、作业、变量、事件订阅和缓存,短期看似能退回,长期会造成执行树与历史表互相矛盾。
历史表记录的是事实,不是业务结论
Flowable 的 HistoricActivityInstance 表示某个活动的一次执行,HistoricTaskInstance 则包含正在等待、已经完成或已经删除的历史任务。ACT_HI_ACTINST 中可看到 activityId、executionId、taskId、开始结束时间、duration、deleteReason 等字段;ACT_HI_TASKINST 也记录 taskId、assignee、completedBy、endTime 和 deleteReason。
这两类数据能回答“引擎发生了什么”,却不能完整回答“为什么发生”。Change State 产生的“Change activity to taskC”只表明引擎做过技术迁移,不天然等于业务上的驳回、撤回、跳转或管理员干预。
因此建议额外保存结构化审批操作表:
| 字段 | 作用 |
|---|---|
| operation_id / idempotency_key | 防止按钮连点和消息重放 |
| process_instance_id | 关联流程实例 |
| source_task_id / source_activity_id | 记录从哪里操作 |
| target_activity_id | 记录退到哪里 |
| operation_type | REJECT、WITHDRAW、JUMP 等业务类型 |
| round_no / branch_id | 标记轮次和并行分支 |
| operator / reason / occurred_at | 审计操作者、意见和顺序 |
历史表是引擎审计底座,审批操作表是业务语义账本,两者不能互相替代。
四、核心场景与处理策略
连续驳回:同一节点会出现多轮新任务
考虑顺序流程 A 申请人提交 → B 部门经理 → C 财务经理 → D 总经理。第一轮 A1、B1、C1 已完成,D1 正在审批。随后发生三次连续驳回:
- 总经理在 D1 驳回到 C,D1 结束,创建 C2;
- 财务经理在 C2 再驳回到 B,C2 结束,创建 B2;
- 部门经理在 B2 继续驳回到 A,B2 结束,创建 A2。
此时历史中同时存在 A1/A2、B1/B2、C1/C2 和 D1。activityId 仍只有 A、B、C、D 四个模型节点,但 taskId 已经至少产生八个任务实例。D1、C2、B2 的结束方式还是迁移删除,而 A1、B1、C1 是正常完成。

图 2:连续驳回每次都会关闭一个当前任务并创建下一轮任务,节点相同但任务实例和轮次不同。
连续驳回为什么最容易把“上一步”算错
常见实现会查询 HistoricTaskInstance,按 endTime 倒序取第一条,再把它的 taskDefinitionKey 当作驳回目标。连续驳回后,这种算法可能得到刚刚被迁移删除的 C2,而不是业务希望的 B;在毫秒精度相同、同一事务创建多条历史活动时,单靠时间排序也可能不稳定。
Flowable 8.0.0 在 ACT_HI_ACTINST 增加了 TRANSACTION_ORDER_。同一引擎事务内创建多个活动实例时,它能提供更明确的先后辅助信息,但它不是跨服务的全局业务序号,也不能自动判断哪个历史任务具有“可驳回资格”。
另外,历史中还可能混有自动服务任务、网关、边界事件、已取消会签任务和来自另一并行分支的用户任务。简单地“找最近一条”无法分辨:
- 它是否属于同一业务轮次;
- 是否属于当前并行分支;
- 是否处于同一子流程作用域;
- 是正常完成,还是驳回产生的删除记录;
- 操作者是否有权限把流程退到该节点。
五、数据、规则与状态设计
连续驳回的正确目标应该怎样计算
稳定方案不是从历史表猜答案,而是在每次正常流转和驳回时维护业务路径与轮次。可以把“可返回节点”建成一个受约束的已访问节点栈或操作边集合:
- R1:A1 → B1 → C1 → D1;
- R2:D1 --reject--> C2;
- R3:C2 --reject--> B2;
- R4:B2 --reject--> A2。
计算目标时至少应用四层过滤:模型层检查目标节点仍存在且类型允许;作用域层限制在同一流程或规定的父子流程边界;路径层限制在当前活动分支实际到达过的节点;权限层根据发起人、当前审批人、管理员和业务状态确定可选范围。
如果产品提供“退回上一审批人”,上一审批人应来自当前分支的业务操作链;如果提供“退回任意已办节点”,则展示通过上述过滤的节点实例,并同时显示轮次、办理人和完成方式,不能只显示重复的节点名称。
并行网关中的驳回要先定义令牌策略
假设法务和财务由并行网关同时审批,财务要驳回到申请人。平台必须明确:
| 产品语义 | 执行树动作 |
|---|---|
| 仅退回财务分支 | 只移动财务 execution;法务继续运行 |
| 整体退回申请人 | 取消或收拢全部并行 execution,再创建申请人任务 |
| 等法务结束后再退回 | 记录驳回意图,在汇聚或协调节点执行 |
不能只移动一个 execution 到并行网关之前,同时保留另一条分支继续向 Join 推进,否则可能产生额外令牌、重复执行,或让 Join 永久等待。Flowable 官方 8.0.0 的 change-state 测试目录专门覆盖 Gateway 场景,也说明状态迁移必须按拓扑验证,而不是把所有模型都当成顺序流程。
六、工程实现与系统集成
多实例会签中的驳回要先定义人员范围
会签节点有 A、B、C 三个任务,B 点击驳回时,至少存在三种业务口径:只终止 B 的实例,其他两人继续;结束整个会签 Activity Body 并全部退回;先等待其他审批人完成,再按计票规则决定是否退回。
如果选择整体退回,剩余任务必须统一标记为取消或因状态迁移删除,已提交意见保留,未提交草稿按制度处理。若只移动 B 的 execution,流程设计还要保证 B 的退回路径不会破坏多实例计数和完成条件。Flowable 源码对 activityId 解析包含多实例分组逻辑,官方测试也单独设置了 ChangeStateForMultiInstanceTest,因此不能把“节点驳回”误解成“随便选一条 taskId”。
跨子流程和调用活动为什么不能普通跳转
嵌入式子流程、事件子流程和 Call Activity 都形成作用域边界。跨边界驳回时,父 Execution、子流程实例、边界事件订阅和局部变量必须一致处理。ChangeActivityStateBuilder 为“移动到父流程活动”“从父流程进入子流程”“移动到子流程活动”等场景提供专用 API,源码还会校验要终止的子流程 Execution 是否已经完整纳入迁移范围。
平台应把“允许跨作用域驳回”做成显式模型配置。默认只允许在同一作用域内选择目标;确需跨边界时,应采用专用迁移方法并执行集成测试,不能把子流程内部 activityId 直接传给普通 moveExecutionToActivityId。
七、安全、性能与治理要求
事务、锁和幂等决定驳回能否安全落地
驳回按钮可能被连点,移动端可能重试请求,定时任务也可能同时完成当前待办。推荐处理顺序如下:
- 使用 taskId 和业务版本号确认任务仍处于活动状态;
- 校验操作者、目标节点、流程作用域和业务单据状态;
- 通过 idempotencyKey 防止同一次请求重复执行;
- 锁定业务单据或使用乐观锁,写入审批操作记录;
- 调用 ChangeActivityStateBuilder 完成状态迁移;
- 查询并断言迁移后的活动任务集合符合预期;
- 更新待办投影和业务状态,发布可靠事件。
引擎、业务表和操作记录处于同一数据源与事务管理器时,可尽量放在同一本地事务中;跨库、跨服务时,应使用 Outbox、可重试消费和补偿机制。不能先把单据改成“已驳回”,再调用引擎而不处理失败。

图 3:运行时迁移、引擎历史和业务操作账本分层保存,再生成用户可理解的审批轨迹。
审批轨迹应该怎样展示才不会误导
流程图高亮不能只根据 activityId 是否出现在历史表中。连续驳回后,A、B、C 都同时存在已完成轮次和重新激活轮次。建议轨迹按“节点 + 轮次 + taskId”展示,并区分四种视觉状态:正常完成、当前活动、驳回/迁移结束、系统取消。
排序可采用发生时间、引擎事务顺序和业务 operation sequence 的组合;跨服务最终以业务操作账本的单调序号或可靠事件序号解释业务顺序。deleteReason 用作技术证据,operationType 和审批意见用作用户语义。
一条清晰的轨迹可以显示为:“总经理 D1 驳回至财务 C(第 2 轮)”“财务 C2 驳回至部门 B(第 2 轮)”,而不是把 D1、C2、B2全部显示为普通“已完成”。
八、平台落地、测试与选型
低代码工作流平台需要补齐哪些能力
引擎 API 只是底层能力。云程低代码开发平台这类面向业务人员的工作流平台,还需要在模型、运行时和审计层增加一组产品化约束:
- 在设计器中配置允许驳回的范围、作用域和分支策略;
- 发布时检查目标可达性、并行 Join、多实例和子流程边界;
- 为每次任务生成轮次、分支和参与人快照;
- 以审批操作表统一表达驳回、退回、撤回、撤销和跳转;
- 迁移后自动校验待办数量、业务状态和流程变量;
- 在流程图上按轮次展示历史,而不是粗粒度高亮节点。
![在这里插入图片描述]()
产品界面还应把“退回上一审批人”“退回发起人”“退回指定已办节点”和“管理员自由跳转”做成不同权限和风险等级,避免一个通用按钮承担所有语义。
上线前至少覆盖十二组测试
- 顺序流程从 D 驳回 C,确认 C 生成新 taskId;
- D→C、C→B、B→A 连续驳回,确认轮次和轨迹顺序;
- 同一请求重复提交,确认只发生一次迁移;
- 当前任务已被他人完成时,驳回请求失败且业务状态不变;
- 同 activityId 存在多个活动 Execution 时,精确移动指定 execution;
- 并行分支只退一支和整体退回两种策略分别验证;
- 并行 Join 前后驳回,确认令牌数量和下游调用次数;
- 会签一人驳回、整体驳回和提前结束分别验证;
- 子流程内部驳回与跨父子作用域驳回分别验证;
- 迁移后的历史 task、activity、deleteReason 和操作账本一致;
- 业务更新失败、引擎迁移失败和事件发布失败均可恢复;
- Flowable 升级后回归执行树、历史排序和连续驳回结果。
断言不能只看“页面出现了一个新待办”。还要检查活动 Execution 数、taskId 是否更新、旧任务结束方式、并行分支集合、业务单据版本和审计事件是否一致。


浙公网安备 33010602011771号