去年年中,我接手了一个困扰公司很久的课题:能不能用Spring Cloud构建一套微服务架构下的企业员工工作流引擎。当时公司的现状是——请假审批在OA里,报销审核在财务系统里,用章申请在行政系统里,每个系统的审批逻辑各写各的,流程规则变一次就要改代码发一次版,三个系统之间的数据还对不上。作为后端负责人,我需要把散落的流程能力收口到一个统一的引擎里,做成一套可复用、可扩展、扛得住高并发压力的员工工作流平台。这篇文章就把我们整个调研、选型、开发、上线过程中最核心的东西整理出来,包括为什么没直接用Flowable/Activiti、服务怎么拆、Spring Cloud组件怎么落地、流程节点怎么设计、以及上线后踩过的坑。希望能给正在做流程中台或者微服务改造的朋友一些实实在在的参考。
1. 从"写死的审批链"到微服务引擎:这个项目要解决的三个核心问题
1.1 流程逻辑散落在业务代码里,改一次流程要发一次版
没做过企业流程系统的人可能不太理解"写死"是个什么概念。拿请假审批来举例:员工提交请假申请后,系统要判断请假天数、请假类型,然后决定把任务推给直属主管、部门经理还是总经理。在很多老系统里,这段判断逻辑是直接写在业务代码的if-else里的,比如if (days > 3 && type.equals("annual")) { assignTo(managerId); }。
看起来没什么问题,但实际运营中痛点非常大。公司组织架构调整了,三级审批变成两级审批,要改代码;考勤制度更新了,一线员工请假3天以上需要HRBP参与复核,要改代码;甚至某个部门临时需要加一道"合规确认"节点,也要改代码、走测试、排期发版。一条简单的流程变更,快则一周,慢则半个月。
而且这种散落的写法带来一个更隐蔽的问题:业务系统之间的流程状态互相不通。OA说请假没批完,财务那边的考勤数据已经把假扣了,对账对不上,员工来投诉,IT部门成了背锅侠。所以当时我们定了一个目标:所有员工相关流程必须由一个独立的引擎统一管理,流程规则配置化,变更不需要发版,状态全局可查。
1.2 为什么不用Flowable/Activiti,而要自研轻量流程引擎
这个问题在技术评审会上被问了很多次。Flowable和Activiti都是非常成熟的开源BPM引擎,功能覆盖BPMN2.0全模型,支持会签、子流程、条件网关、事件监听,理论上企业里能想到的流程模式都能实现。那我们为什么最终选择自研?
核心原因有三点。第一,BPMN建模对业务人员不友好。Flowable的上手成本在于要理解BPMN那一套图形化模型,部门里真正愿意画流程图去配置的运营同学很少,最后还是要开发同学去维护XML/JSON流程定义,等于把"线上改配置"又变成了"开发写代码"。第二,二开成本被严重低估。Flowable的表结构极其复杂,光ACT_开头的表就有大几十张,我们需要的人事审批、用章申请等场景根本用不到那么多能力,但每次查问题、做扩展都要面对整套框架的复杂度。第三,轻量场景下性能和可控性反而不如自研。员工请假、报销、轮岗、用章申请这类流程,节点少、规则简单、但调用量大,自研一个基于状态机+JSON流程定义的引擎,完全可以覆盖业务,而且出了问题自己就能改,不用啃开源框架的源码。
当然这不是说Flowable一无是处。如果是做跨系统、跨组织、需要复杂事件编排的端到端流程,成熟引擎依然是更稳妥的选择。但在"企业内部员工流程"这个特定领域,轻量自研的投入产出比确实更高。我们在博文后面会详细讲引擎怎么设计,这里先记住这个判断逻辑:选型不是选最强大的,而是选最匹配场景的。
1.3 微服务架构在工作流场景里能带来什么
确定了自研引擎的方向之后,第二个问题是:为什么一定得上微服务架构?单体应用挂一个工作流模块不也能跑吗?
从技术上讲,单体确实也能跑。但我们当时有三个具体诉求,单体很难同时满足。第一是部署独立性:工作流引擎的发布频率远高于用户中心、组织架构服务这些基础服务。流程规则每周都可能调,如果把引擎和用户系统打包在一个应用里,每次调流程规则都要连带重启用户服务,风险面太大了。第二是资源伸缩差异:工作流引擎经常出现月初月末的审批高峰,而组织架构服务是持续平稳的读多写少,两者放在一起,CPU和内存资源没法独立调配。第三是团队协作边界:公司有专门的OA小组负责流程配置和用户运营,有后端小组负责平台服务,微服务的服务边界本身就是团队分工边界,切开了才好并行迭代。
所以最终我们确定了一套基于Spring Cloud的多服务架构,而不是简单地在一个工程里建一个流程模块。服务拆分的细节放到下一节详细讲,这里可以明确一个观点:微服务不是为了技术炫技,而是为了把"变更频繁的流程引擎"和"相对稳定的基础数据服务"隔离开,让它们各自演进、各自伸缩。
2. 整体服务架构与流程引擎的领域边界划分
2.1 服务拆分原则:不是服务越多越好,而是按"变更频率"切
微服务拆分最容易犯的错误就是按数据表切,或者按页面切。比如把"请假管理"切一个服务,"报销管理"再切一个服务,表面上很清晰,实际上两个服务里大量的流程引擎逻辑是重复的,等于把单体里的重复代码变成了跨服务的重复接口。
我们当时的拆分原则很简单粗暴:按"变更频率"和"领域归属"两个维度切。基础数据域的东西(员工、部门、职级、考勤)变更频率低、被依赖面大,做成稳定服务;流程域的东西(请假、报销、用章、轮岗)变更频率高、但底层的流程引擎机制相同,全部收口到一个引擎服务里;对外暴露的人事审批入口、待办列表属于交互层,通过网关统一路由,不单独拆服务。
基于这个原则,整个平台最终拆成了五个核心服务:
| 服务名 | 职责范围 | 变更频率 |
|---|---|---|
| gateway-service | 统一入口、路由转发、Token鉴权、接口限流 | 低 |
| auth-service | 登录认证、Token签发与刷新、权限点校验 | 低 |
| user-service | 员工信息、组织架构、部门关系、审批人查询 | 低 |
| workflow-engine-service | 流程定义管理、流程实例流转、任务生成与处理、会签或签、历史轨迹 | 高 |
| message-service | 站内信、邮件、企业微信通知、催办提醒 | 中 |
五个服务,不多不少。很多团队一上来就拆十几个服务,结果每个服务只有几个接口,光维护服务间调用关系和部署流水线就消耗了大量精力。我们这套拆分把最复杂的业务逻辑集中在了workflow-engine-service里,其他服务职责单一,整体维护成本可控。
2.2 核心服务职责与关键依赖关系
user-service在整个架构里处于"基础被依赖"的位置,workflow-engine-service在处理节点流转时要频繁查询审批人、部门主管、角色成员,这些都是通过OpenFeign调用的。message-service不直接对外暴露接口,它监听MQ消息,有通知需求就往里发。auth-service独立于业务服务,提供统一登录和鉴权能力。
workflow-engine-service的内部职责划分更细致一点:
- 流程定义模块:负责流程的创建、版本管理、发布、启停。运营同学通过管理后台配置一个JSON流程定义,保存后生成一个新版本,发布后新发起的流程实例走新定义,老实例继续走旧定义。
- 流程实例模块:维护每条流程从发起、审批中、通过、驳回、撤回、终止的状态机流转。
- 任务模块:生成审批待办任务、处理任务、查询待办列表、已办列表,支持转办、委派、催办。
- 规则模块:解析流程定义中的条件表达式,处理分支判断,以及会签/或签的计数器逻辑。
- 历史模块:记录每一个节点的处理人、处理时间、处理意见、流转结果,形成可追溯的审批轨迹。
依赖方向上,引擎服务只依赖user-service提供的员工和部门接口,不依赖任何业务系统的私有接口,这样业务系统接入引擎时只需要关心"我发起一个流程、然后在回调里拿到结果",不用理解引擎内部逻辑。
2.3 服务间调用链路:一个审批操作背后发生了什么
一个审批操作从用户点击"通过"到消息通知触达,完整链路是这样的:
- 用户在Web前端点击通过,请求经过gateway-service。
- gateway解析Token,校验权限,将请求路由到workflow-engine-service的任务处理接口。
- 引擎服务先对任务表执行乐观锁更新,确保同一任务不会被并发处理两次。
- 任务状态更新成功后,引擎加载流程实例的快照定义,找到当前节点和下一节点,计算下一节点的处理人。
- 如果下一节点处理人需要查部门主管或指定角色成员,引擎通过OpenFeign调用user-service的接口。
- 引擎把新任务写入task表,同时把流程实例的当前节点指针向前推进。
- 引擎发送一条"有新待办"的消息到MQ,message-service消费后推送企业微信通知给下一节点处理人。
- 前端通过轮询或者长连接,在待办列表里看到新任务。
这条链路里最核心的是第3到第6步。你会发现,一次简单的审批通过,背后涉及任务表更新、流程实例状态推进、下一节点任务生成、消息通知四个关键动作。这也是整个系统设计中最容易出问题的地方——我们后面在数据一致性那一章会专门展开讲怎么保证"不丢、不重、不错"。
3. Spring Cloud关键组件在工作流引擎中的落地用法
3.1 Nacos:服务注册与配置管理,为什么选它做注册中心
服务注册发现这块,我们当时对比了Eureka、Consul和Nacos。Eureka虽然经典,但早就停止大的版本更新了,而且它只是注册中心,没有配置中心能力,等于我们要额外再搭一套配置管理方案。Consul功能全但对运维的复杂度要求偏高,国内团队用得少,出了问题社区支持不如Nacos好。最终我们选了Nacos 2.x。
Nacos在工作流场景里有两个特别实用的点。第一是配置动态刷新,我们很多流程引擎参数,比如任务处理线程池大小、通知重试次数、流程定义缓存过期时间,都放在Nacos配置中心里,通过@RefreshScope注解实现修改即时生效,不用重启服务。配合Nacos的命名空间隔离,我们把dev、test、prod三套环境的配置完全隔离开,避免了配置串线的问题。
第二是健康检查与负载均衡的稳定性。创建Spring Cloud项目的时候,新版的Spring Cloud LoadBalancer已经替代了Ribbon,而Spring Cloud Alibaba的Nacos Discovery能很好支持这个组合。workflow-engine-service部署了多个实例,Nacos注册中心按服务名对这些实例做负载均衡,某台实例挂了,注册中心会在几个心跳周期内把它摘掉,上层调用自动切到健康实例。
这里有一个容易被忽略的坑:Nacos的配置变更如果涉及数据源连接串、Redis地址这类核心参数,即使加了@RefreshScope,连接池也不会自动重建。我们的做法是在配置变更监听器里主动调用GenericApplicationContext的refresh操作,或者直接重启服务集群。生产环境我建议配置变更分两步走——先改不核心的参数,观察日志稳定后再改核心参数。
3.2 Spring Cloud Gateway:流程入口的统一路由与鉴权
工作流引擎对外暴露的接口天然有"内部系统调用"和"员工前端使用"两种路径。如果每个服务都自己管鉴权,既容易漏,后期加权限策略也非常痛苦。我们用Spring Cloud Gateway做了统一入口,所有请求先过网关再进服务。
网关层面做了三件事:
- 路由转发:约定好规则,
/api/user/**路由到user-service,/api/workflow/**路由到workflow-engine-service,/api/auth/**路由到auth-service。配置如下:
spring: cloud: gateway: routes: - id: user-route uri: lb://user-service predicates: - Path=/api/user/** - id: workflow-route uri: lb://workflow-engine-service predicates: - Path=/api/workflow/** - id: auth-route uri: lb://auth-service predicates: - Path=/api/auth/**Token鉴权:写了一个全局过滤器,对所有
/api/**请求校验Header里的Token,Token合法才放行。部分服务之间内部调用的场景,通过一个内部的X-Internal-Call: true头加上IP白名单双重校验,防止外部绕过网关直接打服务端口。跨域处理:前端部署在不同的域名下,网关统一处理CORS跨域配置,避免每个服务都配一遍跨域。
网关还有一个额外的好处:我们把限流器也放在这一层。工作流引擎月初月末流量比平时高好几倍,如果不做限流,一旦某个瞬间大量流程并发发起,后面的服务很容易被打垮。我们选了Sentinel作为限流组件,配合网关可以针对某个路由设置QPS阈值,超过阈值的请求直接返回"系统繁忙",保护下游服务。这个限流的详细配置后面踩坑章节会讲。
3.3 OpenFeign + Sentinel:服务调用与流量防护的配合
服务间调用我们用的是OpenFeign。每个服务通过@FeignClient(name = "user-service")声明依赖,调用关系清晰,还自带Spring Cloud LoadBalancer的负载均衡能力。
OpenFeign的配置有几个细节必须注意。第一个是超时时间,Feign默认的连接和读取超时非常短,链路稍微复杂一点就容易超时。我们这里是全局设置了连接超时3秒、读取超时10秒,对于少数需要查多级组织架构的接口,单独在FeignClient注解里指定更长的超时时间。第二个是异常处理,工作流引擎在调用user-service查询审批人时,如果身份认证服务层面查不到员工,不应直接抛异常导致整个审批链路失败,而是要捕获异常并返回一个可读的业务错误码,让引擎决定是走默认审批人还是挂起流程人工处理。
Sentinel的落地不只是网关限流,在服务内部也要配置。我们在workflow-engine-service里给三个核心接口加了流控规则:发起流程、处理任务、查询待办。流控模式用的是QPS,并且配置了fallback方法,当触发限流时返回一个业务上可接受的降级结果——比如发起流程限流时提示"当前审批请求过多,请稍后重试",查询待办限流时直接读Redis里的热点数据,保证前端列表不空白。
这里特别提醒:Sentinel控制台上的规则是内存态,重启就没了。生产环境一定要通过FlowRuleManager.loadRules()加载配置,或者接入Nacos持久化,把限流规则作为配置管理起来。我们上线初期就吃过这个亏,重启一次服务,限流规则全部丢失,后面才把规则迁移到Nacos配置中心。
4. 轻量流程引擎的核心实现:流程定义模型与节点流转
4.1 JSON流程定义:把流程图变成计算机能识别的东西
自研引擎最先要解决的就是"流程怎么描述"。我们调研过直接用Flowable的XML,也考虑过用数据库表存流程图,最后都否决了,原因前面说过——太重、运维同学看不懂。最终我们设计了一套极度简化的JSON流程定义。
一个流程定义由nodes(节点列表)和edges(连线列表)组成。每个节点有一个nodeId、一个type(start、approval、condition、cc、end),以及节点专属的配置项。每一条连线表示从一个节点到另一个节点的流转关系,可以挂在条件表达式。下面是一个请假审批的流程定义核心结构:
{ "processKey": "leave", "version": 3, "nodes": [ { "nodeId": "start", "type": "start", "name": "发起申请" }, { "nodeId": "leader_approve", "type": "approval", "name": "直属主管审批", "assigneeType": "leader", "level": 1 }, { "nodeId": "condition_days", "type": "condition", "name": "请假天数判断", "rules": [ { "condition": "days <= 3", "target": "end" }, { "condition": "days > 3", "target": "hr_approve" } ] }, { "nodeId": "hr_approve", "type": "approval", "name": "HR审批", "assigneeType": "role", "roleCode": "HR_MANAGER" }, { "nodeId": "end", "type": "end", "name": "流程结束" } ], "edges": [ { "from": "start", "to": "leader_approve" }, { "from": "leader_approve", "to": "condition_days" }, { "from": "condition_days", "to": "end" }, { "from": "condition_days", "to": "hr_approve" } ] }这个结构有几个值得说明的细节。第一,condition节点不产生任务,它只是一个分支判断器,根据流程变量决定走向哪个目标节点。第二,assigneeType是可扩展的,目前支持指定的员工ID、指定角色、直属主管、部门领导四级审批人,每种类型在user-service里都有对应的查询逻辑。第三,整个JSON作为一条记录存到数据库的process_definition表里,发布的时候通过管理后台提交,不会直接改数据库。
4.2 条件表达式的解析与节点流转实现
流程引擎最核心的方法就是processNextNode。这个方法的入参是当前的流程实例、当前节点的处理结果,输出是下一个节点的集合或者流程结束信号。逻辑大致是这样的:
- 从流程实例的快照中取出流程定义JSON。
- 根据当前
nodeId,在edges里找到所有以当前节点为起点的连线。 - 如果当前节点是
approval节点且审批通过,则根据连线找到后续节点;如果审批驳回,则走驳回逻辑(默认驳回给发起人,也可以配置驳回指定节点)。 - 后续节点如果是
condition,则解析条件表达式,把多个分支收敛到一个确定的目标节点。 - 如果是
end节点,则流程实例状态改为已结束。 - 如果是
approval节点,则根据审批人配置生成新任务。
条件表达式的解析,我们用的Spring自带的SpEL(Spring Expression Language)。JSON里存的condition字段就是可解析的表达式,比如days <= 3、type == 'annual' && days > 5。流程发起时,发起人填写的表单数据会作为流程变量存入process_variable表,条件节点计算时把这些变量放入SpEL的EvaluationContext里,直接算出结果,决定走哪条分支。
这个过程看起来简单,实际上逻辑分支非常多。尤其是并行分支的场景,一个节点可以同时指向两个审批节点,两个审批节点都完成后才能汇聚到下一个节点。并行汇聚的实现复杂度比条件节点高了一个数量级,我们第一期并没有支持,而是明确把它排除在范围外,只支持单线串行和条件分支。做流程引擎最怕的就是拿到需求就想全支持,先控制范围,把一个简单的模型做到极致稳定,再迭代扩展。这个原则帮我们节省了大量的上线时间。
4.3 会签/或签/驳回到指定节点的代码级设计
员工工作流里,会签和或签是最常见的两个复杂场景。比如"采购合同审批"需要财务、法务、采购三个角色都同意才能通过,这是会签;"项目方案评审"可以任何一个评委驳回整个方案就终止,这是或签。我们设计节点配置时增加了两个字段:multiType(AND表示会签、OR表示或签)和multiAssignee(处理人列表或角色)。
会签的实现逻辑是这样:流程推进到会签节点时,引擎一次性为所有审批人生成独立的task记录。每一条任务处理完,都更新任务状态并检查该节点的"完成进度"。我们用两个字段控制:totalCount(总审批人数)和doneCount(已完成审批人数)。当一个审批人通过,引擎执行doneCount+1,当doneCount == totalCount时,整个节点才算通过,流程继续推进。只要有一个审批人驳回,节点状态就变成驳回。或签则更简单:任一条任务通过,整个节点直接通过,同时把还没处理的兄弟任务全部取消掉。
驳回指定节点这个功能,核心要处理的是"目标节点可以不是上一个节点"。比如说总经理驳回,希望直接回到发起人修改,而不是再走一遍直属主管。我们在JSON里给approval节点加了一个配置项rejectTo,默认值是START,表示驳回到发起人;也可以配置为指定nodeId,表示驳回到具体节点。驳回动作发生后,流程实例的当前节点指向目标节点,同时生成一个"流程被驳回"的历史记录和一条新的待办任务。
会签这类多任务并发处理的场景,对数据一致性要求极高。两个审批人同时点"通过",如果不用技术手段控制,极有可能出现任务重复处理或者进度计算错误。这部分我在下一章专门讲,它是整个引擎稳定性里最关键的一环。
5. 数据一致性:流程实例状态不丢、不重、不错
5.1 乐观锁在任务处理中的应用
流程引擎的数据一致性,第一个要解决的就是"同一个任务不能被人处理两次"。想象一下,一条审批任务同时出现在Web端和移动端的待办列表里,用户先在PC上点了一次通过,又拿起手机点了一次通过。如果没有并发控制,两个请求都会走到"更新任务状态"的逻辑,造成任务状态重复流转、流程实例被推进两次。
我们的方案是乐观锁。task表加了一个version字段,每次更新任务状态时带着版本号执行update语句:
UPDATE wf_task SET status = #{targetStatus}, version = version + 1, finish_time = NOW() WHERE id = #{taskId} AND version = #{oldVersion};这段SQL的影响行数如果是0,说明任务已经被别人处理过了,直接抛出"任务已被处理"的业务异常。这里要注意不要用status = 0这类状态条件来替代版本号,因为状态条件在复杂的处理链路里不可靠,还是版本号最直接。
流程实例表同样加了version字段。任务处理成功后,推进流程实例状态时再走一次乐观锁更新。这样从任务到实例,每一层都防了并发。实际线上运行半年,没有出现过一次任务重复处理的问题。
5.2 流程定义快照:线上流程不能被新定义"截胡"
流程定义是有版本的,这个前面提过。但单纯的版本管理还不够,还有一个隐蔽的坑:流程定义发布新版本后,已经发起、正在流转中的实例如果实时去读最新定义,就会出现"流程走到一半规则变了"的诡异现象。
比如一个请假审批,发起时是"3天以上需要HR审批",走到一半运维改了定义,变成"5天以上才需要HR审批",那么原来的那条流程是走HR还是不走?答案必须是"走HR",因为它发起时依据的是旧规则。
实现上,我们没让运行中的实例去读process_definition表,而是在流程发起的那一刻,把当时使用的流程定义JSON完整复制一份,存到wf_process_snapshot表,字段包括instance_id、definition_content和version。后续这个实例的所有流转判断,都从快照里读,跟最新的流程定义彻底隔离。这样即使流程定义被改了、被删了、甚至新版本发布了一百次,运行中的老实例都安然无恙。
这个设计的代价是快照表会越来越大。我们的做法是定期把已结束三个月以上的实例快照迁移到归档库,只保留运行中实例的完整快照,查询历史时再按流程实例ID走归档数据。成本可控,逻辑也干净。
5.3 跨服务通知的最终一致性
前面提到,流程推进成功后要发MQ消息触发企业微信通知。这里有一个一致性问题:如果任务状态更新成功了,但MQ发送失败,那么下一节点处理人收不到通知,审批就会卡住。如果MQ发送成功了,但任务状态更新失败(比如上面说的乐观锁冲突),那么就会多出一条"幽灵通知"。
业界标准做法是本地消息表,我们也用了。具体流程是:在更新任务状态和推进流程实例的同一个数据库事务里,向mq_message表插入一条消息记录,状态为PENDING。事务提交成功后,后台有一个独立的消息投递线程,扫描PENDING状态的消息,按顺序发送到MQ。发送成功后把状态更新为SENT。如果投递失败,重试次数达到阈值则标记为FAILED,同时告警通知开发人员人工介入。
这里有个关键点:消息表的写入一定要和业务表更新在同一个事务里。Spring的@Transactional注解可以保证这一点。只要事务提交了,消息记录就落盘了,不会丢;只要事务回滚了,消息记录也不存在,不会多发。这套方案牺牲了一点点实时性——消息投递可能比业务操作晚几百毫秒——但换来了最终一致性,对通知场景来说完全可接受。
我们没用Seata这种分布式事务框架。不是说Seata不好,而是本地消息表这种方案对工程师的心智负担更小,出错时排查链路更清晰。工作流引擎的核心是状态机,状态机本身就要求每一步都必须落地,不能有"中间态",本地消息表天然契合这个诉求。
6. 上线后的踩坑记录与性能调优
6.1 OpenFeign调用超时导致的审批失败问题
上线初期,我们遇到过一个非常诡异的线上问题:审批人点击通过后,系统偶尔报"系统繁忙,请稍后重试",而且频率跟组织架构的复杂程度强相关。排查后定位到是OpenFeign调用user-service查询审批人时超时了。
原因说起来很尴尬。我们的user-service有一个查询"部门所有上级领导"的接口,内部要递归查组织架构。某个事业部组织层级特别深,最深的部门有七级,一次递归查完要执行二十多次SQL,耗时超过10秒。而流程引擎的Feign读取超时只配了5秒,导致查询超时直接抛异常,整个审批链路被中断。
这个问题的修复分三步。第一步,在FeignClient注解上单独为这个接口加长超时配置,从10秒改成30秒。第二步,优化user-service的查询逻辑,把递归查询改成一次性查出整棵组织树然后在内存里计算,耗时从10秒降到200毫秒,这是根治。第三步,在引擎侧把"查询审批人失败"改成了降级处理——查不到审批人任务挂起并告警,而不是让整个流程失败。经过这轮优化,类似问题再没出现过。
这个坑给我们的教训是:跨服务调用的超时配置必须跟被调方的真实性能对齐,不能凭感觉设一个值。而且服务间的接口性能必须要有持续监控,否则随着数据增长,某天某个慢接口就会把整个流程引擎拖下水。
6.2 流程定义缓存与数据库索引优化
上线三个月后,流程引擎的接口耗时开始明显上涨。分析后发现,热点集中在两个地方:一是流程定义的读取,二是待办列表的查询。
流程定义这块,我们最初的设计是每次流转都从数据库读process_definition表和wf_process_snapshot表,运行中的流程多了之后,这两张表的查询频率非常高。优化方案很简单:把快照和定义内容放到Redis缓存,缓存key是wf:def:{processKey}:{version}和wf:snp:{instanceId},设置了30分钟的过期时间。流程定义发布时主动删除相关缓存key,保证变更后第一次请求能拉到最新数据。实测下来,流程定义读取接口的P99耗时从80毫秒降到了5毫秒。
待办列表这块的问题在SQL。最初的wf_task表只有主键索引,查询"某个人所有待办"要走全表扫描,数据量到几十万就开始卡。我们加了几组关键索引:
ALTER TABLE wf_task ADD INDEX idx_assignee_status (assignee_id, status); ALTER TABLE wf_task ADD INDEX idx_instance_id (process_instance_id, status); ALTER TABLE wf_task ADD INDEX idx_create_time (create_time);同时把待办列表的排序字段从create_time降序改成按status和create_time组合排序。配合分页时用游标分页替代深分页偏移量,列表接口的响应时间从1.2秒降到了200毫秒以内。这里特别说一下:工作流的待办列表数据天然是"热尾"分布——大部分人待办很少,极少数角色(比如部门主管)会有几百条待办,所以索引设计上优先保障按处理人维度的查询效率。
6.3 线上限流配置的教训与Sentinel规则持久化
最后一个坑来自限流规则本身。初期我们图省事,直接在Sentinel控制台上为workflow-engine-service的"发起流程"接口配置了一条QPS=100的流控规则。当时验证没问题,但有一次我们重启了workflow-engine-service的某个实例,第二天一早运营反馈流程发起失败率变高。查了半天才发现,重启后Sentinel控制台上配置的规则丢失了,接口回到无限制状态,而当天正好是月初审批高峰,流量直接打满了数据库连接池。
修复方案是把所有流控规则迁移到Nacos配置中心。具体做法是写一个FlowRuleInitRunner,在服务启动时从Nacos读取规则配置,调用FlowRuleManager.loadRules()加载。以后再改限流阈值,直接在Nacos配置中心改,利用@RefreshScope动态刷新,不需要重启服务。另外还把"降级规则"也加了进去:当某个接口错误率超过30%时,直接熔断10秒,给下游服务喘息的机会。
这个经历告诉我们,任何线上的动态配置,都要考虑"持久化"和"幂等加载",不能在控制台上配完就以为万事大吉。
最后再说点实际感受
这个项目从调研到上线,前后差不多花了四个月。整个做下来最深的体会是:每一个复杂问题的背后,都有一堆"看似简单但必须较真"的细节。比如流程定义快照这个设计,说穿了就是一个复制JSON的动作,但如果没有它,流程中途变更规则就会造成线上混乱。再比如乐观锁,只是一个version字段加一条update语句,却决定了整个引擎在多人并发场景下是否稳得住。
如果你准备在公司里做类似的流程中台或工作流引擎,我建议先把"流程定义模型""节点类型""审批人类型"这几个基础概念约定清楚,再动手写代码。模型定了,后面的实现就顺了。还有一点,做一个员工工作流引擎,不要一开始就想把所有BPMN的高级特性都实现,串行审批、条件分支、会签或签、驳回重做这四板斧足够覆盖绝大多数企业内部场景。把这四板斧做到位,比把一个半成品的复杂引擎扔上线要靠谱得多。
如果这篇文章对你有帮助,希望你能在评论区聊聊你公司现在的流程系统是怎么设计的,或者踩过哪些我上面没提到的坑。互相交流,大家都能少走弯路。