
1. 这不是重复造轮子而是给工作流做“减法手术”最近在几个技术群里看到不少人在问“Flowable 都这么成熟了为什么还有人坚持手写一个轻量工作流引擎”——这个问题我去年也反复被问过三次一次是客户现场评审会上一次是团队内部架构讨论一次是朋友约饭时掏出手机直接甩来截图“你看这个 GitHub 项目 star 涨得比我们 KPI 还快到底图啥”我的回答从来不是“因为 Flowable 太重”而是先反问一句“你们上一个用 Flowable 跑通的最小可运行流程从建模、部署、启动、审批到归档一共动了多少个类、配了多少个 YAML、启了多少个 Spring Bean、查了多少次ACT_RU_EXECUTION表”没人答得上来。但我知道答案平均 17 个配置项、9 个依赖包、5 层抽象接口、3 次数据库事务嵌套外加至少 2 小时调试ProcessEngineConfiguration初始化失败的日志。这不是夸张。我上周帮一家做 SaaS 化进销存系统的创业公司做流程模块重构他们原系统用 Flowable 实现“采购申请→部门审批→财务复核→入库通知”四步流程。上线后发现单次流程实例创建耗时 420ms其中 280ms 花在DeploymentBuilder解析 BPMN XML 上流程变量序列化/反序列化占 CPU 时间 37%而真正业务逻辑执行只用了 63ms。更麻烦的是他们只需要“审批通过/驳回”两个动作却被迫继承AbstractBpmnActivityBehavior、重写execute()、处理ExecutionEntity生命周期、监听TaskListener……最后代码里 83% 是 Flowable 的胶水层17% 才是他们自己的业务判断。这就是问题的本质Flowable 是一台精密的数控机床而很多场景只需要一把能拧紧螺丝的活动扳手。它解决的是企业级复杂流程编排、跨系统协同、历史审计追溯、多租户隔离、高并发流程实例调度等命题但如果你的系统本质是“带状态跳转的表单提交器”那 Flowable 就像用航天级钛合金去造订书钉——材料没错但成本、重量、维护难度全都不匹配。关键词里出现的jeeflow、LogicFlow其实已经透露了风向jeeflow 是面向低代码平台的轻量流程内核核心只有FlowContextNodeExecutorStateTransition三个类LogicFlow 更激进连状态机都不要直接用 JSON 描述节点关系靠前端渲染后端校验驱动流转。它们不是 Flowable 的竞品而是对同一问题的不同解法切片——就像 Linux 内核和 BusyBox 的关系一个提供完整能力矩阵一个只交付最小可行路径。所以“为什么还要写一个轻量工作流引擎”答案不是“Flowable 不好”而是“Flowable 在太多场景里好得过了头”。我们真正需要的不是另一个 BPMN 解析器而是一个能用 200 行代码说清“状态怎么变、谁来干、干完去哪”的确定性模型。接下来我会拆解这个模型怎么设计、怎么落地、怎么避开那些看似合理实则致命的坑。2. Flowable 的“重”不是代码量而是它的抽象契约很多人一提 Flowable 重第一反应是“jar 包太大”“依赖太多”。这其实是个严重误解。Flowable 7.x 的flowable-engine核心 jar 只有 2.1MBSpring Boot Starter 加起来也不过 3.8MB在现代 Java 应用里根本不算负担。真正的“重”藏在它强制你签署的三份抽象契约里。2.1 契约一你必须接受“流程即文档”的世界观Flowable 把 BPMN 2.0 规范当作宪法。这意味着每个流程定义必须是合法的 XML 文件或数据库里的 CLOB 字段必须通过BpmnXMLConverter解析所有网关ExclusiveGateway、InclusiveGateway、事件StartEvent、EndEvent、任务UserTask、ServiceTask都必须映射到规范定义的语义即使你只想实现“if-else”分支也得画两个 SequenceFlow配两个conditionExpression再写 EL 表达式。我见过最典型的反模式是某政务系统把“市民提交材料→窗口初审→后台复核→发证”做成 BPMN 流程。开发为了省事把“初审不通过”直接连到 EndEvent结果测试时发现流程结束后所有变量包括身份证号、材料附件ID全被自动清理无法触发短信通知和日志归档。原因BPMN 规范规定 EndEvent 是流程终点Flowable 会执行deleteProcessInstance清理上下文。他们本意是“终止当前分支”实际却签了“整个流程死刑协议”。轻量引擎的破局点在于把流程定义降维成状态迁移图State Transition Graph。比如上面的场景只需定义{ states: [SUBMITTED, REVIEWING, VERIFIED, ISSUED, REJECTED], transitions: [ {from: SUBMITTED, to: REVIEWING, trigger: submit}, {from: REVIEWING, to: VERIFIED, trigger: approve, guard: checkAttachment()}, {from: REVIEWING, to: REJECTED, trigger: reject} ] }没有网关概念没有事件类型没有 XML 解析开销。状态变更就是纯内存操作guard函数可直接调用业务服务失败就卡在当前状态无需担心上下文丢失。2.2 契约二你必须拥抱“执行即事务”的强一致性模型Flowable 默认开启PROPAGATION_REQUIRED事务传播每个task.complete()、runtimeService.startProcessInstanceByKey()都绑定数据库事务。这保证了流程状态与业务数据的一致性但也带来硬伤事务边界模糊当 ServiceTask 调用外部 HTTP 接口时Flowable 会把整个 HTTP 调用塞进数据库事务。一旦超时事务回滚但 HTTP 请求可能已成功如支付扣款造成“流程回滚、钱已付”的经典分布式陷阱锁竞争激烈高并发下多个线程同时complete同一任务会争抢ACT_RU_TASK表的行锁TPS 直接腰斩调试成本爆炸想查某个任务为什么没触发监听器得翻ACT_HI_DETAIL表找历史变量再关联ACT_HI_ACTINST查执行链路最后在ACT_RU_EXECUTION看当前执行树——三张表关联查询索引还未必覆盖全。轻量引擎的解法是主动放弃“强一致幻觉”改用最终一致性 显式状态机。核心原则就一条流程引擎只管状态变更不管业务执行。比如审批任务完成时引擎只做两件事将任务状态从ASSIGNED更新为COMPLETED发布TaskCompletedEvent事件用 Spring Event 或 Kafka。业务监听器收到事件后再调用paymentService.deduct()。如果调用失败事件可重试状态机卡在COMPLETED不动人工介入时一眼就能看到“任务已完成但付款未到账”。这比 Flowable 的“事务内调用失败导致流程卡死在中间状态”要透明得多。2.3 契约三你必须承担“扩展即侵入”的定制成本Flowable 提供了ProcessEngineConfiguration、ProcessEngineBuilder、CommandInterceptor等全套扩展钩子但每个钩子都是双刃剑。举个真实案例某金融系统需要在流程启动时自动填充“客户风险等级”变量。开发按文档写了ExecutionListenerpublic class RiskLevelListener implements ExecutionListener { Override public void notify(DelegateExecution execution) { String customerId execution.getVariable(customerId, String.class); String riskLevel riskService.getLevel(customerId); // 这里调用了远程服务 execution.setVariable(riskLevel, riskLevel); } }上线后发现流程启动耗时从 120ms 暴涨到 1.8s。根因是ExecutionListener运行在主事务内远程调用阻塞了数据库连接而 Flowable 的ProcessEngine默认只配了 10 个连接池。更糟的是这个监听器被所有流程共享当“贷款审批”流程调用时正常但“账户注销”流程调用时因customerId为空直接 NPE导致整个流程引擎崩溃。轻量引擎的应对策略是彻底解耦扩展点。它不提供Listener而是定义清晰的生命周期事件总线ON_PROCESS_START仅传入流程 ID 和初始变量禁止任何业务调用ON_STATE_ENTER进入新状态时触发参数为state,context只读上下文ON_STATE_EXIT退出状态时触发参数为state,result执行结果。所有事件监听器必须声明EventListener(async true)强制异步执行。业务方若需同步获取风险等级必须在ON_PROCESS_START事件里预加载并存入上下文——这反而倒逼出更清晰的数据准备逻辑。提示Flowable 的“重”本质是它把企业级流程治理的复杂度全部打包塞进了开发者日常编码的缝隙里。轻量引擎不是降低标准而是把治理责任交还给业务系统自己——就像把消防栓换成灭火器前者需要整套供水管网和压力监测后者只要知道“拉销、对准、按压”三步。3. 轻量引擎的核心骨架用 300 行代码定义什么是“流程”既然目标是“轻”那第一刀必须砍在架构分层上。Flowable 分process-engine、task-service、history-service、identity-service四大模块而轻量引擎只保留一个核心概念流程实例ProcessInstance是状态机的载体节点Node是状态迁移的规则执行器Executor是规则的解释器。下面用伪代码关键注释还原其骨架。3.1 状态机内核StateMachine类87 行public class StateMachine { private final MapString, State states new ConcurrentHashMap(); private final MapString, ListTransition transitions new ConcurrentHashMap(); // 注册状态SUBMITTED, REVIEWING... public void registerState(String stateName, State state) { states.put(stateName, state); } // 注册迁移规则从 SUBMITTED 到 REVIEWING触发条件为 submit public void registerTransition(String fromState, String toState, String trigger, PredicateContext guard) { transitions.computeIfAbsent(fromState, k - new ArrayList()) .add(new Transition(toState, trigger, guard)); } // 核心方法根据当前状态和触发事件计算下一个状态 public OptionalString next(String currentState, String trigger, Context context) { return transitions.getOrDefault(currentState, Collections.emptyList()).stream() .filter(t - t.trigger.equals(trigger)) .filter(t - t.guard.test(context)) .map(t - t.toState) .findFirst(); } // 状态定义包含进入/退出行为但不耦合业务逻辑 public static class State { public void onEnter(Context context) {} // 空实现由业务子类覆盖 public void onExit(Context context) {} // 同上 } // 迁移规则含守卫函数决定是否允许跳转 public static class Transition { final String toState; final String trigger; final PredicateContext guard; public Transition(String toState, String trigger, PredicateContext guard) { this.toState toState; this.trigger trigger; this.guard guard ! null ? guard : ctx - true; } } }这段代码的关键设计选择无数据库依赖所有状态和迁移规则存在内存里启动时从 JSON/YAML 加载无事务封装next()方法纯函数式输入状态触发器上下文输出目标状态不修改任何外部状态守卫函数Guard可组合比如checkAttachment().and(checkCreditScore())比 BPMN 的 EL 表达式更易单元测试。3.2 流程实例ProcessInstance类62 行public class ProcessInstance { private final String id; private volatile String currentState; // 当前状态如 REVIEWING private final Context context; // 不可变上下文存储变量 private final StateMachine stateMachine; public ProcessInstance(String id, String initialState, Context context, StateMachine sm) { this.id id; this.currentState initialState; this.context context; this.stateMachine sm; } // 同步状态变更线程安全CAS 更新状态 public boolean transition(String trigger) { OptionalString nextState stateMachine.next(currentState, trigger, context); if (nextState.isPresent()) { // 先执行当前状态的退出逻辑 getState(currentState).onExit(context); // 原子更新状态 String old currentState; currentState nextState.get(); // 再执行新状态的进入逻辑 getState(currentState).onEnter(context); return true; } return false; // 守卫失败状态不变 } // 获取当前状态对象业务可继承 State 自定义行为 private StateMachine.State getState(String stateName) { return stateMachine.states.getOrDefault(stateName, new StateMachine.State()); } // 状态快照用于持久化或调试 public ProcessSnapshot snapshot() { return new ProcessSnapshot(id, currentState, context.clone()); } }这里刻意规避了 Flowable 的ExecutionEntity复杂继承体系。ProcessInstance就是一个 POJOtransition()方法是唯一入口所有业务逻辑通过State.onEnter/onExit注入。比如“REVIEWING”状态的进入逻辑可能是public class ReviewingState extends StateMachine.State { Override public void onEnter(Context context) { // 发送待办消息不阻塞流程主线程 notificationService.sendTodo(context.get(assignee), context.get(processId)); } }3.3 执行器NodeExecutor接口29 行// 定义节点执行契约输入上下文输出执行结果 FunctionalInterface public interface NodeExecutor { ExecutionResult execute(Context context); // 执行结果包含状态码、业务数据、下一步触发器 record ExecutionResult( StatusCode code, // SUCCESS / FAILED / PENDING Object data, // 业务返回值如审批意见 String nextTrigger // 下一步要触发的事件如 approve 或 reject ) {} // 工具方法快速构建成功结果 static ExecutionResult success(Object data, String nextTrigger) { return new ExecutionResult(StatusCode.SUCCESS, data, nextTrigger); } } // 使用示例审批节点执行器 public class ApprovalExecutor implements NodeExecutor { Override public ExecutionResult execute(Context context) { try { String opinion context.get(opinion, String.class); boolean approved APPROVE.equals(opinion); return success(opinion, approved ? approve : reject); } catch (Exception e) { return new ExecutionResult(StatusCode.FAILED, e.getMessage(), null); } } }这个设计直击 Flowable 的痛点JavaDelegate必须实现execute(DelegateExecution)而DelegateExecution又包裹了ExecutionEntity、ProcessEngineConfiguration等一堆无关对象。NodeExecutor只认Context业务方可以自由选择执行方式——同步调用、线程池异步、甚至发 MQ 消息解耦。注意轻量引擎的“轻”不是功能少而是把每个抽象都控制在单一职责边界内。StateMachine只管状态跳转逻辑ProcessInstance只管实例生命周期NodeExecutor只管节点执行。三者通过Context解耦而不是像 Flowable 那样用ExecutionEntity把所有东西焊死在一起。4. 从零搭建一个可运行的轻量工作流引擎实战光讲理论不够现在用 Spring Boot 3.x JDK 17 搭一个真实可用的轻量引擎。目标实现“请假申请→直属领导审批→HR 归档”三步流程支持拒绝重提交并全程无 Flowable 依赖。整个工程结构极简core引擎内核、webREST API、demo业务示例。4.1 步骤一定义流程模型YAML 配置在src/main/resources/processes/leave-process.yaml中声明id: leave-process initialState: SUBMITTED states: - name: SUBMITTED onEnter: com.example.demo.state.SubmittedState - name: APPROVING onEnter: com.example.demo.state.ApprovingState - name: ARCHIVED onEnter: com.example.demo.state.ArchivedState - name: REJECTED onEnter: com.example.demo.state.RejectedState transitions: - from: SUBMITTED to: APPROVING trigger: submit guard: com.example.demo.guard.LeaveDaysGuard - from: APPROVING to: ARCHIVED trigger: approve - from: APPROVING to: REJECTED trigger: reject - from: REJECTED to: SUBMITTED trigger: resubmit这个 YAML 比 BPMN XML 简洁 10 倍且 IDE 支持 Schema 校验。guard字段指向一个实现了PredicateContext的类比如LeaveDaysGuard检查请假天数是否超过 3 天Component public class LeaveDaysGuard implements PredicateContext { Override public boolean test(Context context) { Integer days context.get(days, Integer.class); return days ! null days 3; } }4.2 步骤二注入状态机到 Spring 容器Configuration public class WorkflowConfig { Bean Primary public StateMachine stateMachine(Value(classpath:processes/*.yaml) Resource[] resources) { StateMachine sm new StateMachine(); for (Resource resource : resources) { try { Yaml yaml new Yaml(new SafeConstructor()); MapString, Object config yaml.loadAs(resource.getInputStream(), Map.class); loadProcessDefinition(config, sm); } catch (IOException e) { throw new RuntimeException(Failed to load process definition, e); } } return sm; } private void loadProcessDefinition(MapString, Object config, StateMachine sm) { String processId (String) config.get(id); String initialState (String) config.get(initialState); // 注册状态 SuppressWarnings(unchecked) ListMapString, Object states (ListMapString, Object) config.get(states); for (MapString, Object stateCfg : states) { String stateName (String) stateCfg.get(name); String stateClass (String) stateCfg.get(onEnter); try { Class? clazz Class.forName(stateClass); StateMachine.State state (StateMachine.State) clazz.getDeclaredConstructor().newInstance(); sm.registerState(stateName, state); } catch (Exception e) { throw new RuntimeException(Failed to instantiate state: stateClass, e); } } // 注册迁移规则 SuppressWarnings(unchecked) ListMapString, Object transitions (ListMapString, Object) config.get(transitions); for (MapString, Object transCfg : transitions) { String from (String) transCfg.get(from); String to (String) transCfg.get(to); String trigger (String) transCfg.get(trigger); String guardClass (String) transCfg.get(guard); PredicateContext guard guardClass ! null ? createGuard(guardClass) : ctx - true; sm.registerTransition(from, to, trigger, guard); } } private PredicateContext createGuard(String className) { try { Class? clazz Class.forName(className); return (PredicateContext) clazz.getDeclaredConstructor().newInstance(); } catch (Exception e) { throw new RuntimeException(Failed to create guard: className, e); } } }这段代码展示了轻量引擎的“可装配性”YAML 是配置Java 类是行为Spring 是粘合剂。没有ProcessEngineConfiguration的千行配置也没有BpmnParseHandler的复杂解析逻辑。4.3 步骤三暴露 REST APIController 层RestController RequestMapping(/api/processes) public class ProcessController { private final ProcessInstanceFactory instanceFactory; private final StateMachine stateMachine; public ProcessController(ProcessInstanceFactory instanceFactory, StateMachine stateMachine) { this.instanceFactory instanceFactory; this.stateMachine stateMachine; } // 启动新流程 PostMapping(/{processId}/start) public ResponseEntityProcessInstance startProcess( PathVariable String processId, RequestBody MapString, Object variables) { Context context new Context(variables); ProcessInstance instance instanceFactory.create(processId, context); return ResponseEntity.ok(instance); } // 触发状态迁移 PostMapping(/{processId}/{instanceId}/transition) public ResponseEntityProcessInstance transition( PathVariable String processId, PathVariable String instanceId, RequestBody TransitionRequest request) { ProcessInstance instance instanceFactory.get(instanceId); if (instance null) { return ResponseEntity.notFound().build(); } boolean success instance.transition(request.getTrigger()); if (!success) { return ResponseEntity.badRequest() .body(new ErrorResponse(Invalid transition: request.getTrigger())); } return ResponseEntity.ok(instance); } } // 请求体 public record TransitionRequest(String trigger) {}API 设计极度克制只有/start和/transition两个端点。对比 Flowable 的RuntimeService.startProcessInstanceByKey()TaskService.complete()HistoryService.createHistoricProcessInstanceQuery()这里没有任务查询、没有历史记录、没有流程图渲染——因为这些都不是“流程引擎”的核心职责而是 UI 层该干的事。4.4 步骤四集成业务执行器Service 层Service public class LeaveService { private final ProcessInstanceFactory instanceFactory; private final NodeExecutor approvalExecutor; public LeaveService(ProcessInstanceFactory instanceFactory, Qualifier(approvalExecutor) NodeExecutor approvalExecutor) { this.instanceFactory instanceFactory; this.approvalExecutor approvalExecutor; } // 审批操作先执行业务逻辑再触发流程迁移 Transactional public void approveLeave(String instanceId, String opinion) { ProcessInstance instance instanceFactory.get(instanceId); Context context instance.getContext(); context.put(opinion, opinion); // 执行审批业务逻辑如扣减年假余额 ExecutionResult result approvalExecutor.execute(context); if (result.code() StatusCode.SUCCESS) { // 业务成功触发流程迁移 instance.transition(result.nextTrigger()); // 发送归档通知 if (approve.equals(result.nextTrigger())) { archiveService.archive(instance.getId(), context); } } else { // 业务失败记录错误流程状态不变 log.error(Approval failed for {}: {}, instanceId, result.data()); } } }这里的关键洞察流程迁移transition和业务执行approvalExecutor.execute是分离的。Flowable 强制你在JavaDelegate里既做业务又改状态而轻量引擎让你明确区分“做什么”和“变成什么样”。这种分离让单元测试变得极其简单——你可以 MockNodeExecutor只测状态机逻辑也可以 MockStateMachine只测业务执行。实操心得我在实际项目中发现轻量引擎最大的收益不是性能提升而是需求变更响应速度。上周客户临时要求“审批通过后若请假天数5天需追加总监审批”。Flowable 方案要重画 BPMN 图、重新部署、改监听器轻量引擎只需在 YAML 里加一个APPROVED_BY_DIRECTOR状态再配两条transitions5 分钟搞定。因为所有复杂度都被锁在配置里业务代码零修改。5. 避坑指南那些你以为很“轻”实则埋雷的典型设计轻量引擎容易陷入“越做越重”的陷阱。我见过三个最危险的误区每个都曾让我连续加班三天才填平。5.1 误区一用 MapString, Object 当万能上下文结果类型安全全崩很多初版轻量引擎把Context设计成MapString, Object觉得“灵活”。上线后立刻暴雷前端传days: 3字符串后端context.get(days, Integer.class)直接抛ClassCastException或者context.put(files, fileList)序列化时ArrayList变成LinkedHashMap反序列化失败。正确解法Context 必须是强类型容器。我们改造为public class Context { private final MapString, TypedValue values new ConcurrentHashMap(); public T void put(String key, T value) { values.put(key, new TypedValue(value, value.getClass())); } SuppressWarnings(unchecked) public T T get(String key, ClassT type) { TypedValue val values.get(key); if (val null) return null; if (type.isAssignableFrom(val.getType())) { return (T) val.getValue(); } // 类型转换String → Integer, Long → Integer 等 return convertValue(val.getValue(), type); } private T T convertValue(Object value, ClassT targetType) { if (value instanceof String targetType Integer.class) { return (T) Integer.valueOf((String) value); } if (value instanceof Number targetType Integer.class) { return (T) ((Number) value).intValue(); } throw new IllegalArgumentException(Cannot convert value to targetType); } }TypedValue记录原始值和类型get()方法提供安全转换。这样既保持灵活性又杜绝运行时类型错误。5.2 误区二在状态机里做异步结果状态不一致成常态有团队为了让“发送邮件”不阻塞流程直接在State.onEnter()里起线程Override public void onEnter(Context context) { new Thread(() - { emailService.send(审批通知, context.get(assignee)); }).start(); }问题来了onEnter()返回后状态已变为APPROVING但邮件可能发送失败。下次查询流程状态时显示“已审批”实际通知没发出去业务方投诉“系统说审批完了但我根本没收到消息”。正确解法异步必须可追溯、可重试。轻量引擎内置事件总线public class AsyncEventBus { private final ExecutorService executor Executors.newFixedThreadPool(5); public void publishAsync(String eventType, Object payload) { executor.submit(() - { try { // 执行业务逻辑 eventHandlers.get(eventType).handle(payload); } catch (Exception e) { // 记录失败事件供后台重试 failedEvents.add(new FailedEvent(eventType, payload, e)); } }); } } // 在 State.onEnter() 中 Override public void onEnter(Context context) { eventBus.publishAsync(SEND_APPROVAL_EMAIL, Map.of(to, context.get(assignee), processId, context.get(id))); }所有异步操作都走事件总线失败事件落库后台定时任务扫描重试。状态机永远只关心“状态是否变更”不关心“邮件是否发成功”。5.3 误区三把流程变量当数据库用结果数据一致性失控最常见反模式在Context里存大量业务数据比如context.put(orderItems, orderItems)然后在不同状态里反复读写。结果是并发修改时orderItems被覆盖流程重启后Context从数据库恢复但orderItems是深拷贝还是浅拷贝没人说得清审计时发现“订单明细变了但流程日志里没记录谁改的”。正确解法Context 只存流程元数据业务数据走独立仓储。约定Context只存 ID 类字段orderId,customerId,assigneeId所有业务数据通过Service注入的 DAO 获取状态变更时只更新Context中的状态相关字段如currentApprover,lastApprovedAt。比如“审批通过”状态onEnter()只做Override public void onEnter(Context context) { String orderId context.get(orderId, String.class); Order order orderService.findById(orderId); order.setStatus(APPROVED); orderService.update(order); // 只更新流程元数据 context.put(approvedBy, context.get(assignee)); context.put(approvedAt, Instant.now()); }这样Context始终轻量、可序列化、可审计业务数据一致性由 DAO 层保障。最后分享一个血泪教训我们在某项目上线前做压力测试发现 QPS 上不去。排查发现Context.clone()方法用了new HashMap(original)而original里存了 2MB 的 Base64 图片字符串。修复方案很简单Context的clone()方法只克隆键名值一律惰性加载。这个坑提醒我轻量引擎的“轻”必须贯穿每一个字节。