
接手一个基于若依的二次开发项目最让人头疼的不是 CRUD而是“加审批流”这种听起来简单、做起来没底的需求。团队里同事的第一反应基本都是“上 Flowable”领导也默认工作流就该配个引擎。但我翻了实际需求之后发现这个系统里的审批链路固定得不能再固定为了三个审批节点把 BPMN 引擎整个塞进来后续的部署、学习、维护成本全压在团队身上。这篇文章就把我给若依加审批流、但没碰 Flowable 的完整方案写出来包括数据表怎么设计、状态机怎么写、和若依的权限/消息/日志怎么对接以及实际跑了一个月后踩到的坑。适合在若依基础上做功能扩展、又被“工作流引擎”这个词吓住的开发者参考。1. 先泼冷水Flowable 不是审批流的标配很多团队选择 Flowable并不是因为业务需要它而是因为“业界都在用”“以后需求会变复杂”。但技术选型最怕这种没有边界的假设。在决定引入一个重引擎之前我习惯先把它的真实成本摊开算一遍。1.1 一个“上引擎”决定背后的隐性成本Flowable 这类工作流引擎本质上是一个完整的流程运行时环境。引入它意味着要维护引擎自身的流程部署、流程实例、任务、变量、历史数据等等一套独立体系。落在若依这种后台管理框架上你会发现几个很现实的问题。第一是学习曲线。团队里不是每个人都研究过 BPMN 2.0也不是每个人都理解流程定义、流程实例、任务节点、网关、监听器这些概念。新人接手一个用 Flowable 搭的审批模块首先要去补引擎知识然后才能改业务这个周期比想象中长得多。第二是部署和升级成本。Flowable 自带几十张表版本升级还要专门处理数据迁移。如果项目部署在单节点 K8s 上以后要迁移到云服务器Flowable 的整套环境也得跟着走一遍稍微出点问题就影响线上审批。第三是定制难度。若依的场景下审批往往要和若依自己的用户、角色、部门体系绑定要跟业务表的状态字段联动。Flowable 的设计是通用的通用意味着你要做很多适配工作。反过来如果自己维护一套轻量状态机这些联动逻辑就是自己写的想怎么改都行。这不是否定 Flowable而是说它不应该成为默认选项。它适合复杂的、跨系统的、需要可视化和流程版本管理的场景但若依上常见的单业务审批往往用不到这个复杂度。1.2 什么项目真的可以绕开工作流引擎根据我这次的实际经验下面几种情况完全可以不碰引擎直接自研轻量审批流流程链路固定或基本固定比如“申请人 - 直属领导 - 部门领导 - 人事归档”节点的先后关系变化很少。驳回规则简单无非是“驳回到发起人”或“驳回到上一节点”两种模式。不需要图形化拖拽流程设计器用表格配置甚至代码配置链路就够。审批节点数量少单个流程不超过五六个节点。团队对现有业务系统的掌控力强希望审批逻辑完全在自己的代码里而不是散落在引擎的 XML 和监听器里。我当时把项目的流程需求拉了个清单请假、报销、采购、合同会签每个流程都是 2 到 5 个节点没有复杂的并行网关没有跨系统会签。这种量级用状态机加三张表就能覆盖而且覆盖得还很舒服。1.3 什么样的流程复杂度必须上引擎反过来如果出现以下几种苗头我劝你不要硬扛老老实实用 Flowable审批链路经常需要动态调整业务人员要自己拖流程图。存在复杂的并行汇聚、子流程、多实例会签。需要做流程版本管理老流程没走完新流程已经改了。平台化需求多个业务系统都要接同一套流程能力。判断标准很朴素审批流如果只是业务系统里的一个“功能模块”那它就是状态机如果它是公司级的“基础能力平台”那才轮到引擎出场。若依这种单体后台绝大多数所谓审批需求都是前者。2. 三张表一套状态机轻量审批流的数据模型既然不上引擎第一件事就是把数据模型设计好。我最终的落地方案只用了三张核心表审批实例表、审批节点表、审批记录表。这三张表足够覆盖提交、同意、驳回、撤回、转办、委托等常见操作。2.1 用请假流程反推数据结构我们拿最常见的请假流程来推演。员工提交请假单表单先落在业务表biz_leave里状态是“草稿”。点提交之后系统生成一条审批实例同时按照预定义的链路生成一串节点。链条大概是这样的节点一直属领导审批节点二部门领导审批节点三人事归档确认这个链条不是存在某个 XML 文件里而是由代码根据业务类型构建生成后落到数据库的审批节点表。每审批完一个节点就把当前节点标记为已通过同时把“当前节点”指针移到下一个节点直到全部节点完成整个实例状态变为“已通过”。理解了这个过程数据模型其实就顺理成章了一张表记录“这单审批整体走到哪了、什么状态”一张表记录“这条审批链上有哪些节点、各自什么状态”再一张表记录“每一步操作谁在什么时候做了什么”。2.2 建表 SQL 与关键字段的“为什么”以下是我实际使用的建表语句去掉了业务相关字段只保留审批核心部分。-- 审批实例表 CREATE TABLE biz_approval_instance ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, biz_type varchar(32) NOT NULL COMMENT 业务类型: leave/reimburse/purchase, biz_id varchar(64) NOT NULL COMMENT 业务单号, current_node_key varchar(64) DEFAULT NULL COMMENT 当前待审批节点标识, status varchar(16) NOT NULL DEFAULT DRAFT COMMENT 实例状态: DRAFT/RUNNING/APPROVED/REJECTED/CANCELED, initiator_id bigint NOT NULL COMMENT 发起人ID, version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_biz_type_biz_id (biz_type, biz_id), KEY idx_initiator (initiator_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT审批实例表;-- 审批节点表 CREATE TABLE biz_approval_node ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, instance_id bigint NOT NULL COMMENT 审批实例ID, node_key varchar(64) NOT NULL COMMENT 节点标识如 LEADER/DEPT_LEADER/HR, node_name varchar(64) NOT NULL COMMENT 节点名称, approver_type varchar(16) NOT NULL COMMENT 审批人类型: USER/ROLE/DEPT, approver_value varchar(255) NOT NULL COMMENT 用户ID/角色编码/部门ID, node_status varchar(16) NOT NULL DEFAULT PENDING COMMENT 节点状态: PENDING/APPROVED/REJECTED/CANCELED/SKIPPED, sort_no int NOT NULL DEFAULT 1 COMMENT 节点顺序, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, finish_time datetime DEFAULT NULL COMMENT 完成时间, PRIMARY KEY (id), KEY idx_instance_sort (instance_id, sort_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT审批节点表;-- 审批记录表 CREATE TABLE biz_approval_record ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, instance_id bigint NOT NULL COMMENT 审批实例ID, node_key varchar(64) DEFAULT NULL COMMENT 节点标识, action varchar(16) NOT NULL COMMENT 操作: SUBMIT/AGREE/REJECT/WITHDRAW/TRANSFER/DELEGATE/END, operator_id bigint NOT NULL COMMENT 操作人ID, operator_name varchar(64) NOT NULL COMMENT 操作人姓名, comment varchar(500) DEFAULT NULL COMMENT 审批意见, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 操作时间, PRIMARY KEY (id), KEY idx_instance_time (instance_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT审批记录表;三个表的职责边界必须分清楚实例表只关心“整单审批处于什么位置”。它上面的current_node_key字段是流转的核心指针所有“当前该谁审批”的判断都靠它。节点表关心“这条链路每个节点的处理结果”。注意我没有设计“流程定义表”节点数据是在提交那一刻按业务类型生成的。记录表是纯审计日志只负责追加永远不 UPDATE。这一步对后续排查问题和出报表非常重要。biz_type biz_id的唯一索引给整单审批加了一层硬约束同一张业务单只能有一条审批实例。这是防止并发提交的关键兜底。2.3 不建流程定义表固定链路的妥协方案很多做传统工作流的人会问你怎么没有流程定义表流程模板都存在哪我的答案是业务方也没有要求后台可以自由配置流程链路就是写在代码里的。每个业务类型对应一个构建器提交时把节点列表生成出来。这样写的最大好处是链路逻辑可调试、可单测坏处是改链路必须发版。但对于大多数内部管理系统这个坏处完全可以接受。如果担心以后要支持配置化可以预留一张biz_approval_flow_config表把节点列表以 JSON 的方式存进去提交时解析 JSON 生成节点。这就算从“硬编码链路”平滑升级到“配置化链路”了而且完全可控不至于一上来就搞个引擎。3. 核心流转逻辑审批、驳回、撤回、转办的代码实现数据模型定了接下来是流转逻辑。这一节是整套方案的重头戏我会把提交、同意、驳回、撤回、转办这些关键操作的实现思路和代码片段完整放出来。代码基于 Spring Boot MyBatis落在若依框架里可以直接套用。3.1 提交创建实例并落到第一个节点提交审批的入口接收业务类型、业务单号和当前登录用户。核心逻辑是创建审批实例生成节点链路把实例指针指向第一个节点状态改为运行中最后写一条提交记录。Transactional(rollbackFor Exception.class) public void submit(String bizType, String bizId, Long initiatorId) { // 1. 校验业务单状态确保是草稿状态 Leave leave leaveMapper.selectByLeaveNo(bizId); if (leave null || !DRAFT.equals(leave.getStatus())) { throw new ServiceException(业务单不存在或已提交); } // 2. 创建审批实例幂等靠唯一索引兜底 ApprovalInstance instance new ApprovalInstance(); instance.setBizType(bizType); instance.setBizId(bizId); instance.setStatus(DRAFT); instance.setInitiatorId(initiatorId); instanceMapper.insert(instance); // 3. 生成节点链路这是按业务类型定制的 ListApprovalNode nodes buildApprovalNodes(instance.getId(), bizType); for (ApprovalNode node : nodes) { approvalNodeMapper.insert(node); } // 4. 指针指向第一个节点 ApprovalNode first nodes.get(0); instance.setCurrentNodeKey(first.getNodeKey()); instance.setStatus(RUNNING); instanceMapper.updateById(instance); // 5. 记录提交动作 recordAction(instance.getId(), null, SUBMIT, initiatorId, 提交审批); }buildApprovalNodes方法里就是根据业务类型 switch 创建节点列表。每个节点的approver_type和approver_value在这个阶段确定。直属领导可以取发起人部门的主管部门领导可以按角色编码查这样就和若依的组织架构挂上了钩。这里有个细节需要注意审批实例表不存冗余的业务快照例如请假天数、金额这些信息都不放进去。需要展示的时候直接查业务表避免两份数据不一致。这也让审批模块保持通用性接新的业务类型时不需要改审批表结构。3.2 同意死锁风险与行锁的正确姿势同意操作是整个流转里最容易写错的地方。核心问题是并发审批人快速点了两次“通过”或者多个审批人同时操作很可能产生脏数据。我的做法是查询实例时用SELECT ... FOR UPDATE行锁把整条审批实例锁住让同一时间只有一个操作能真正执行流转。这样代码逻辑就线性化了不需要再纠结乐观锁的版本号比较。Transactional(rollbackFor Exception.class) public void approve(Long instanceId, String nodeKey, Long operatorId, String comment) { // 1. 行锁锁定实例防止并发流转 ApprovalInstance instance approvalInstanceMapper.selectByIdForUpdate(instanceId); if (instance null || !RUNNING.equals(instance.getStatus())) { throw new ServiceException(审批实例不存在或已结束); } if (!nodeKey.equals(instance.getCurrentNodeKey())) { throw new ServiceException(当前审批节点已变化请刷新后重试); } // 2. 校验当前节点 ApprovalNode node approvalNodeMapper.selectByInstanceAndKey(instanceId, nodeKey); if (!PENDING.equals(node.getNodeStatus())) { throw new ServiceException(该节点已处理不能重复操作); } checkApprover(node, operatorId); // 3. 当前节点置为通过 node.setNodeStatus(APPROVED); node.setFinishTime(new Date()); approvalNodeMapper.updateById(node); // 4. 找下一个待办节点 ApprovalNode next approvalNodeMapper.selectNextPending(instanceId, node.getSortNo()); if (next null) { // 5. 没有下一节点整单审批完成 instance.setStatus(APPROVED); instance.setCurrentNodeKey(null); approvalInstanceMapper.updateById(instance); // 6. 回调业务单状态 callBackBizStatus(instance, APPROVED); } else { // 有下一节点指针后移 instance.setCurrentNodeKey(next.getNodeKey()); approvalInstanceMapper.updateById(instance); } // 7. 记录审批意见 recordAction(instanceId, nodeKey, AGREE, operatorId, comment); }关键点在于第 6 步的回调。审批流模块不应该直接 update 业务表最好通过一个事件发布器或者策略接口来触发业务侧的状态变更。我这里的callBackBizStatus就是根据bizType分发到具体业务处理器这样审批模块和业务模块解耦。selectByIdForUpdate虽然简单粗暴但要注意一个坑所有涉及流转的操作包括同意、驳回、撤回、转办都必须先锁同一个实例对象。如果有的地方锁实例表有的地方锁节点表就会出现死锁。我的原则是“一切流转先锁实例”保持加锁顺序一致。3.3 驳回的两种模式与实现差异驳回需求看着简单实际是业务沟通的重灾区。我调研后发现项目里真正需要的就是两种模式驳回到发起人以及驳回到上一节点。驳回到发起人时实例状态直接置为 REJECTED当前节点指针清空同时回调业务单把业务状态置为“已驳回”。发起人修改后可以重新提交重新提交走的链路仍然完整生成一遍。Transactional(rollbackFor Exception.class) public void reject(Long instanceId, String nodeKey, Long operatorId, String comment, boolean backToInitiator) { ApprovalInstance instance approvalInstanceMapper.selectByIdForUpdate(instanceId); // 省略基础校验和 approve 类似 ApprovalNode node approvalNodeMapper.selectByInstanceAndKey(instanceId, nodeKey); node.setNodeStatus(REJECTED); node.setFinishTime(new Date()); approvalNodeMapper.updateById(node); if (backToInitiator) { instance.setCurrentNodeKey(null); instance.setStatus(REJECTED); approvalInstanceMapper.updateById(instance); callBackBizStatus(instance, REJECTED); } else { ApprovalNode prev approvalNodeMapper.selectPrev(instanceId, node.getSortNo()); if (prev null) { // 没有上一节点时兜底为驳回到发起人 instance.setCurrentNodeKey(null); instance.setStatus(REJECTED); } else { // 上一节点回到待办状态 prev.setNodeStatus(PENDING); prev.setFinishTime(null); approvalNodeMapper.updateById(prev); instance.setCurrentNodeKey(prev.getNodeKey()); instance.setStatus(RUNNING); } approvalInstanceMapper.updateById(instance); } recordAction(instanceId, nodeKey, REJECT, operatorId, comment); }驳回到上一节点时有个容易漏掉的细节把上一节点的finish_time清空node_status改回 PENDING。否则已办列表和待办列表会出现同一节点既显示“已处理”又显示“待处理”的鬼畜现象。这个坑后面我会专门说。驳回意见里还应该带上“当前节点是谁驳回的”“驳回到了哪里”这些信息直接在记录表里查就能完整还原不需要额外的字段。3.4 撤回、转办、委派一个字段能解决的事发起人可以撤回。前提是当前节点还没有被审批人处理也就是node_status还是 PENDING。撤回时把当前节点终止掉实例状态置为 CANCELED。Transactional(rollbackFor Exception.class) public void withdraw(Long instanceId, Long operatorId, String comment) { ApprovalInstance instance approvalInstanceMapper.selectByIdForUpdate(instanceId); if (!operatorId.equals(instance.getInitiatorId())) { throw new ServiceException(只有发起人可以撤回); } if (!RUNNING.equals(instance.getStatus())) { throw new ServiceException(当前状态不允许撤回); } ApprovalNode node approvalNodeMapper.selectByInstanceAndKey(instanceId, instance.getCurrentNodeKey()); if (!PENDING.equals(node.getNodeStatus())) { throw new ServiceException(审批人已处理无法撤回); } node.setNodeStatus(CANCELED); node.setFinishTime(new Date()); approvalNodeMapper.updateById(node); instance.setCurrentNodeKey(null); instance.setStatus(CANCELED); approvalInstanceMapper.updateById(instance); recordAction(instanceId, null, WITHDRAW, operatorId, comment); callBackBizStatus(instance, CANCELED); }转办更简单。当前审批人觉得这事不该自己批可以把任务转给另一人。实现上就是修改节点表里的approver_value字段换成目标用户的 ID。Transactional(rollbackFor Exception.class) public void transfer(Long instanceId, String nodeKey, Long operatorId, Long targetUserId, String comment) { ApprovalNode node approvalNodeMapper.selectByInstanceAndKey(instanceId, nodeKey); checkApprover(node, operatorId); if (!PENDING.equals(node.getNodeStatus())) { throw new ServiceException(当前节点已处理); } node.setApproverType(USER); node.setApproverValue(String.valueOf(targetUserId)); approvalNodeMapper.updateById(node); recordAction(instanceId, nodeKey, TRANSFER, operatorId, 转办给: targetUserId comment); }委派则要区分一下转办是把审批权完全交出去委派是临时找人代理处理完结果要回归给原审批人。严格做委派需要多一张委托关系表记录原审批人和代理人。但对若依项目来说大多数场景转办就够用了委派功能可以先不做等业务方明确提出来再扩展也不迟。3.5 会签和或签先用最土的办法顶上如果你的审批流需求里有“多个人同时审批”先别慌。大部分项目说的会签其实就是“这几个人都要同意才算过”而不是严格的 BPMN 多实例会签。我的做法是在节点表里加一个字段approval_mode取值ANY和ALL。“或签”表示任一审批人同意即可“会签”表示所有审批人都要同意。代码里需要把会签节点的审批人 ID 列表存储下来并记录哪些人已经批了。实现细节是在节点表加total_approvers和approved_approvers两个字段。每次有人同意就把approved_approvers加一达到总数才推进到下一节点。这种数字统计的写法虽然土但对于内部系统的审批场景完全够用而且很容易排查问题。如果后期会签人数不确定、或者要支持“按比例通过”那个时候再考虑上 Flowable 也不晚。至少前期不用为这个边缘场景付出巨大的引擎成本。4. 跟若依的整合权限、接口、前端联动与安全细节审批模块的算法再漂亮最终要跑在若依框架里。这个章节讲怎么和若依的用户、角色、权限、菜单、日志体系无缝衔接。4.1 审批人解析绕开跨库查询复用若依用户服务审批节点的approver_value我设计成三种类型USER、ROLE、DEPT。USER 直接存用户 IDROLE 存若依的角色编码DEPT 存部门 ID。审批时要根据类型解析出具体的用户 ID 列表。如果审批模块和若依的系统管理模块在同一个应用里直接注入SysUserService查询就行。下面是按角色编码查用户的示例public ListLong parseApproverIds(ApprovalNode node) { if (USER.equals(node.getApproverType())) { return Collections.singletonList(Long.parseLong(node.getApproverValue())); } if (ROLE.equals(node.getApproverType())) { // 若依自带按角色查用户的接口 ListSysUser users sysUserService.selectAllocatedList( new SysUser() {{ setRoleKey(node.getApproverValue()); }} ); return users.stream().map(SysUser::getUserId).collect(Collectors.toList()); } if (DEPT.equals(node.getApproverType())) { // 查部门负责人若依的 SysDept 里有 leader 字段 SysDept dept sysDeptService.selectDeptById(Long.parseLong(node.getApproverValue())); return Collections.singletonList(dept.getLeaderId()); } throw new ServiceException(不支持的审批人类型); }如果将来审批模块被拆成独立微服务不再直接依赖若依用户表就需要在审批调用方传入“审批人 ID 列表”或通过 Feign 调用系统服务。但当前若依单体项目中直接用框架的 Service 是最经济的方案不需要自己再拼 SQL 查用户表。4.2 接口设计业务方只认状态前端只认按钮审批接口的 URL 我建议做成通用型而不是每个业务类型写一套。比如POST /approval/instance/submit提交审批POST /approval/instance/approve同意POST /approval/instance/reject驳回POST /approval/instance/withdraw撤回POST /approval/instance/transfer转办GET /approval/instance/detail审批详情这样做的最大好处是新接一个业务类型时前端审批弹窗组件可以直接复用后端也只需要新增一个节点构建器和业务回调器。接口上的权限控制直接用若依的注解PreAuthorize(ss.hasPermi(approval:instance:approve)) PostMapping(/approval/instance/approve) public AjaxResult approve(RequestBody ApproveRequest request) { return success(approvalService.approve(request.getInstanceId(), request.getNodeKey(), getUserId(), request.getComment())); }前端按钮用若依的v-hasPermi指令控制显示。注意按钮显示只是用户体验层面真正的权限校验必须落在后端。因为当前审批人是谁、能不能批这个节点只有后端根据实例状态和节点状态才能判断这个判断不能依赖前端传参。有一个细节审批详情接口一定要返回一个 DTO里面包含当前实例状态、当前节点信息、当前用户是否可审批、是否可撤回等计算好的布尔值。前端拿到这个 DTO 直接控制按钮不要在页面里自己拼逻辑判断。否则审批状态一多前端就要跟着写一堆 if else维护起来非常痛苦。如果你用的若依是 Vue3 TypeScript 分支这里还容易踩一个坑审批详情接口返回的currentNodeKey字段在流程结束后是 nullTypeScript 里如果把它定义成string类型模板中写detail.currentNodeKey.length会直接编译报错。建议接口返回类型定义成string | null或者后端统一返回空字符串。4.3 待办与消息别在事务里发通知待办列表和已办列表是审批模块的门面。待办的查询逻辑是查出biz_approval_node中node_status PENDING且审批人是当前用户的数据join 实例表和业务表返回。已办的查询逻辑是查biz_approval_record中operator_id 当前用户且 action 属于 AGREE、REJECT、TRANSFER、DELEGATE 的数据。这里有个教训消息通知不能直接写在审批事务里。如果审批通过的同时发站内信消息服务一旦超时整个事务回滚审批明明已经通过了业务单状态却还没改过来用户看到的就是“系统卡了”。正确做法是使用 Spring 的TransactionalEventListener监听事务提交成功后再发送消息。或者更简单粗暴一点在审批记录表里记下动作后另起一个异步任务扫描“已审批但未通知”的记录补发通知。后一种方案对团队要求低不容易踩“事务还没提交异步线程已经查不到数据”的坑。消息内容至少要包含业务单号、审批结果、审批意见点击消息可以跳转到对应的业务详情页。若依后台有站内信模块直接调用它的接口保存即可不需要单独搭消息系统。4.4 动态表单脚本与存储型 XSS 的防护若依生态里有人会配合表单设计器使用让节点支持“动态执行脚本”。比如“金额大于 5000 走总监审批否则走经理审批”这个需求很常见。我实现的方案是在节点表加一个condition字段存储一个 SpEL 表达式或者一个可选的脚本标识流转时判断表达式是否满足满足才进入该节点。但这里必须提醒动态执行脚本是一把双刃剑。如果表单设计器允许业务人员填写任意 JavaScript 或 SpEL服务器端执行这些脚本等于开门迎接 RCE 攻击。我最后的折中方案是不执行任意脚本只预置一批固定的表达式模板业务人员只能选择模板、填写阈值参数。这样既满足了动态条件的诉求又不会开放任意代码执行。另一个容易被忽视的点是存储型 XSS。审批意见如果不做过滤直接存库前端再用v-html渲染恶意用户可以在审批意见里塞一段脚本任何打开审批详情的用户都会中招。若依的富文本组件输出前一定要做白名单过滤比如后端统一调用 Jsoup.clean 去掉 script 标签和事件属性。别以为审批系统是内部系统就不会有人攻击安全防线不能省。5. 实际跑起来后踩过的四个坑方案上线一个月表面上风平浪静实际遇到的坑一个接一个。这里完整记录四个比较有代表性的问题每一个都是我花时间排查过的写出来帮大家少走弯路。5.1 并发提交的漏网之鱼第一版代码里提交审批的逻辑是先查业务表状态再 insert 审批实例。当时想得很简单觉得业务单状态是“草稿”才允许提交而草稿状态只会出现一次。结果上线第三天就出问题了用户快速点了两次提交两个请求几乎同时进来都查到业务单是草稿状态都认为自己可以提交结果创建了两条审批实例。业务数据直接乱套。这个问题的根子在于“检查状态”和“修改状态”之间存在时间窗口不是加一重 if 判断能解决的。我后来的修复方式是在biz_approval_instance表加唯一索引uk_biz_type_biz_id数据库层面保证同一业务单只能有一条审批实例。同时把“更新业务单状态为提交中”这个动作放在事务最前面形成条件更新UPDATE biz_leave SET status SUBMITTING WHERE leave_no #{leaveNo} AND status DRAFT如果影响行数为 0说明业务单已经不是草稿状态直接拒绝提交。这个条件更新配合唯一索引双保险彻底消除了并发提交问题。5.2 已办与待办的状态漂移驳回到上一节点后审批人 B 同时收到了两份矛盾的数据已办列表里 B 有一张“已审批”的单子待办列表里 B 又看到同一条单子“待审批”。排查后发现问题出在“驳回到上一节点”时我只改了实例表的当前节点指针没有把上一节点的状态恢复成 PENDING。上一节点在第一次审批时已经被标记为 APPROVED现在虽然当前指针指回来了但节点状态还是 APPROVED待办查询自然就漏掉了它。修复方法就是 3.3 节代码里写的驳回到上一节点时把上一节点的node_status重置为 PENDING同时清空finish_time。这个动作要放在和驳回同一个事务里否则会出现短暂的不一致窗口。我后来写了一个自动化校验脚本每十分钟扫一遍所有 RUNNING 状态的实例检查当前节点指针指向的节点是不是真的处于 PENDING。如果发现不一致立刻告警并输出完整实例、节点数据。这个脚本成了审批模块的守护神。5.3 审批通过瞬间下游回调失败审批流程走完后系统要回调业务模块把请假单状态改成“已通过”。最初版本里回调是同步的一旦业务模块抛异常整个审批事务回滚审批人明明点了“通过”提交后被告知“操作失败”实际数据却没有变化。这个问题的本质是“审批结果”和“业务状态更新”的耦合。审批的核心动作是记录并推进状态业务状态更新只是它的一个副作用副作用不应该影响主流程。我调整后的策略是审批事务里只做审批相关操作写记录表。回调业务状态通过事件监听在事务提交后执行失败则重试三次仍然失败就记录到一张失败任务表由定时任务补偿。这样审批人永远不会因为下游业务问题看到“审批失败”的提示。这种设计也方便以后接更多业务类型。审批模块只管审批业务方自己监听审批完成事件去更新自己的单据互不干扰。5.4 数据量上来了列表查询带头掉链子刚上线时待办列表只有几百条数据怎么查都快。跑了不到两个月审批记录表突破几万条待办列表的分页查询开始变慢有一次甚至慢查询超时。查看执行计划后发现问题出在排序和联表查询上。待办列表本质上是查节点表再 join 实例表和业务表最后还要按业务单的创建时间排序。业务表是分模块的审批模块没法建业务表的索引所以排序只能让数据库临时 filesort数据一多就慢了。我的优化思路是分步骤查询第一步只查节点表用索引过滤出当前用户待审批的instance_id列表第二步根据这些 ID 批量查业务表的数据在内存里组装。分页时先按节点的实例 ID 分页再回表查业务数据。这样每次 join 的数量大幅减少查询稳定在毫秒级。另外一个建议是审批记录表是做审计和留痕用的它只增不改数据量增长一定比业务表快。上线前就要规划好归档策略比如三个月前的记录迁移到归档库页面默认只查最近三个月。这个操作越早做越省事等数据堆积到百万级别再处理就很痛苦了。6. 上线一个月的复盘这套方案够用在哪、乏力在哪没有哪套方案是万能的。这个轻量审批流跑了一个月整体是稳的但我也清楚它的边界在哪里。这个章节做个阶段性复盘给后来者一个诚实的参考。6.1 与 Flowable 的直接对比很多同事问我自研这套和直接用 Flowable 到底差多少。我整理了一个对比表可以直观看到差异。对比维度Flowable轻量自研本文方案数据表数量几十张表三张表概念学习成本高需要理解 BPMN、流程实例、任务、网关低只要懂状态机流程可视化配置支持有模型设计器不支持链路靠代码或 JSON 配置动态链路支持强支持运行时变更弱变更需要发版或改配置流程版本管理内置支持需要自己实现复杂会签/子流程支持支持有限依赖扩展字段与若依系统耦合度低相对独立高直接复用若依的用户、权限服务排障成本需要理解引擎内部运行机制代码自己写的日志一看就懂适合场景跨系统、复杂流程、平台级能力内部系统、固定链路、快速交付从表里能看出来Flowable 的优势集中在“复杂流程”和“平台化能力”上而若依单体项目里的审批需求通常够不到这个复杂度。反过来轻量自研在排障成本、交付速度、可维护性上的优势非常实际。6.2 预留的演进点动态条件跳过节点、流程版本化虽然目前链路是代码写死的但我在设计时留了几个扩展点防止需求突变时推倒重来。一个是节点表里的condition字段。虽然现在只支持预置的 SpEL 表达式模板但字段已经预留了。真要支持动态跳转只需要在构建节点时把表达式解析结果纳入判断即可不用改表结构。另一个是biz_type维度的扩展性。所有节点构建逻辑都收敛在buildApprovalNodes工厂方法里新接一个业务类型就是加一个 case 和对应的回调处理器。目前接新流程的开发工作量大约一个下午就能搞定包括前端审批弹窗复用。如果有一天业务方需求真的膨胀到“运营人员要自己画流程图”那再考虑引入 Flowable 也不迟。而且有了这套自研状态机的理解团队去学 Flowable 反而更顺畅因为很多概念是相通的。6.3 我的选型标准不是小而美是够用且兜得住回看整个决策过程我最庆幸的不是“没被喷”而是把选型标准拉回到了业务本身。我当时的判断依据有三个第一这套审批流要解决的是若依系统内部的业务审批不是做一个给全公司复用的流程平台。第二团队规模有限如果引入 Flowable真正投入在业务功能开发的精力会被引擎学习和维护摊薄。第三时间窗口紧项目要求一个月内跑通三个审批流程自研方案可以把控每个环节风险完全在掌控内。当然选型没有银弹。如果你所在的项目已经明确要用流程引擎承载多个系统的复杂流程那 Flowable 就是正确答案千万不要拿这篇文章作为拒绝引擎的借口。技术选型的本质永远是让复杂度匹配真实需求而不是反过来让需求迁就技术栈的清高。