第一版的流程定义是可以原地改的:租户管理员在控制台上调整节点,保存即生效,在途实例读的也是这份最新定义。这个设计简单直观,上线后出了两类事故,性质完全不同。
第一类是实例直接中断。某个租户的报销流程原来是「主管 → 财务 → 总经理」三级,运营为了提效改成「主管 → 财务」两级,把总经理节点删了。那些当前正停在总经理节点上的实例,一读定义发现自己所在的节点不存在了,引擎直接抛异常,这些单子既走不下去也退不回来,只能人工从数据库改。
第二类更严重,而且是静默的:有实例跳过了本该有的审批。反过来的场景——原来两级,改成三级加了个合规审批节点。已经走过财务、正等着结束的实例,因为定义变了,它的「下一步」被重新计算,有的直接走到了结束,等于绕过了新增的合规节点;也有的莫名回到了一个它从没经过的节点。审批被绕过在企业软件里不是 bug,是合规问题——审计要看的是「这笔钱是不是按当时的规则批的」。
两类事故指向同一个根因:流程定义是有状态实例的依赖,而依赖被原地替换了。这和「代码热更新时正在执行的方法被改掉」是同一类问题。
所以核心设计是流程定义版本化,实例在发起时绑定一个版本,整个生命周期内只读这个版本。新版本只对新发起的实例生效;旧版本标记为「停止接受新实例」但继续服务在途实例,直到最后一个在途实例结束才能真正归档。
第二个设计来自另一类事故:发布了一个结构上有问题的定义,问题在运行时才暴露。最高频的是条件分支的所有条件都不满足——运营配了「金额小于 1000 走 A、大于 1000 走 B」,忘了等于 1000 的情况,结果金额正好 1000 的单子走到这个节点就永远停住了,不报错、不流转,用户以为在审批中。类似的还有不可达节点、没有出口的节点、并行分支没有汇聚、条件形成环路。
这些全是可以在发布前静态检查出来的。所以设计是发布前做静态校验,不通过不允许发布,其中「条件分支必须配置默认出口」是一条硬性规则——这一条是直接从卡单事故里换来的,它把「所有条件都不满足」这个最高频的卡单原因从结构上消掉了。
在途实例锁定旧版本,代价是运营的预期落差。技术上这个选择没什么可争的(不锁定就会出事故),但它带来一个真实的产品摩擦:运营改了流程,发现今天提的单还在按老规则走,会认为「系统没生效」,甚至反复改反复保存。缓解手段有三个:控制台上明确显示「本次修改仅对新发起的申请生效」;提供在途实例列表让运营看到还有多少单在跑旧版本;提供强制迁移作为例外出口。我认为这个取舍值得讲,因为它说明技术决策会产生产品成本,而好的做法是主动把成本暴露在界面上,而不是让用户自己困惑。
为什么强制条件分支必须有默认出口,而不是「运行时找不到出口就兜底到管理员」。运行时兜底也能防止卡死,但它把配置错误变成了一个持续存在的隐患——每次走到这里都要兜底一次,管理员收到一堆莫名的待办,而根因(条件没配全)从来没被修。发布时强制校验是把问题挡在源头,代价是运营配置时多一步、觉得系统啰嗦。取舍原则是:能在配置期发现的错误,绝不留到运行期,因为运行期的错误发生在真实业务单据上,代价高得多。
踩过的坑:删除节点导致在途实例中断。租户把三级审批改成两级删掉了总经理节点,那些正停在该节点的实例一读定义发现自己所在的节点不存在,引擎直接抛异常,既走不下去也退不回来,最后只能人工改数据库。修法是定义版本化 + 实例绑定发起时版本。教训是:任何「有状态的执行体依赖一份可变配置」的结构,配置都必须版本化——这条在流程引擎、规则引擎、定价规则、退改规则上完全一致,我在旅游那边的退改规则快照上是同一个判断。
踩过的坑二:新增审批节点后,部分在途实例绕过了这个节点。这个比中断更严重,因为它是静默的——单子正常走完了,没人发现少批了一道。是后来做审计的时候比对流转记录才发现的。审批被绕过在企业软件里不是 bug 而是合规问题,审计要看的是「这笔钱是不是按当时的规则批的」。修法同上(版本绑定),另外加了流转记录的完整性校验(详见模块三)。教训是:静默的正确性问题必须有独立的校验机制去发现,不能指望它自己暴露。
踩过的坑三:条件分支漏了边界值,金额正好等于阈值的单子全部卡死。运营配「小于 1000 走 A、大于 1000 走 B」,等于 1000 的单子走到这个节点就停住了:不报错、不流转、状态还是审批中,用户以为在排队,两周后才来问。修法两条:发布前强制要求条件分支配置默认出口;加卡单扫描主动发现。教训是:分支条件的完备性不能靠人工细心,要靠结构约束——要求有默认出口,等于在结构上保证了「无论条件怎么配都有路可走」。
没做的部分:没做流程定义的可视化差异对比(改版前后哪些节点变了、影响哪些在途实例)。运营改版时只能靠自己记,这是个明显的体验缺口,但需要做图结构的 diff 与影响面分析,成本不低。也没做子流程与流程嵌套,当时所有流程都是单层的。
在途实例中断:确定性验证。构造场景——发起实例走到中间节点,然后修改定义删除该节点并发布,检查该实例仍能按旧版本正常流转到结束。改造前必然中断,改造后不中断。这类正确性用确定性用例覆盖,不要用比率。
审批被绕过:同样是确定性验证,而且要比对流转记录与绑定版本的定义——检查实例实际经过的节点序列,是否与它绑定版本的定义一致。这个校验做成自动化跑批,报的是「校验覆盖多少实例、发现多少不一致」,发现数用绝对数。
发布校验拦下的错误:报各类结构性错误被拦下的次数分布(缺默认出口、不可达节点、并行未汇聚、无出口)。这个分布很有说服力——如果「缺默认出口」占大头,正好证明这条硬性规则是必要的,而不是我们凭空加的。
卡单量的变化:报因「条件都不满足」造成的卡单数量,改造后应该归零(因为结构上不可能了)。诚实的表述是「这一类卡单在强制默认出口后不再产生,其他原因的卡单仍然存在」——不要说卡单整体消失了,审批人离职之类的卡单还在(见模块三)。
不要报「流程流转成功率 99.9%」。这个数字混合了配置质量、组织数据质量、外部系统可用性,不是引擎设计的成果。也不要报「零卡单」——卡单的成因有一部分在引擎之外(审批人离职、外部回调不来),声称零卡单一问就穿。正确表述是「哪一类卡单被结构性消除了、发布校验拦下了哪些错误、在途实例的版本隔离由确定性用例保证」。
企业审批和消费级产品最不一样的地方在这里:审批人不是一个人,而是一条需要解析的规则。流程定义里写的是「发起人的部门主管」「角色等于财务」「发起人上级的上级」,而不是「张三」。原因很简单——组织架构一直在变,人会调岗、离职、代管,部门会合并拆分。定义里写死人,三个月后必然过期。
第一版犯的错是在发起时就把审批人解析成具体的人并写进实例。这么做的好处是发起人立刻能看到完整审批链,很直观。问题是从发起到走到那个节点可能过了好几天甚至几周(前面还有几级要审),这期间:审批人离职了、调岗到别的部门了、原来的主管换人了。结果是待办出现在一个已经离职的人名下,实例卡死,而且没人知道它卡了。
所以改成到达节点时才解析。取舍很明确:到达时解析更正确但不可预测(发起人在提交时看不到完整的、确定的审批链);发起时解析可预测但会过期。我们选到达时解析,同时在发起页展示一条明确标注为「预计」的审批链——把可预测性作为一个展示层的补充,而不是让它污染执行层的正确性。
第二个必须处理的是解析失败。规则不一定解析得出人:部门没设主管、角色下一个人都没有、上级链断了(发起人是老板,没有上级)、解析出来的人已停用。第一版的行为是抛异常,实例停在那里,这就是静默卡死。
改法是解析不到人一律兜底到租户管理员,并且告警。核心判断是:宁可让一个不太合适的人收到待办,也不能让实例静默卡死。因为待办发错了人,那个人会来问、会转签,问题会被发现;而卡死是没有任何信号的,用户以为在审批中,可能两周后才来问。这一条是这个模块最重要的取向。
第三个是流转语义必须先定义清楚再写代码。加签、转签、退回、撤回这几个动作,业务方说起来都很自然,但边界全是模糊的:加签是在我之前审还是之后审?转签之后原审批人还能不能看到这单?退回是退给发起人还是退给上一个节点,重新提交后从头走还是从退回点继续?这些不定清楚,实现出来一定和业务预期不符,而且不同人问会得到不同答案。
最后是并发。或签节点上有三个人,两个人同时点了同意,如果不控制就会推进两次,产生两条流转记录甚至走到两个不同的下游节点。所以审批动作要用条件更新(只有当前节点状态还是待审时才能更新成功),并且动作本身要幂等(用户重复点提交、网络重试都不能产生第二次流转)。
到达时解析 vs 发起时解析,这是这个模块最核心的取舍。发起时解析的好处是可预测——发起人提交时就能看到确定的审批链,「我这单要经过谁」很清楚;坏处是会过期,从发起到抵达某节点可能几周,那时解析出的人可能已经离职。到达时解析反过来:正确但不可预测。我们选到达时解析,理由是「正确性问题会造成卡单和合规风险,可预测性问题只是体验」,量级不同。缓解可预测性的手段是发起页展示标注为「预计」的审批链——关键在于「预计」这两个字,它把不确定性诚实地传达给用户,而不是给一个假的确定答案。
解析失败兜底到管理员,而不是抛错或跳过节点。三个选项各有代价:抛错卡死是最差的(静默、无信号);跳过节点看起来最顺畅,但那等于绕过审批,是合规问题,绝对不行;兜底到管理员会让管理员收到一些不属于他的待办,有骚扰成本,但问题是可见的、可处理的(他可以转签)。取舍原则是:在「静默错误」和「显式麻烦」之间,永远选显式麻烦。这条判断和「超时按不可售处理」「设施取交集」是同一个取向——面对不确定,选那个会被发现的选项。
踩过的坑:发起时解析审批人,待办出现在已离职的人名下。实例前面还有两级要审,走了一周多,到这个节点时原审批人已经离职,待办挂在他名下没人处理,实例卡死且没有任何告警。用户以为还在排队,两周后才来问。修法是改为到达时解析 + 解析失败兜底 + 卡单扫描。教训是:把「随时间变化的外部数据」在早期快照下来,就等于承担了它过期的风险——要么在使用时刻取最新,要么明确接受过期并有过期处理机制,不能什么都不做。
踩过的坑二:会签的应审人数随组织变动改变,导致永远凑不齐。某个会签节点解析出 3 个财务,其中一人审完之后又有新人被加入财务角色,应审人数变成 4,而系统按「已审人数等于当前角色人数」判定完成,于是永远差一个。这个卡单极难定位,因为看起来「就差一个人审」很正常。修法是应审人数在解析时固定下来,之后不再随组织变动。教训是:判定完成的基数必须在开始时固化,用一个会变的集合大小做完成条件,判定就永远不稳定。
踩过的坑三:或签节点两人同时点同意,产生了两条流转记录并走到两个下游节点。实例状态混乱,出现了「一个实例同时在两个节点」的现象。修法是条件更新加动作幂等。教训是:状态机的每一次推进都必须是条件更新(where 当前状态等于期望值),无条件更新在并发下一定会出事——这条和订单状态机、库存扣减是同一个模式。
踩过的坑四:退回后重新提交从头走,已经批过的人又收到一遍。一个五级流程在第四级被退回,发起人改完重提,前三级全部重新审一遍,审批人抱怨「我上周不是批过了吗」。修法是默认从退回点继续,并把「退回后重提从哪走」做成可配。教训是:退回这类逆向操作的语义必须和业务确认清楚,技术上两种实现都很简单,但选错了会显著增加所有人的工作量。
没做的部分:没做审批人的负载均衡(角色下多人时按待办量分配)和自动代理(休假期间自动转给指定同事)。自动代理需要接假期数据,当时组织数据里没有可靠的假期信息,只做了手动设置代理人。
审批人过期造成的卡单:报改造前后这一类卡单的数量(绝对数)。改造前是「待办挂在已离职人员名下」的实例数,改造后应该归零——因为解析发生在到达时,而离职人员不会被解析出来。用绝对数不用比率,因为这类问题的总量本来不大但每一个都是客诉。
解析失败兜底的触发次数:这个数字要主动报,而且它不是越低越好——它反映的是租户组织数据的完备度(有多少部门没设主管、多少角色是空的)。报它的价值是证明兜底路径真的在生效,而不是写了没跑过。同时可以按租户看分布,帮助推动客户补全组织数据。
会签凑不齐的问题:确定性验证——构造场景:会签节点解析出 N 人,其中一人审批后向该角色新增成员,检查应审人数仍是 N 且能正常完成。改造前会永远差一个,改造后不会。
并发重复流转:确定性验证——或签节点上并发提交多个同意动作,检查只产生一条流转记录、只推进一次。同时验证重复点击提交的幂等性。
审批耗时:报各节点的停留时长分布(中位数与长尾),而不是平均值。平均值在审批场景里几乎没有意义——大部分单子几小时内批完,少数卡好几天,平均值把两者混成一个谁都不认识的数。长尾才是要治理的对象。
不要报「审批人解析准确率 100%」。「准确」在这里的定义依赖租户的组织数据质量,组织数据不全时解析结果本身就没有唯一正确答案。正确表述是「解析规则覆盖哪几类、解析失败的兜底路径是什么、兜底触发次数与分布」。也不要报「零卡单」——这个模块消除的是「审批人过期」和「会签凑不齐」两类,其他原因的卡单在模块三处理。
这个模块的出发点是一句话:审批卡单是静默故障。
实例停在某个节点上没人处理,系统不会报错、不会告警、监控上一切正常——因为从引擎的角度看,「等待审批」是一个完全合法的状态,它没法区分「正常在等人审」和「永远等不到人审」。用户那边看到的是「审批中」,他会耐心等,等到某个业务节点(月底报销截止、合同要签)才来问。我们遇到最久的一单卡了三周多。
所以这个模块的第一件事不是「怎么修卡单」,而是「怎么知道卡了」。做法是扫描停留超过阈值的实例,并且按成因分类。分类很重要——不同成因的处理方式完全不同,混在一个队列里运维只能一个个点开看:
审批人停用或离职(模块二的到达时解析已经消掉大部分,但审批中途离职仍会发生)、角色空缺(组织调整后某角色一个人都没了)、条件无匹配(模块一的强制默认出口已消除,但历史版本的实例还在跑)、外部回调未达(流程里有调外部系统的节点,对方没回)、调度异常(定时任务挂了,超时提醒和自动流转都停了)。
第二件事是超时策略要分级,而且自动决策要极度保守。三级:催办(提醒审批人,最轻)、升级(转给上级,中等)、自动通过或自动驳回(最重)。
自动通过是这里唯一需要非常谨慎的能力,因为它等于绕过审批。一笔报销因为主管没及时看就自动批了,出问题时责任在谁?所以设计上:默认关闭、需要租户显式开启、限定流程类型、限定金额上限、并且在审批记录里明确标注「系统自动通过」。自动驳回相对安全(用户可以重新提),所以门槛可以低一些。这个不对称很重要——同样是自动决策,往「不通过」的方向偏风险小得多。
第三件事是人工干预能力。卡单发现了,得能修。四类:强制指派(换个人审)、跳过节点、回退到指定节点、终止实例。
但干预本身是危险的——它在绕过正常的审批规则。所以:需要二次确认、必须填理由、权限严格控制、并且干预记录和流转记录同等留痕且不可删除。审批记录在企业软件里是有法律和审计意义的,「谁在什么时候以什么理由跳过了哪个审批」必须永久可查。
最后是流转记录的完整性校验。这一条是为了发现模块一那类静默问题——实例实际走过的节点序列,是否和它绑定版本的定义一致,有没有断链(记录首尾不相接)、有没有绕批(少走了应有的节点)。做成跑批,主动去查。
自动通过为什么默认关闭,而不是设一个较长的超时后自动通过。从效率角度,自动通过很有吸引力——审批卡住会阻塞业务,自动放过能让业务跑起来。但它改变了「审批」这件事的性质:审批的价值在于有人对这笔支出负责,自动通过之后没有人负责,而单据上却显示已通过。出问题时的追责会直接指向系统设计者。所以我们的选择是默认关闭、租户显式开启并承担责任、限定流程类型与金额上限、记录明确标注。取舍是有些租户会觉得不够自动化,缓解手段是把催办和升级做得足够好——大部分卡单其实是审批人没看到,催办和转给上级就能解决,真正需要自动通过的场景很少。
为什么干预权限要比正常审批管得更严。直觉上管理员权限大是正常的,但干预能力本质上是「合法地绕过审批规则」,如果管理员可以随意跳过节点,那整套审批设计的约束力就没了——想批什么找管理员跳一下就行。所以设计上干预权限独立于普通管理权限、需要二次确认与理由、并且留痕不可删。取舍是干预效率降低(多几步操作),但换来的是审批体系的可信度。我认为这是企业软件和消费级产品很不一样的一点:消费级产品倾向给管理员最大便利,企业软件必须约束管理员,因为审计对象包括管理员本人。
踩过的坑:卡单靠用户来问才发现,最久的一单卡了三周多。实例停在某节点,系统不报错、监控正常、用户以为在排队,直到月底报销截止才来问。修法是按成因分类的主动扫描。教训是这一页最想传达的判断:静默失败比报错危险得多——报错至少有信号,静默失败的发现时长等于用户的容忍时长。凡是「某个状态可以合法地长期存在」的系统,都需要一个外部视角来判断这个状态是否已经不正常了,因为系统内部无法自己区分。
踩过的坑二:定时任务挂了,所有超时提醒和自动流转全停,而这件事本身也是静默的。催办没发、升级没触发、自动驳回没执行,表现是「卡单突然变多」,但我们过了两天才注意到,因为卡单本来就有一些,量的变化不容易察觉。修法是给调度任务本身加心跳监控(任务在预期时间内没有执行完就告警),并把「调度异常」列为卡单成因的一类。教训是:兜底机制自己也需要被兜底——扫描任务、对账任务、清理任务这些「用来发现问题的东西」一旦挂掉,问题就会重新变成静默的,而且更难发现。
踩过的坑三:干预记录可以被删除,出现了无法解释的审批链。早期干预记录和普通业务数据一样支持删除,有人清理数据时删掉了一批,结果审计时发现某些实例的流转记录接不上(中间有个跳过节点的动作没了),完全解释不清。修法是干预记录与流转记录一律只增不删,从数据库权限到应用层都不提供删除入口。教训是:有审计意义的记录必须在设计上就不可删除,而不是靠约定「不要删」——只要提供了删除能力,迟早会有人用。
踩过的坑四:卡单扫出来了但队列没人管,越积越长。扫描做好了、分类也做了,但没有指定责任人和处理时限,队列从几十涨到几百,最后大家都当它不存在。修法是加责任人、处理时限、超时升级。教训是:发现机制必须配处置闭环,否则只是把「卡单」换成了「卡单列表很长」,问题没被解决只是被记录了。这个坑我在数据同步的拦截队列上也踩过同一个,属于通病。
没做的部分:没做卡单的根因自动归因(自动判断「这一批卡单都是因为某个部门没设主管」并给出修复建议)。目前是按成因分类,但同一成因下的共性还要人工看。也没做审批时长的预测(告诉发起人「预计还需多久」),需要历史数据建模,当时只展示了当前节点已停留时长。
卡单发现时长:这个模块最该报的数字,而且要报路径变化而不只是时长——改造前是「等用户来问」(发现时长等于用户容忍时长,我们遇到过三周多的),改造后是「扫描主动发现」(发现时长等于扫描周期)。路径的变化比数字更有说服力,因为它说明的是从被动到主动。
卡单的成因分布:报各成因的占比。这个数据有双重价值:证明分类在真实生效;分布本身指出治理重点(如果「角色空缺」占大头,说明要推动客户补全组织数据,而不是改引擎)。
各类超时策略的效果:报催办后被处理的比例、升级后被处理的比例、最终走到自动决策的比例。如果大部分卡单靠催办就解决了,正好证明「自动通过默认关闭」是合理的——真正需要自动通过的场景很少,这个数字直接支撑了那个设计决策。
自动通过的使用情况:报开启了自动通过的租户数、触发次数、涉及流程类型。这个数字要主动报,因为它是风险敞口的度量。
干预的使用情况:报各类干预动作的次数分布。「跳过节点」占比高是个警示信号——说明流程配置有问题,运维在用干预掩盖配置缺陷,这时候该去修流程而不是继续跳过。
流转记录完整性校验:报校验覆盖的实例数与发现的异常数(绝对数)。发现数应该很小,但要诚实报出「发现过 N 笔断链或绕批」而不是声称从未发现——发现过才说明校验真的在跑。
不要报「卡单率降低 N%」或「零卡单」。卡单成因有一部分完全在引擎之外(审批人真的在出差、外部系统真的挂了),引擎能做的是发现和提供处置能力,不是消灭卡单。正确表述是「发现路径从被动变主动、发现时长收敛到扫描周期、成因分布是什么、各级超时策略的处理占比」——把责任边界说清楚。
没有匹配的内容,换个关键词试试。
项目拆解 · 工作流与审批引擎(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据