如果你的业务里已经跑了一个审批系统,核心链路就是提交申请、部门审批、HR审批、结束,你其实不需要 Activiti,也不需要 Camunda。Java 后端这个圈子,流程引擎被默认等同于“重引擎”太久,直到我在一个中台项目里用上 Easy Work 这个轻量级流程引擎,才真正把“搭一套流程”的成本压到两个工作日内。
很多刚入行的 Java 开发会有一个误区:做流程引擎的业务,不就该上 Activiti 吗?面试题里也总爱问引擎表结构、会签、驳回怎么实现。但真到了生产环境,你很快会发现,重引擎带来的复杂度,往往比业务问题本身更难处理。Easy Work 给我的感觉,更像是一个“刚好够用”的引擎:没有几十张表,没有 BPMN 编辑器,没有独立的流程服务,但核心的节点流转、条件分支、审批驳回、会签都能干净利落地做掉。
这篇文章不会给你一堆浮在表面的概念,而是从我在生产环境里的实际使用路径出发:先聊清楚轻量级引擎的定位,再拆它的核心执行原理,接着给一套可以直接抄的 Spring Boot 集成方案,然后重点讲我踩过的几个线上坑,最后聊性能调优和进阶玩法。适合的人群也很明确:后端开发、架构师、以及对 Java 流程引擎有兴趣但不想被重框架劝退的读者。
1. 轻量级流程引擎的定位:不是低配版,而是反着设计的产物
1.1 重引擎的隐形成本
我见过太多团队,项目刚立项就高高兴兴引进了 Activiti。流程确实能做,但后面几乎每一步都在还债。
Activiti/Flowable 这类引擎遵循 BPMN 2.0 规范,能力边界很完整,这是优点,也是负担。完整意味着复杂。它的数据库表动辄几十张,流程部署、流程实例、任务、身份、历史、通用数据、定时器各占一块,光靠记忆很难把每张表的作用说清楚。出了问题,第一反应往往是查官方文档而不是查业务代码。
部署成本同样不低。很多中大型项目为了流程引擎单独起服务,甚至上独立集群。引擎本身的数据库连接、缓存、定时任务、多租户隔离、权限模型,每一项都要有人维护。更尴尬的是,很多业务其实只用到“提交——审批——结束”这一条直线,重引擎的大量能力从上线到下线都从来没被触发过。
我并不是否定重引擎的价值。在复杂人力审批、跨系统编排、流程版本回溯要求极高的场景下,Activiti 依然很稳。但如果你只是需要一个“能定义节点、能流转、能审批”的基础能力,重引擎这套复杂模型反而成了业务迭代的阻碍。每次改一个审批链路上的小逻辑,都要先弄明白引擎的缓存机制、命令模式、拦截器链,学习成本被无限放大。
1.2 Easy Work 的取舍逻辑
Easy Work 的设计思路是反着来的。它不先想“规范里有什么”,而是先想“业务里天天被用的到底是什么”。
我在实际使用中总结,普通业务流引擎真正的高频能力其实就五类:顺序流转、条件分支、并行分支、驳回回退、任务分配。低频能力则是定时流程、子流程嵌套、复杂事件监听、动态编排。Easy Work 只把前五类做得足够扎实,后几类用扩展点的方式保留,而不是内置到核心。
这样的取舍带来了三个直接好处:
- 核心实体表少。我在生产环境里只维护了流程定义表、流程实例表、任务表、历史记录表,排查问题非常快。
- 流程定义用 JSON 描述,不用学 BPMN 标签。任何后端同事打开 JSON 都能看懂节点和跳转。
- 引擎可以嵌入业务服务。不依赖独立进程,没有额外的运维组件。
轻量级不是“简化版”,而是“目标明确版”。它把有限资源全部集中在业务方真正高频使用的环节,省掉的恰恰是那些用不上却又消耗认知负担的部分。
1.3 适用与不适用的场景边界
基于我的项目经验,Easy Work 非常适合这几类场景:
- OA 审批流:请假、报销、合同审批,节点不多,但分支条件灵活。
- 工单流转:客服工单、运维工单,需要按状态推进,可能涉及加签。
- 订单审核:风控审核、价格审批,需要记录每个节点的操作人和操作时间。
- 服务编排的轻量表达:把几个业务动作串成一条流程,节点里放一个个业务服务调用。
反过来,如果你们要做的是一套面向多租户的商业流程平台,或者流程中要编排大量异步任务、复杂补偿、长时间挂起的事务,那 Easy Work 这类轻量引擎不一定够用。我们要做的是选对工具,而不是迷信工具。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心执行链路:当一次 start() 被调用之后,引擎内部到底发生了什么
2.1 流程定义到流程实例的映射关系
先明确 Easy Work 里的几个核心模型,这是理解后面所有源码细节的前提。
- ProcessDefinition:流程定义。对应一条审批流程模板,比如“请假审批”“采购申请审批”。它包含流程 key、版本号、节点列表、节点之间的跳转关系。
- ProcessInstance:流程实例。一次具体业务发起的流程,比如“张三提交了请假申请”。它持有流程定义快照、变量上下文、状态。
- Task:任务。实例运行到某个任务节点时生成,通常绑定一个处理人或者处理角色。
- Execution:执行分支。并行分支场景下,一个实例可能有多个分支同时执行。
从定义到实例的映射关系并不复杂。定义是模板,实例是模板的一次运行。定义中的节点是静态的,实例中会保存当前所处节点的 id,以及节点流转需要用的变量上下文。
我在内部设计的时候会这样类比:ProcessDefinition 是绘制建筑图纸,ProcessInstance 是依据图纸开始施工。Task 则是施工过程中一个个待验收的工序。
2.2 启动流程时引擎执行了什么
当业务代码调用 engine.start(processKey, version, variables) 时,Easy Work 内部大致做了这几件事:
- 从缓存或数据库中加载流程定义,反序列化成内存模型。
- 创建 ProcessInstance,保存传入的变量上下文,初始状态为 running。
- 找到 start 节点,开始执行节点流转逻辑。
- 如果 start 的下一个节点是任务节点,生成一条 Task,流程暂停,等待处理。
- 如果 start 的下一个节点是条件或并行节点,递归执行后续流转,直到抵达任务节点或结束节点。
很多人以为 start 操作只是插一条记录,实际上引擎在这一步就把定义加载、变量初始化、节点执行汇聚成了一个小闭环。我曾被一个线上问题折腾到凌晨,最后发现是流程定义 JSON 里 start 节点指向了一个不存在的节点 id,引擎加载时没有做校验,运行期直接抛了 NPE。所以后来我们团队严格要求在部署定义时做一致性校验:所有 next 指向必须存在,条件表达式必须能被解析。
2.3 节点类型与流转策略
Easy Work 对节点的抽象很符合直觉,主要有这么几类:
- start:启动节点,一个流程只有一个。
- end:结束节点,流程实例到达后置为 finished。
- task:任务节点,生成任务并等待处理人完成。
- condition:条件节点,根据变量表达式选择唯一后继节点。
- parallel:并行节点,同时激活多个后继分支。
- subprocess:子流程节点,进入嵌套的流程定义。
每个节点都有独立的执行逻辑,但整体上统一实现同一个 NodeExecutor。这样做的好处是,如果需要扩展新节点类型,比如“消息节点”,只需要实现 executor 并注册到引擎即可,不用改动核心流转代码。
并行分支需要注意。parallel 节点会生成多个 Execution,每个 Execution 独立推进,直到汇聚节点。汇聚时引擎需要检查所有分支是否已经到齐,如果只有一部分分支到了,就要继续等待。这个机制和重引擎里的并行网关是类似的,但 Easy Work 的控制逻辑更薄,因为它没有历史流程图的完整追踪需求。
2.4 条件表达式与变量上下文
条件分支是流程引擎最容易出低级 bug 的地方,Easy Work 在这块的设计很干脆:条件表达式就是一段可以被引擎解析的字符串,变量来自启动流程时传入的 variables,以及各任务节点处理完后的返回值。
一个典型的条件节点 JSON 配置长这样:
json复制{
"id": "condition_1",
"type": "condition",
"conditions": [
{
"expression": "${days <= 3}",
"target": "manager_approval"
},
{
"expression": "${days > 3}",
"target": "hr_approval"
}
]
}
引擎执行到 condition_1 时,会用当前变量上下文里的 days 值去计算表达式,命中哪一个条件就走哪一条分支。如果所有条件都没命中,引擎会抛异常,而不是“随机选一条”。这是当时我强烈要求保留的行为——静默走错分支比直接抛错可怕得多。
条件表达式这块,Easy Work 底层可以对接多种表达式引擎。默认实现是基于统一表达式解析的轻量解析器,性能和功能都够用。如果你有更复杂的脚本需求,也可以替换成 Groovy 或 SpEL 的实现,但我不建议把业务逻辑写进流程表达式里。流程只是“路由”,真正的业务判断应该放在传入变量的生产方。
3. 从 Demo 到生产:Spring Boot 集成的最短可行路径
3.1 依赖引入与自动装配
Easy Work 提供了 Spring Boot Starter,集成成本很低。我在项目里的做法是只引入核心依赖,避免把不需要的扩展全部装上。
xml复制<dependency>
<groupId>com.github.easywork</groupId>
<artifactId>easy-work-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency>
Starter 会做人自动配置,比如创建 ProcessEngine 实例、初始化数据源、加载默认的节点执行器。我在实际落地中还会额外加一个配置项,显式告诉引擎使用哪个 schema 和自己的数据源:
yaml复制easywork:
enabled: true
datasource:
schema-prefix: biz_workflow
expression:
type: el
这里有个容易被忽略的点:Starter 默认会尝试创建核心表。如果你已经有一个成熟的数据库升级流程,建议关闭自动建表,把建表脚本纳入项目的统一版本管理。毕竟生产环境的数据库变更不能靠引擎启动时顺手做。
3.2 用 JSON 定义一条请假审批流程
下面是我在测试环境反复使用的一条定义。它覆盖了顺序流转、条件分支、两个审批节点。
json复制{
"key": "leave",
"name": "请假审批",
"version": "1.0.0",
"nodes": [
{
"id": "start",
"type": "start",
"name": "开始",
"next": "apply"
},
{
"id": "apply",
"type": "task",
"name": "填写请假申请",
"assignee": "${applyUserId}",
"next": "manager_approval"
},
{
"id": "manager_approval",
"type": "task",
"name": "部门负责人审批",
"assignee": "${managerUserId}",
"next": "condition_days"
},
{
"id": "condition_days",
"type": "condition",
"name": "请假天数判断",
"conditions": [
{
"expression": "${days <= 3}",
"target": "end"
},
{
"expression": "${days > 3}",
"target": "hr_approval"
}
]
},
{
"id": "hr_approval",
"type": "task",
"name": "HR审批",
"assignee": "${hrUserId}",
"next": "end"
},
{
"id": "end",
"type": "end",
"name": "结束"
}
]
}
部署定义的代码很简单:
java复制String definitionJson = Files.readString(Paths.get("leave.json"));
engine.deploy("leave", "1.0.0", definitionJson);
部署不是“存 JSON 字符串”,引擎会解析节点、索引流向、预编译表达式、做一致性校验。如果 JSON 格式有问题或者节点引用缺失,部署阶段就应该抛错,而不是等到流程启动时才炸。
3.3 核心 API 实战:启动、审批、驳回
集成到最后,真正的业务代码其实就三个操作。
启动流程:
java复制Map<String, Object> vars = new HashMap<>();
vars.put("applyUserId", 1001L);
vars.put("managerUserId", 1002L);
vars.put("hrUserId", 1003L);
vars.put("days", 5);
ProcessInstance instance = engine.start("leave", "1.0.0", vars);
启动后引擎会生成第一条待办任务。查待办并完成任务:
java复制List<Task> tasks = engine.getTodoTasks(1001L);
for (Task task : tasks) {
if ("leave".equals(task.getProcessKey())) {
Map<String, Object> result = new HashMap<>();
result.put("approved", true);
result.put("remark", "同意");
engine.complete(task.getId(), 1001L, "同意", result);
}
}
驳回则更直接,告诉引擎当前任务、操作人、要退回的节点 id:
java复制engine.reject(task.getId(), 1001L, "驳回重填", "apply");
这里我要提醒一句:驳回要搞清楚是“驳回到指定节点并重新生成任务”,还是“驳回到某个历史节点且后续节点都作废”。Easy Work 的默认行为是前者:目标节点会生成新任务,当前任务关闭,之前已经走过的节点不会回滚。这个语义非常适合审批驳回场景;但如果你需要“驳回后整条流程回到起点重新走”,就要额外处理历史任务的状态。
4. 我把这套引擎推进生产后,踩出来的五个大坑
4.1 并发启动流程导致热点行锁
第一个线上事故发生在业务高峰期。多个用户同时发起同一类型流程,数据库出现大量的锁等待,接口耗时从几十毫秒飙升到几秒。
排查过程很有意思。我先看到慢 SQL 里有大量针对流程定义表的 SELECT ... FOR UPDATE。翻引擎源码才发现,启动流程时为了防止定义被并发修改,Engine 会在拿到定义后对定义主键加一把行锁。这个设计在流程定义频繁热更新的场景下是合理的,但我们的流程定义很少变,启动却非常频繁,锁就变成了热点。
最终解决方案很直接:在引擎配置里开启定义缓存,部署后的定义常驻内存,启动流程时从缓存加载,不再每次查库拿行锁。同时对定义表做主从分离,引擎只读从库,只有 deploy 操作走主库。
4.2 事务边界没控制好,业务数据回滚了任务却推进了
这是流程引擎集成中最经典的坑,Easy Work 默认不会帮业务方管理事务。启动流程、完成任务这两个动作,如果放在一个事务里,正常没问题;但如果你把任务完成后的业务操作放在引擎调用之后,事务一提交,业务数据回滚,任务状态却已经变了。
举个真实例子。我们有个订单审核流程,任务完成后要扣减库存。代码写的是:
java复制engine.complete(taskId, userId, result);
deductStock(orderId);
结果库存扣减超卖了,事务回滚。任务状态却已经推进到了下一个节点,导致订单状态还停留在“待审核”,但流程已经走到“已审核通过”。客户在后台看到一条永远处理不了的死单。
解决方法是把引擎调用和业务操作放到同一个本地事务里,或者把任务完成设计成“先预留”模式:业务成功后通过回调接口确认任务完成。第二种方案更稳,但实现复杂度高。我们在实际项目里优先保证事务一致,坚决不在事务外直接调用完成接口。
4.3 驳回之后出现幽灵任务
又一次线上问题:用户点击驳回之后,新任务没有出现在待办列表里。我去查任务表,发现同一个流程实例里有两条当前状态为可处理的任务,一条是旧的审批任务,一条是新生成的驳回节点任务。按处理人查询待办时,默认只查最新一条,旧任务就被漏掉了。
根本原因很简单。驳回的实现里先关闭当前任务,再创建目标节点任务,两个操作应该在一个事务里保证原子。但我们某个版本里,这两个操作中间插入了一个业务事件通知,通知因为网络问题抛了异常,当前任务关闭成功,新任务还没创建,事务被标记为 rollback-only。回滚时没回干净,最终留下一个“半状态”。
从那之后,我们给引擎核心表都加上状态约束,并且在创建新任务前判断同实例是否存在未关闭的当前任务。一旦发现异常,宁可外抛错误也不要静默跳过。幽灵任务比明确报错难排查十倍。
4.4 流程定义版本管理缺失
最初我们部署流程定义时只覆盖部署,没有保留历史版本。某天产品提了个小需求:请假超过 7 天的审批人从 HR 改成总监。我直接改了 JSON 然后 deploy 覆盖。结果所有正在运行中的旧实例,下一跳全部指到了新节点,审批逻辑瞬间变了。
这是流程引擎使用中最常见的版本思维缺失。正确做法是:每次部署生成一个新的版本号,新流程实例使用最新版本,旧实例继续沿用启动时的定义快照。Easy Work 支持定义版本,但需要业务方自己保证 deploy 时不覆盖关键 key。我个人强烈建议加一个上线规范:流程定义的变更必须走 Review,变更记录写明影响范围和兼容策略,绝对不能手动覆盖。
4.5 超时任务没有兜底
审批流程挂在某个处理人那里两周没人动,业务方也没催,流程就默默躺平了。重引擎自带定时提醒能力但不一定合适,轻量引擎更不会主动帮你做。
我们在 Easy Work 之上加了一个简单的超时任务扫描器:每 10 分钟扫描一次所有状态为 running 的 Task,对比创建时间,超过业务配置的 SLA 就发送提醒消息;再超过一级时限就直接自动转交上级。这个逻辑不复杂,但一定要通过独立定时任务模块跑,不要阻塞主流程处理线程。
5. 性能压测与调优过程:把单个实例的平均处理时间从 12ms 压到 3ms
5.1 瓶颈定位:JSON 解析和表达式反射
第一轮压测结果并不好看。单实例启动流程平均耗时在 12ms 左右,高并发下还会有很大波动。我用 JFR 抓热点,发现 CPU 消耗主要不在数据库 IO,而在两个地方:流程定义的 JSON 序列化和条件表达式的反射解析。
每次启动都从数据库读取定义 JSON 并反序列化,确实很浪费。优化后,定义对象在部署成功后直接缓存到本地内存,流程定义变更通过版本号失效缓存。因为这个项目是单机部署,所以无分布式缓存问题;如果以后横向扩展,需要考虑缓存一致性,把定义变更做成一条消息广播到所有节点。
表达式反射优化相对简单:表达式解析器支持预编译,把字符串解析成可执行对象。流程定义加载时预编译所有条件表达式,执行时直接运行编译结果,避免每次都走一遍词法分析。
压测结果从 12ms 降到了 3ms 左右。数据库操作耗时只占了其中不到 1ms,其余基本都是定义加载、表达式执行、任务状态更新。这说明轻量级引擎的性能潜力非常可观,前提是别把 JSON 解析反复放在请求链路上。
5.2 待办列表的查询优化
流程引擎最频繁的查询就是“我的待办”。大多数流水线做法是直接查 Task 表,但 Task 表数据量大了以后,索引设计就变成关键。
我们给 Task 表建了联合索引 (assignee_id, status, create_time),查询待办按处理人过滤状态,再按创建时间倒序排序。一开始漏了 create_time,导致数据量大了之后排序走临时文件,性能掉得很厉害。加上 create_time 之后,查询稳定在毫秒级。
另一个优化点是分页。用户待办不能一页全查出来,我们用游标分页而非 offset 分页。因为用户通常只查看前几页,游标查询可以用索引直接定位,避免深度分页。
5.3 监控与自愈:给引擎本身加心跳
流程引擎是业务系统的关键路径,一旦它卡住,整个审批链路都会停摆。所以我们后来给 Easy Work 加了一层轻量监控。
核心指标主要有这几个:
- 流程实例启动速率。
- 任务完成成功率。
- 当前待处理任务数。
- 引擎内部异常计数。
- 从“任务创建”到“任务完成”的平均耗时。
我通过 Micrometer 暴露这些指标,接入 Prometheus 和 Grafana。某次线上版本发布后,任务完成率突然从 99% 掉到 80%,就是因为前面提到的幽灵任务问题。如果没有监控,我们可能还要客户先发现。
自愈方面,我们加了一个兜底扫描任务:把异常状态的任务统一转移到死信区域,每小时重试一次。能做重试的不多,但至少不会让流程永远卡死在用户不可见的地方。
6. 进阶玩法:会签、子流程与自由跳转
6.1 会签与或签的落地实现
Easy Work 本身核心模型里没有强制内置会签,但它提供了可扩展的任务节点属性,让我可以在节点配置里加 signType 和 signUsers 来实现。
会签(AND 会签)要求所有审批人都同意才通过。实现方式:当流程走到会签节点时,引擎一次性生成 N 条任务,每个 signUsers 里的人都有一条独立任务。每条任务完成时记录自己的审批结果,同时检查同实例同节点下是否还有未完成任务。全部完成且全部同意才流转到下一节点;任意一个人拒绝,整条流程直接走 reject 分支。
或签(OR 会签)则简单很多:只要一个人审批通过,立刻关闭同节点的其他任务,流转到下一节点。这里需要额外注意并发:两个人同时点通过时,要保证只有一个能触发流转。我踩过的坑就是没有做并发控制,导致后续节点生成了两条任务。最终通过给同实例同节点加分布式锁解决。
6.2 子流程嵌套与回到父流程
子流程是 Easy Work 中比较容易被忽略的能力。我面对的场景是:主流程里某个审批节点需要走一段独立的子流程,比如“合同审批”这个节点,内部还要走“法务审核、财务审核、总经理审批”。
子流程节点的配置一般是在主流程节点上声明 subProcessKey 和 subProcessVersion。引擎执行到这个节点时,创建一个新的子流程实例,并记录父流程实例 id 和待恢复节点 id。子流程结束后,通过回调恢复父流程继续推进。
这里有个坑:子流程和父流程之间的变量传递。我们规定子流程启动时只读取父流程变量的一个快照,子流程结束后通过统一的返回节点合并部分变量回父流程,而不是实时共享。这样做的好处是子流程可以独立回滚,不会影响父流程已经走过的路径。
6.3 动态加签与自由跳转
有时候业务方希望审批人中途加一个人。Easy Work 的核心 API 里没有直接暴露“加签”,我就在上游封装了一层:拿到当前任务,复制一份同节点任务,指定给新的审批人,同时给原任务加一个“已加签”标记。加签任务完成后再回到原来的节点,继续等待原处理人。
自由跳转要更谨慎。虽然引擎层面提供 moveTo 接口,但我强烈不建议在业务代码里随意调用。因为这个操作会绕过流程定义中的既定路线,让历史记录变得不可解释。我们在生产环境里只开放了两种自由跳转:驳回、撤回。其他跳转都要通过新增节点或条件分支来实现,尽量让流程的定义永远和实际执行一致。
从核心原理到生产落地,Easy Work 给我的整体感受是:它不会替你解决所有流程问题,但能把基础能力打得足够扎实,留出扩展空间。使用过程中最重要的一件事,永远是定义清楚边界——哪些逻辑放引擎,哪些逻辑放业务代码,哪些操作必须保持事务一致。只要边界清晰,轻量级引擎也能扛住生产环境的高压。我自己现在每接入一个新流程,都会先写一页简单的设计说明,把节点类型、变量来源、异常恢复策略列清楚,这个习惯帮我避开了后续无数麻烦。
