ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

工作流引擎中人工响应的优雅设计:进度树与表单流的双流解耦

工作流引擎中人工响应的优雅设计:进度树与表单流的双流解耦

设计工作流引擎的时候,大多数人都默认流程是一路跑到底的:任务进来,自动处理,输出结果。但真实业务根本没那么听话。我见过太多流程,跑着跑着突然需要停下来问人——报销单金额超限要找确认、发布单灰度比例要负责人拍板、材料缺了一份要发起人补交。很多引擎把这种场景当成边缘情况来处理,结果就是人工参与环节写得极其拧巴:要么把审批节点伪装成普通任务节点,要么把表单数据硬塞进流程上下文里,最后状态和数据纠缠在一起,线上排查问题的时候一个头两个大。

openJiuwen 这个开源项目里的 SwarmFlow 模块,就是专门来收拾这个局面的。它的核心思路非常直接:工作流跑到一半要问人,那就大方承认需要问人,把"等待人工响应"做成流程里一个一等公民状态;同时把进度树和表单流拆成两条独立的流——进度树只管流程执行到哪一步、节点处于什么状态;表单流只管这次询问需要收集什么信息、收集到了什么。两条流通过节点实例和表单实例的关联关系对账,互不干扰。这篇文章我会把这套设计的来龙去脉、状态机细节、踩过的坑都摊开讲一遍,给正在做流程引擎、或者被人审批环节折磨得够呛的同行一个参考。

1. 流程引擎最容易忽略的事:自动逻辑和人工响应根本不是一回事

1.1 把人工节点当普通节点,是所有后续问题的起点

工作流引擎第一版通常都做得非常简单:节点和节点之间串起来,上一个节点输出,下一个节点消费,跑完收工。自动节点确实是这样,输入确定、输出确定、可以快速重试,失败了你让它再跑一次就行。但人工节点完全不是这个脾气:它没有确定的完成时间,它需要某个真实的人在一个界面上做操作,这个人可能半天不回、可能填错、可能填了一半关掉页面、甚至可能填完之后反悔要改。

这些差异不是"加个超时时间"就能解决的。我见过不少团队的做法是:给每个节点加一个assignee字段,然后暴露一个接口让人提交结果,后台继续往下走。看起来能用,但真正跑起来全是隐患——超时了流程要走向哪个分支?人被换岗了任务怎么转移?表单内容被退回修改后,已经执行完的节点能不能保持不动?这些问题在"人工节点=普通节点+一个提交接口"的模型里,根本没有街可走。

SwarmFlow 在这里做了一个比较彻底的决定:从状态机层面把"需要人工响应"当成一种独立的节点状态来建模。它不再追求"所有节点统一抽象",而是承认自动执行和人工响应是两种截然不同的执行模型,进度树需要为后者专门开一条路。

1.2 "问人"至少有四种完全不同的形态

人工响应的需求也不是铁板一块。做系统设计的时候,最忌讳把"等用户提交一个表单"当成唯一的交互模式。我梳理了一下实际业务里"跑到一半要问人"的场景,大致有四种形态:

形态典型场景暂停语义唤醒条件
审批型金额超限、权限申请阻塞当前节点,等待决策审批通过或拒绝
补料型材料缺失、参数不完整阻塞当前节点,等人补充内容表单提交且校验通过
决策型A/B 分支选择、方案取舍阻塞当前分支,等人拍板用户做出选择后按分支继续
反馈型人工执行线下动作阻塞当前节点,等待结果回填结果写入并确认

这四种形态如果全都抽象成"提交一个表单"就太亏了。比如审批型,它需要的不是表单收集,而是一个决策动作加上可选的意见;补料型则需要反复退回重填的可能;决策型干脆是要影响进度树下一步走向哪根分支的。SwarmFlow 的做法是:统一使用"等待人工响应"作为挂起状态,但每个节点声明自己的响应模式(decision / form / confirmation),进度树根据模式解析响应内容,决定下一步是继续、驳回还是改道。这样既out做了语义区分,又不至于把引擎核心复杂化。

2. 进度树和表单流:为什么拆成两条,而不是揉在一起

2.1 进度树是一棵会暂停的树,它不是一条会跑完的线

很多轻量级引擎把流程实例做成一个链表,节点线性执行,简单是简单,但一遇到并行分支、条件循环、子流程嵌套就彻底露馅。SwarmFlow 从一开始就决定用树来组织流程实例,所以叫"进度树",而不叫"进度链"。

进度树的每个节点实例需要保存这些信息:节点定义 ID、实例 ID、父节点引用、节点类型、当前状态、关联的表单实例引用、进入时间和结束时间。其中状态字段是这个设计的心脏,它一共有这么几个取值:

  • pending:还没轮到执行
  • running:正在执行
  • waiting_human:挂起等待人工响应
  • completed:正常完成
  • failed:执行失败
  • cancelled:被取消

关键在于waiting_human这个状态。它不是一个临时概念,不是"running 的附属子状态",而是和 running 平级的正式状态。节点一旦进入 waiting_human,整棵子树就冻结了,所有并行的子分支走到这个节点的边界都会被挡住。后台扫描器看到这个状态,就知道该去检查依赖的表单流;外部回调来了,也知道应该定位到哪棵树的哪个位置去唤醒。整个体系里,这个状态是一切人工介入逻辑的支点。

2.2 表单流是业务数据的仓库,不是流程上下文的附属品

第一批做流程引擎的人,很容易把表单数据直接塞进流程上下文的 Map 里:流程变量里写个formData,人工节点提交回来往 Map 一放,下一个节点再读。这个方案对小 demo 没问题,但真实业务一跑就发现:流程上下文被表单数据越撑越大,序列化负担重、字段变更影响面广、人工退回修改时完全没办法做版本比较。

SwarmFlow 把表单流拿出来单独立了模型。一个表单实例有自己完整的生命周期:draft(草稿)、submitted(已提交)、validating(校验中)、accepted(已采纳)、rejected(已退回)、timeout(超时作废)。它和任何流程定义都无关,只和实际的业务对象绑定——比如一张入职信息表,它可以先后被"背调节点"和"合同节点"引用,但表单本身自始至终是独立的一份数据资产。

这样设计最大的好处是上下文干净。进度树不背着表单数据走,树只记录"我在哪个节点、处在什么状态、引用了哪个表单实例"。需要展示表单内容的时候,按引用 ID 去查表单流;需要修改表单内容的时候,也走表单自己的版本机制,完全不用惊动流程引擎。拆开之后的读写路径都单一了,维护起来舒服得多。

为了说明这种绑定关系,我贴一个简化版的节点实例结构,实际生产环境里可能还要加更多字段,但核心思路是这样:

{ "node_instance_id": "node_20001_x7", "workflow_instance_id": "wf_10001", "parent_node_instance_id": "node_20000_a3", "node_type": "human_form", "status": "waiting_human", "mode": "form", "form_refs": [ { "form_instance_id": "form_50012", "role": "primary", "required": true } ], "entered_at": "2024-05-17T10:00:00Z", "exited_at": null }

form_refs这个数组很关键,它意味着一个节点可以关联多个表单。比如一个"入职复核"节点,可能同时要求提交体检报告、学历证明、背调授权书三张表,三张都到位了节点才能醒过来。

2.3 解耦后的桥:双向绑定与多对多关系

拆成两条流之后,接下来必须回答的问题就是:两条流怎么对上?SwarmFlow 用的是一个双向的引用关系。进度树节点实例持有表单实例的引用清单;表单实例那边,也会记录自己当前被哪些节点实例引用。

这个关系在某些场景下是多对多的:一张表单可能被多个节点引用,一个节点也可能引用多张表单。举个例子:员工入职流程里有一张"个人信息确认表",它在"资格初审节点"要被读取,在"合同签署节点"又要被再次确认。如果表单直接内嵌在节点里,这一张表就要复制两份,后面员工改了一次手机号,两个副本就打架了。表单独立流之后,它只维护一份数据,两个节点各存各的引用,读到的是同一份最新版本,数据一致性从根上就保证了。

还有一个细节值得注意:被引用的表单不一定都是"等人填"的。有的节点引用表单是只读用途,比如从表单里取几个字段拼下游参数;只有mode为human_form的节点,才真正把表单当作人工响应的入口。这个设计在做权限控制的时候特别有用——只读引用无需表单编辑权限,只有挂起节点才需要,避免权限被过度放大。

3. 停下来等"问"的机制:从挂起到唤醒的完整链路

3.1 挂着等不是线程 sleep,而是持久化状态迁移

我第一次设计这个功能的时候,第一个念头是写个线程在那里wait(),直到超时或者有人提交。后来很快意识到这套思路走到生产环境就完了:应用一重启线程就没了、分布式部署下提交回调根本不知道去哪台机器找那个线程、更别说流程实例那么多,线程数量根本扛不住。

正确的做法是把"等待"建模成状态,而不是把"等待"建模成阻塞调用。节点进入waiting_human的时候,引擎就做两件事:把节点状态持久化到数据库,把对应的表单实例置为可提交状态。然后这个流程实例就从内存里消失了,引擎完全不需要为它保留任何资源。当有人提交了表单,或者超时定时器触发,再有一条消息把流程实例从数据库里重新加载出来,判断当前状态是不是还在等待,然后决定是继续往下走还是走超时分支。

这个机制带来的工程收益非常明显。我做过一个小压测:5000 个流程实例同时挂在waiting_human状态上,因为内存里根本不用存着它们,引擎整体内存占用几乎不涨。要换作线程阻塞模型,5000 可能已经是个相当危险的数量级了。

3.2 三种唤醒源:表单提交、定时器、外部回调

挂起之后怎么醒过来,是这套设计里最需要想清楚的事。SwarmFlow 目前支持三种唤醒源,它们的使用条件和取舍不太一样,我整理成一张表:

唤醒源触发方式适用场景注意点
表单提交事件表单流提交成功后发出事件补料、反馈类节点要做好幂等,防重复唤醒
定时器后台扫描超时节点审批超时自动挂起、自动驳回扫描频率要控制,别打成热点
外部回调对接企业通讯工具等审批人在聊天工具里点击同意需要统一的外部网关适配层

表单提交事件是最主流的唤醒源。表单流那边一收到提交,就走校验逻辑,校验通过后把表单状态改为accepted,然后发布一个"表单已采纳"事件。引擎侧监听到这个事件,拿着事件里的form_instance_id反查所有引用它的节点实例,把还在waiting_human里等这张表的节点挑出来,挨个唤醒。

有一个很隐蔽的场景差点坑了我:表单提交事件发出之后,表单状态已经改了,但流程引擎这边如果更新节点状态失败,整个链路就不一致了。这里一定要把"表单状态更新"和"事件发布"放在同一个事务里,或者退一步做成事件表加定时投递,绝对不能事件先发出去、表单状态后面才改,不然流程被唤醒之后去读表单,读到的还是 submitted 而不是 accepted,一堆边界异常就全出来了。

3.3 唤醒动作拆成两步:变更状态,再调度继续

等节点被唤醒,也不是直接执行下一个节点就完事。我踩过一次坑:节点唤醒后,pipeline 的下一步节点立刻开始跑,结果发现下一步依赖于表单里某个字段加工出来的参数,而字段还没有来得及做数据整理。后来我把唤醒拆成了两步——先更新节点状态为running,把表单参数整理好塞给后续执行的上下文,等这些都落库了,再调度执行引擎继续推进。中间如果失败,节点状态还在 running,后台扫描器可以自动做补偿重试。这种"先记账后干活"的方式,和数据库事务里的两阶段提交思路有点像,代价不大,鲁棒性提升非常明显。

4. 两条流对不齐怎么办:对账、幂等、版本回溯

4.1 你永远不知道用户会不会手抖点两次提交

分布式系统里,重复提交这种事防不胜防。用户以为自己没点成功,又点了一下;或者前端请求超时后自动重试,后端收到两个内容一模一样的提交。如果处理不干净,最轻的后果是表单流里出现两条提交记录,重的后果是流程被连续唤醒两次,后面的节点被执行了两遍,产生重复的审批意见或者重复的对外请求。

SwarmFlow 的做法是在表单提交接口前面放一层幂等护盾:每个表单实例在创建的时候生成一个submit_token,前端每次提交必须带上这个 token,后端拿到之后先查这个 token 是否已经消费过,消费过就直接返回上一次的结果,不再重新处理。像很多做法一样,这个 token 的唯一约束要落在数据库层面,靠应用层判断总是有并发漏洞。

比重复提交更恶心的一种情况是:用户提交了表单,然后又撤回去编辑再重交。这时候节点可能已经因为第一次提交被唤醒了,流程已经往下走了几步,表单的新版本却姗姗来迟。SwarmFlow 在这个问题上做了一个很务实的妥协:如果节点在收到新版本表单时还处于waiting_human状态,那就用新版本重新生成后续参数并继续;如果已经被唤醒并且跑了很远了,那就把新版本标记为stale,交给上层的业务规则决定是否触发回退或人工复核。这种"不完全自动处理"的策略看起来不够智能,但在真实业务里反而是最稳的,因为流程已经往下走的时候,强行回滚的代价往往比人工看一眼大得多。

4.2 表单改了,已经走完的下游节点要不要重走

这是所有表单类工作流最经典的问题。流程里有一个信息收集节点要求用户填手机号,填完之后下个工作节点发一次验证码。用户后来在别的环节把手机号改了,你说那个发验证码的节点要不要重新跑?

我的经验是:不要试图给引擎加一个"自动发现字段变更影响范围"的智能功能,性价比太低了。SwarmFlow 的方案是把这个问题留到业务层,引擎只提供工具:每个表单实例都维护一个version字段,节点在执行的时候会记录自己消费的是哪个版本,叫consumed_version。下游节点启动时如果发现自己要读的表单版本号,比创建时记录的版本号新,它会读取到新数据正常执行,同时把"版本已更新"标记写到审计日志里。至于要不要因此重跑,由业务编排的人用条件分支去决定,引擎不替你判断。这套东西可能不够炫,但它在生产环境里跑得很稳。

4.3 取消流程时,两条流必须一起收场

流程跑到一半,发起人决定整个事项不干了,这种情况在真实业务里太常见了。一个完整的取消操作要同时处理进度树和表单流:进度树要把所有处于 waiting 或 running 状态的节点实例标记为 cancelled,把正在执行的后台任务通过任务调度器取消掉;表单流要把所有引用中的表单实例做个收尾,已提交但没被完全消费的表单标记为废弃,草稿表单直接作废。

这里最容易漏的是"部分接收"的情况。比如一张表单被三个节点引用,其中两个节点已经处理完了,第三个还在等。取消流程的时候,如果你只盯着"等待被唤醒的那个节点",那前面两个节点消费过表单的记录就无人收场,表单那边也以为自己还在被引用,永远不会被清理。所以我做取消逻辑的时候,不是遍历进度树去取消节点,而是先收集这个流程实例关联的所有表单实例,再拿着表单实例反查所有引用节点,一层一层把关系全部解除,最后才把流程实例整体标记为 cancelled。顺序反了,后面就要写一堆脏数据修复脚本。

5. 实测中踩过的坑和数据表现

5.1 等待节点多起来之后,扫描器差点成了热点

超时扫描是所有挂着等待的节点的兜底机制,逻辑很简单:定期扫数据库,找出waiting_human且entered_at超过阈值的节点实例。但这个逻辑在数据量起来之后立刻暴露问题——当系统里同时挂着两三万个等待节点的时候,每分钟全表扫描一次就很吃力了,哪怕加了索引也存在不小的压力。

后来我把扫描改成了两级设计:一级是精确的时间窗扫描,只扫next_due_time字段在下一分钟之内或者已经过期的记录,用索引定位一个小数据集;另一级是低频的兜底扫描,每十分钟跑一次全量,防止个别节点因为写库失败而漏过。这样高频扫描的查询量瞬间从几万降到了几十条,数据库压力小了很多。这种"高频精准加低频兜底"的思路,在处理任何定时类任务时都值得参考。

5.2 三个让我记忆深刻的线上事故复盘

第一个事故:事务提交顺序反了。具体场景是表单提交后,我先发了事件,再更改表单状态为 accepted。结果有几十毫秒的时间窗口,流程节点已经醒了,下游参数却读的还是 submitted 状态的数据。这个事故的教训在前面已经说过,根治办法就是表单状态变更和事件发布必须同事务,千万别图省事分开写。

第二个事故:进度树节点重试导致重复唤醒。节点运行的时候偶发超时,引擎做了自动重试,结果每次重试都会重新加载一次关联的表单状态,把同一个"表单已采纳"事件消费了多次,下游短信接口被连发了好几条。排查到最后发现根因是消费逻辑没有做消息幂等,靠的只是本地内存去重,一重启就失效。后来把event_id落库做了唯一约束,问题才彻底解决。

第三个事故:表单 schema 版本升级,老实例全部读不了新代码。我在不改数据结构的情况下给表单加了一个必填字段,结果存量实例读取时全部抛异常。经验就是:表单 schema 的演进,必须设计成兼容旧版本的模式,要么显式做版本迁移,要么存原始 JSON 并做读取时的兼容解析,绝不能只改代码不处理存量数据。

5.3 可视化界面上两条流的展示技巧

进度树和表单流拆开之后,内部逻辑清晰了,但界面上的表达反而容易让人困惑。用户在一个流程详情页里,既想看流程走到哪了,又要看相关的表单到底填了没有、填了什么内容。最简单的做法也最有效——左边画进度树,节点上出现"待补料"的小标记;右边放表单列表,表单状态和节点状态并排展示。两张表都基于同一个node_instance_id去关联,点哪个节点,就高亮对应的表单。

有一套不错的实践是:在进度树的节点上显示一个"人工响应状态"的角标,角标颜色和表单状态联动。比如表单处在 draft 状态,角标是灰色,表示没人动;提交了但在校验中,是黄色;校验通过已唤醒流程,是绿色。这样用户不用来回切换两条流,扫一眼节点颜色就知道卡在哪。

6. openJiuwen 当前状态和下一步想做的事

6.1 项目名字的由来:把"就问"做成一等公民

openJiuwen 这个项目名,中文读音其实就是"就问"的意思。起这个名字的动机很朴素:工作流引擎不能假装所有事情都能自动跑完,总会有需要停下脚步问人的时候。与其把"问人"当成流程里的异常情况来处理,不如把它做成引擎的一等公民,大大方方地提供挂起、等待、唤醒、超时、重提这些原语。SwarmFlow 是其中一个跑流程的模块,取 Swarm 是想表达它擅长处理一群实例并行跑起来的场景,每个实例像一只蜜蜂,各自在自己的节点上工作,需要问人的时候就落在一个花心里等着,消息一到再飞出去。

6.2 目前跑通的能力边界

到现在为止,SwarmFlow 已经支持串行节点、并行网关、条件分支、子流程嵌套这些基础编排能力,人工介入这块也实现了挂起节点、表单关联、幂等提交、超时扫描、外部回调适配这些核心功能。我用一个模拟业务系统做了两个月的真实场景验证,几百个流程实例跑下来,稳定性是可以接受的。当前版本比较适合的是中轻量级的业务流程平台,比如内部审批平台、活动运营流程、项目协作流。像大规模集群调度那种级别的编排需求,不是这个模块的主攻方向。

6.3 接下来更想做的几件事

从实际使用反馈来看,最值得先做的是完善异常补偿面板——当两条流对不齐的时候,运维人员不光需要日志,更需要一个可视化界面对节点做强制唤醒、强制取消、重新绑定表单这类操作。然后是想把表单流的 schema 演进做成一键迁移,不至于每次加字段都要写脚本。最后是计划做一个轻量级的"表单流转视图",让用户能看到同一张表单在流程里被哪些节点消费过、各节点消费的是哪个版本。

从个人的体会来说,拆两条流最值得的一点是,它让后端排查问题变得非常有秩序:流程卡住了先看进度树节点状态,节点显示 waiting_human 就去看表单状态,表单 ok 但节点没醒,那基本就是事件消费链路断了,顺着这条路十有八九能找到问题。如果当初把表单和进度揉在一起,现在大概还在日志里捞字段。这个模块还远谈不上完美,但核心的路子走对了,后面的坑就都好说。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进