工作流自动节点的同步与异步:两条实现路径的取舍
> 自动步骤要不要占一个事务、要不要落作业队列,是流程引擎设计里最实际的分歧之一。本文从建模单元、触发时机、失败恢复与可观测性四个角度做对照,并给出实施侧的取舍建议。 ## 一、过程节点是什么:从一个枚举说起 `NodeExt.ts` 里 `WhoExeIt` 是节点属性面板上的一个系统枚举下拉: ``` @0=操作员执行 @1=机器执行 @2=混合执行 @3=流水线执行 @4=过程执行 ``` 它回答的问题是「这个节点由谁做完」。其中 `4` 就是过程执行(过程节点): - **由系统执行**,不需要人点发送; - **在同一次发送请求内同步连发**,不是等定时任务扫描; - **仍然是一个节点**:有接收人、有方向条件、有发送前事件、有轨迹。 对照该平台自己的另外几种执行方式,就能看清过程节点的定位: |WhoExeIt|名称|触发时机|是否进普通待办| | --- | --- | --- | --- | |0|操作员执行|人点发送|进| |1|机器执行|后台定时扫描|不进(查询排除)| |2|混合执行|打开表单时先试发|失败才进| |3|流水线执行|工位扫码发送|进独立工位通道| |4|过程执行|当次发送请求内连发|不进| 关键在最后一行:**过程节点和机器节点都不是「人」,但它们的触发路径不同**。机器节点把继续流转交给定时任务(`AutoRun_WhoExeIt.cs` 里那条 `WHERE WhoExeIt IN (1,2)` 的 SQL,明确把 3、4 排除掉);过程节点则在发送线程里当场串起来跑完。 --- ## 二、该引擎过程节点怎么跑起来 ### 2.1 属性落在节点,运行时抄到待办 发送生成待办时,引擎把节点上的值抄到待办行(`WorkNode.cs`): ``` wl.WhoExeIt = town.HisNode.WhoExeIt; // 设置谁执行它 ``` 批量的插入语句同样带上这一列(`WorkNodePlus.cs`): ``` INSERT INTO WF_GenerWorkerList (WorkID,FK_Emp,FK_Node,Idx,AtPara,IsEnable,WhoExeIt) VALUES(...) ``` 所以 `WhoExeIt` 不是设计期的装饰字段,而是**运行时调度依据**:待办查询、定时任务、消息推送、流水线列表、过程页标签读的都是它。 ### 2.2 发送循环内的同步连发 过程节点不进定时任务,由发送时同步连发。逻辑可以概括为: ``` 发送完成后 while 下一节点的 WhoExeIt == 4 且流程未结束 且存在待办: 切换到该待办处理人(Port_Login) 以系统身份走完整发送(方向条件、接收人、发送前事件仍生效) 把本次过程返回叠加进总消息 切回原操作员 ``` 「同步」两个字的含义是:这一次 HTTP 请求不返回,直到这一串过程节点跑完、或者某一步抛错停下。这跟机器节点「先落一条待办、后台慢慢发」是两条完全不同的路径。 ### 2.3 明确不进定时任务 `AutoRun_WhoExeIt.cs` 里的注释写得很直白: ``` // 只捞机器执行(1)、混合执行(2)。流水线(3)、过程执行(4)不能进定时任务:4 由发送时同步连发。 string sql = "SELECT WorkID,FID,FK_Emp,FK_Node,FK_Flow,AtPara FROM WF_GenerWorkerList WHERE WhoExeIt IN (1,2) AND IsPass=0 AND IsEnable=1 ORDER BY FK_Emp"; ``` 这条边界非常重要:**配置人员如果把一串连续系统节点误设成机器执行(1),它们就会变成一条条等待扫描的待办,时延、事务边界、失败位置都和过程执行(4)不一样。** 这是实施时最容易踩的坑。 ### 2.4 过程可观测:时间轴与流程图 过程节点若仍弹一句「发送成功」,集成场景基本没法用——外部系统可能跑几十秒,人不知道卡在哪一步、返回了什么。该引擎用节点属性 **发送后转向(6/7)** 接上过程执行页(`MyProNodes.vue`): 1. 工具栏先打开大窗(默认时间轴或流程图),状态置为「执行中」,再并发调用发送; 1. 过程执行页轮询轨迹,后端额外下发全流程节点(含 `WhoExeIt`),前端据此打标签: ``` // 前端模块源码目录 const exeLabel = (whoExeIt?: number) => (Number(whoExeIt) === 4 ? '[过程执行]' : '[人工执行]'); ``` 1. 时间轴展示节点名、并行分支、处理人、耗时、下达时间、**过程返回**; 1. 发送返回里的过程明细(JSON、许可 / Kafka / FTP 报文)优先贴到「过程执行」的完成项上,并过滤引擎套话; 1. 超时约一分钟加强「外部系统处理中」提示。 后端在取待办数据时专门补了一张表,把节点的 `WhoExeIt` 带给前端: ``` // ProNodes:全流程节点含 WhoExeIt,用于时间轴 [过程执行]/[人工执行] 标签 Nodes flowNodes = new Nodes(this.FlowNo); ds.Tables.Add(flowNodes.ToDataTableField("WF_FlowNodes")); ``` 人看到的不再是黑盒自动任务,而是**人机混合轨迹**:人工节点显示处理人头像,过程节点显示系统返回与等待时长。 ### 2.5 过程节点不是「跳过引擎」的脚本钩子 这是设计上必须强调的一点:连发过程仍走**完整发送**——`ExecEvent.DoNode(EventListNode.SendWhen, ...)` 照常触发,方向条件、接收人规则、阻塞模式全部生效(见 `WorkNode.cs` `NodeSend`)。身份切到待办处理人再切回,审计主体仍然是人,而不是一个匿名的「引擎账号」。 外部 API 失败时,抛出带「系统执行发送」前缀的异常,流程停在**具体那个过程节点**,前端过程页展示失败节点与返回原文。对国内审批留痕的习惯来说,这比「引擎账号执行、活动上无办理人」更贴近预期。 --- ## 三、Flowable / Camunda 怎么表达「系统执行」 ### 3.1 换图元,而不是改属性 BPMN 2.0 的答案不是「节点的执行方式」,而是**换一种活动类型**: |BPMN 图元|职责|与该引擎大致对等| | --- | --- | --- | |User Task|人工等待,TaskService 查询/签收/完成|操作员执行(0)| |Service Task + 类/表达式|引擎内同步调用 Java 逻辑|过程执行(同步部分)| |Service Task + asyncBefore|落作业,Job Executor 续跑|机器执行(1)| |External Task(C7/Flowable)/ Job Worker(C8)|外部进程按类型拉取、加锁、完成/失败|无直接一等对等| |Timer Event|到点继续|延期发送 / 机器执行| |Script / Business Rule Task|脚本或决策表|无独立图元| 同一张流程图上,人做的、系统做的、外部系统做的是**不同的图形元素**。图上信息量大、语义精确,代价是实施人员要熟悉规范。 ### 3.2 Service Task 的两种形态 **同步委托**(`camunda:class` / `flowable:class`):引擎事务里直接调用 Java 代码。这跟该引擎过程执行的「同一次请求内连发」最接近——**都在调用方线程/事务里把逻辑跑完**。 **异步延续**(`asyncBefore="true"` / `flowable:async="true"`):到达活动时先提交当前事务、落一条作业,返回调用方,由 Job Executor 用**独立事务、独立线程**续跑。官方文档对它的定位是「引入事务边界 / 保存点」,而不只是「让节点自动跑」: > By setting the `asyncBefore='true'` attribute we introduce an additional save point at which the process state will be persisted and committed to the database. A separate job executor thread will continue the process asynchronously by using a separate database transaction. ### 3.3 Job Executor 在解决什么 Flowable 的作业在 API 调用期间就写入 `ACT_RU_JOB` 表,成功的提交会触发 Async Executor 执行;执行器有可配置线程池,并有「acquire」线程去抢占未被加锁的作业。Camunda 7 的 JobExecutor 默认使用 **exclusive job**:**同一流程实例的排他作业同一时刻只执行一个**,避免并行合流处产生乐观锁冲突。 失败处理是这套模型的核心价值: - 作业失败会**自动重试**(Camunda 默认重试 2 次,可用 `camunda:failedJobRetryTimeCycle` 配成 `R5/PT5M` 这类策略); - 抛普通异常 → 走重试;抛 `BpmnError` → 跳过重试,交给边界错误事件; - **重试耗尽 → 生成 Incident(事故)**,人在 Cockpit / Operate 里处理。 对照该引擎:机器执行用「扫待办、发一次、失败记日志」近似「有活就发」,**没有同等的作业锁协议、重试退避和事故工作台**。社区/集群部署下要靠部署约定而不是引擎级 job lock。这一点对比必须写明:在高并发自动节点与失败恢复上,BPMN 作业模型更完整。 ### 3.4 External Task 与 Job Worker:把「自动步骤」也变成待办 External Task 的类比是:**服务任务也是一种待办,只是领取者不是人而是程序**。`fetchAndLock` → 做事 → `complete` / `failure`,工人可多语言、可水平扩展,引擎不持有长调用。 Camunda 8(Zeebe)把这条路走得更彻底:Service Task 到达即生成一条持久化 job,worker 用 `ActivateJobs`(带 type、worker、timeout、maxJobs)拉取,再回 `CompleteJob` / `FailJob`(递减重试、可设 backoff)/ `ThrowError`。超时会重新分配作业,因此**投递语义是 at-least-once,处理逻辑必须幂等**。文档明确指出:worker 与引擎之间**不存在横跨两边的技术事务**,要靠业务事务来兜一致性。 这条路的代价是运维更重:要跑一套消费者、要处理超时与重试、要保证幂等。换来的是跨语言、长耗时、可独立扩缩的集成能力。 --- ## 四、三类实现的核心区别 ### 4.1 一张总表 |维度|该引擎(WhoExeIt=4)|Flowable / Camunda 7|Camunda 8(Zeebe)| | --- | --- | --- | --- | |建模方式|同一节点改属性|换 BPMN 图元|换 BPMN 图元| |过程执行的默认形态|发送请求内同步连发|Service Task 同步委托|默认走 Job Worker| |异步执行载体|待办行(WhoExeIt 区分)|ACT_RU_JOB|持久化 job 队列| |调度者|发送线程 / 定时任务|Job Executor|Worker 轮询 / 推送| |作业锁|无引擎级锁|exclusive job|job 独占 + 超时重分配| |重试|需再次发送或人工处理|引擎自动重试|worker 上报递减重试 + backoff| |失败落点|具体过程节点,返回带异常原文|作业/事故(Incident)|job 失败/事故,Operate 可见| |人怎么看|业务侧过程执行页(时间轴/流程图)|运维侧 Cockpit / 历史活动|运维侧 Operate| |事务边界|一次请求内可能很长|asyncBefore 切分保存点|每步独立提交、at-least-once| |典型优势|同一套节点生命周期、配置面直接、过程可视化给业务看|规范、作业可靠、生态大|分布式、多语言工人、水平扩展| ### 4.2 差异一:自动步骤「还算不算原来那个节点」 该引擎的过程节点仍是**胖节点**:接收人、方向条件、退回、撤销、轨迹、表单权限都挂在它身上。把过程节点从「操作员」改成「过程执行」,这些能力**一个都不用重配**。 BPMN 里 Service Task 是**瘦活动**:它天然没有办理人、没有中国式审批的退回/加签/抄送。要在自动步骤上做这类事,得靠监听器、执行表达式或扩展属性自己补。 这不是谁优谁劣,而是**复杂性放在了哪里**: - 该引擎把复杂度放在「同一套节点生命周期的正确性」上——**共享生命周期,就要自己处理待办过滤和消息短路**。机器节点仍然生成待办行(只是查询排除),若还发「请审批」到达消息,产品就是错的。所以 `WorkNode.cs` 里对机器节点显式短路: ``` if (this.town != null && this.town.HisNode.WhoExeIt == 1) return; // 机器执行不推到达消息 ``` - BPMN 把复杂度放在「规范与作业协议」上——Service Task 往往**根本不生成 User Task**,自然不会进 Tasklist,也就没有这个正确性义务。 ### 4.3 差异二:触发时机的哲学 **该引擎过程执行**优化的是「人点一次发送、盯着集成跑完」。它把一串系统节点串在同一次请求里,业务上等价于「这一批动作是一件事」。 代价同样清楚: - HTTP 超时、中途宕机、逐步重试**不是这条路径的强项**; - 请求内事务可能拉得很长; - 长耗时、需重试的外部调用,更适合机器执行(定时再发)或自建工人,而不是无限拉长当次请求。 **Camunda 7 的官方建议恰恰相反**:即便一串自动活动可以在同一事务里跑完,也建议用 `asyncBefore` / `asyncAfter` 切开,避免长事务与锁竞争。**Flowable / Camunda 用作业把外部 IO 移出引擎事务,这是多年踩坑后的默认结论。** |环节|该引擎的过程执行|Flowable / Camunda 的异步作业| | --- | --- | --- | |起点|人工点发送|人工完成任务并提交| |第 1 步|过程节点 A,同一请求内连发|提交事务,落作业 1| |第 2 步|过程节点 B,同一请求内连发|独立事务执行活动 A,落作业 2| |第 3 步|过程节点 C,同一请求内连发|独立事务执行活动 B,落作业 3| |收尾|汇总结果返回调用方|独立事务执行活动 C| |事务边界|整条链路一个事务|每个活动一个保存点| |失败后|停在具体过程节点,返回异常原文|作业重试,耗尽后生成事故| 左边一条链在同一事务里走完,快、直观、可视化好;右边每一步都有保存点,崩了能从作业恢复,但多了调度与运维成本。 左边一条链在同一事务里走完,快、直观、可视化好;右边每一步都有保存点,崩了能从作业恢复,但多了调度与运维成本。 ### 4.4 差异三:失败之后谁看见、谁重试 ||该引擎过程执行|Camunda 同步委托|异步作业 / Worker| | --- | --- | --- | --- | |失败位置|当前过程节点,返回带「系统执行发送」前缀|当前活动(事务回滚范围取决于实现)|作业/工人失败,实例停在该活动| |重试|需再次发送或人工处理|同请求内难自动重试|引擎/worker 重试 + 事故| |人怎么看|业务侧过程执行页失败态 + 返回原文|异常栈 / 业务自绘|Operate / Cockpit(运维视角)| 过程执行页把集成报文展示给**业务人员**;Cockpit / Operate 面向**运维人员**。两者不互相替代。 --- ## 五、场景怎么选 |场景|更贴该引擎的用法|更贴 Flowable / Camunda 的用法| | --- | --- | --- | |人点发送后连续调内部 API,要盯进度|过程执行(4)+ 发送后转向 6/7|一串 Service Task + 自建进度页;要可靠则每步 async| |夜间过账、不占待办|机器执行(1)|asyncBefore Service Task| |打开即过、过不了再填|混合执行(2)|网关拆分,或监听器 Complete| |工位扫码过站|流水线执行(3)|User Task + 自研工位端| |跨语言、长耗时、需重试扩缩|不宜单靠过程同步连发|External Task / Job Worker| |到点再发已填好的单|延期发送(改执行方式)|Timer 事件(图上可见)| 混合使用也常见:核心审批用该引擎,超长集成用独立工人回写后「发送」推进。那是架构组合,不是某引擎的失败。 --- ## 六、给实施与二次开发的要点 1. **连续系统步骤请设过程执行(4)**,不要设成机器执行(1)——后者进定时扫描,前者在当次发送内连发,时延与事务边界不同。 1. **过程节点仍要配接收人**:连发会按待办人切换身份,便于权限与审计。 1. **外部调用放在节点发送事件 / 过程里**,返回信息会出现在过程页「结果」区。 1. **需要人盯进度时**,上一人工节点的「发送后转向」选 6 或 7。 1. **长耗时、要重试的外部集成**,不要无限拉长过程执行的同步请求——改用机器执行或独立工人。 1. **机器/流水线节点默认不进普通待办**:若业务必须通知人,用独立消息策略,不要依赖默认到达推送。 --- ## 七、收束 三家都在解决同一件事:**人工等待与系统继续如何在一条流程里共存**。分歧不在「有没有自动节点」,而在**建模单元**和**调度哲学**: - Flowable / Camunda 问的是:活动是什么类型,令牌和作业怎么往前走; - 该引擎问的是:这个节点由谁、按什么节奏执行,同一节点进哪条通道。 实现上可以记成:**同一节点、五种执行方式、同一套发送**。机器靠待办扫描,过程靠当次连发,混合靠打开试发,流水线走工位接口;`WhoExeIt` 同时管待办过滤、消息短路和轨迹标签。 过程节点(`WhoExeIt=4`)是这套设计里最典型的一个切面:它把「一串系统动作」仍然当成节点来管——有接收人、有事件、失败停在具体节点、业务人员能在过程页看见人机混合轨迹。而 Flowable / Camunda 用作业模型换来了**可恢复、可重试、可多语言扩展**的自动步骤。复杂性放的位置不同,验收标准就不该混用。 --- ## 附:本文涉及的代码与文件索引 |文件|作用| | --- | --- | |前端模块源码目录|WhoExeIt 前端属性定义(@4=过程执行)| |前端模块源码目录|设计器枚举与写回节点| |引擎源码模块|后端节点实体枚举定义| |引擎源码模块|WhoExeIt 字段常量| |引擎源码模块|Node.WhoExeIt 取属性值| |引擎源码模块|待办抄写 WhoExeIt、发送前事件、机器节点消息短路| |引擎源码模块|待办批量插入带 WhoExeIt| |引擎源码模块|机器/混合节点定时执行(排除 3、4)| |引擎源码模块|下发全流程节点 WhoExeIt 给过程页| |前端模块源码目录|过程执行页时间轴 / 流程图 / 标签| |引擎源码模块|流水线(WhoExeIt=3)待办接口| |引擎源码模块卫星设计院任务流程控制系统/...xml|实际流程中过程节点(WhoExeIt=4)的配置样例|