
讲个真事前阵子帮一个团队排查 Flowable 部署后首个流程实例创建时直接抛异常的问题。配置看着没问题数据源没问题表也自动建了但一启动流程就报could not execute statement。后来翻到 MyBatis 的日志才发现引擎在插入ACT_RU_EXECUTION时拿到的连接并不是我们期望的那个库。那一下我就意识到很多人写 Flowable 代码能跑通 demo但一旦进入 DB 访问这层整个引擎的数据流转其实是个黑盒。这篇东西就是想把 Flowable 工作流引擎的 DB 访问层彻底拆开。核心围绕几个问题引擎执行一条命令时数据库连接是怎么获取和释放的MyBatis 是怎么嵌进 Flowable 的一张运行时表的数据从产生到落库源码层面经历了什么适合已经跑通过 Flowable 集成、但想深入源码或者准备做二次开发的同学也适合正在排查数据库相关诡异报错的人。1. 为什么 Flowable 的 DB 访问值得单独拿出来研究1.1 工作流引擎的本质就是状态机的持久化很多初学者把 Flowable 当作一个“能调接口的工作流库”其实它真正核心的东西是状态机加持久化。流程定义部署、流程实例启动、任务完成、网关判断每一步都是对运行时状态的一次变更。而这些状态如果不落库重启之后就全部丢失引擎就没有存在意义了。所以 DB 访问不是 Flowable 的附属模块而是整个引擎的命脉。再往深一层看Flowable 对 DB 访问的设计并不只是“用 MyBatis 操作数据库”这么简单。它在 MyBatis 之上又封装了一套Session机制让每个命令Command在执行期间持有独立的数据库会话资源。这意味着你看到的引擎行为相当一部分是被 DB 访问层决定的包括事务边界、连接释放时机、锁的获取方式。1.2 从一次实例启动看 DB 访问的生命周期我们用一个最常见的操作来做引子runtimeService.startProcessInstanceByKey(leaveProcess)。这行代码的背后Flowable 至少访问了十几张表包括部署相关的ACT_RE_PROCDEF、运行时相关的ACT_RU_EXECUTION、ACT_RU_TASK以及历史相关的ACT_HI_PROCINST、ACT_HI_ACTINST、ACT_HI_TASKINST。这个过程中每一步都涉及 DB 访问层的核心逻辑查询最新版本的流程定义 -ACT_RE_PROCDEF校验流程定义状态 - 读取部署资源和字节创建流程实例 - 插入ACT_RU_EXECUTION创建初始任务 - 插入ACT_RU_TASK触发监听器和监听类 - 可能更新运行时表记录历史 - 插入ACT_HI_*系列表如果你只有一个印象“Flowable 内部会自动处理”那你很难解释为什么一条 SQL 慢会导致整个流程实例启动失败也很难理解为什么批次提交的时机点安排在命令执行完毕之后。所以要从源码层面真正理解 Flowable第一个绕不开的就是 DB 访问。2. 源码入口CommandExecutor、CommandContext 与 Session 机制2.1 CommandExecutor 如何拉起一次完整的数据访问Flowable 的所有操作都遵循命令模式。你调startProcessInstanceByKey最终会包装成一个StartProcessInstanceCmd由CommandExecutor执行。CommandExecutor的实现类CommandExecutorImpl源码长这样public class CommandExecutorImpl extends AbstractCommandExecutor { protected CommandContextFactory commandContextFactory; public T T execute(CommandConfig config, CommandT command) { CommandContext context commandContextFactory.createCommandContext(command); try { return context.execute(command); } finally { context.close(); } } }看到这里就能明白一个关键点每一个命令都会创建一个全新的 CommandContext并且在命令结束后统一关闭。这个关闭动作不是简单的释放对象它会触发事务提交或回滚、关闭 MyBatis 的 SqlSession、释放数据库连接。我再补充一个容易被忽略的细节CommandContext内部维护了一个sessionMap所有类型的 Session 都存在这个 Map 里。同一个命令执行期间多次访问同一类型的 Session 拿到的是同一个实例。这意味着一次命令就是一个天然的数据库事务边界这一点对理解 Flowable 的写操作非常重要。2.2 Session 接口设计为什么 Flowable 不直接暴露 MyBatisFlowable 的Session是一个很薄的接口public interface Session { void flush(); void close(); }就这么简单。任何想跟外部资源打交道的能力都通过实现这个接口来提供。DbSqlSession是其中最重要的一员它内部封装了 MyBatis 的SqlSession对外提供 Flowable 自己的 CRUD API。这个设计为什么合理因为 Flowable 引擎不仅仅依赖数据库。它还需要访问部署资源、处理 Job 定时任务、管理自定义实体缓存等。如果把 DB 访问和引擎逻辑耦合在一起那每加一种资源类型都要改核心代码。用了Session抽象之后两边的依赖就反过来了引擎核心只依赖Session接口具体是数据库还是其他存储由实现类决定。2.3 DbSqlSession 的内部结构DbSqlSession是 Flowable DB 访问的核心类我建议你直接打开源码跟着读。它的内部持有这些关键对象SqlSession sqlSessionMyBatis 的 SqlSession真正的 SQL 执行器DbSqlSessionFactory dbSqlSessionFactory负责创建当前 Session 的工厂同时也是所有 Mapper 的注册中心ListObject insertedObjects待插入的实体缓存列表ListObject updatedObjects待更新的实体缓存列表ListObject deletedObjects待删除的实体缓存列表注意这三个 List它们是理解 Flowable 批处理的关键。执行一条命令时Flowable 并不会每操作一行就立刻insert一次而是先把实体对象放进对应的 List等到命令执行完毕前统一flush。这样设计的原因很直接一次命令通常涉及多张表的多条数据变更分批插入会带来大量的网络往返和事务提交开销统一提交可以显著提升性能。举个例子启动流程实例时ACT_RU_EXECUTION和ACT_HI_PROCINST都会插入数据。如果分开提交至少产生两次事务提交。而 Flowable 的做法是在同一个CommandContext中把这些实体全部放入insertedObjects在命令最后统一执行 SQL。这个批处理机制在你做批量任务或者大批量流程启动时性能差异特别明显。3. MyBatis 与 Flowable 的整合细节SqlSession 的获取与事务绑定3.1 Flowable 如何加载 Mapper XMLFlowable 使用了 MyBatis但并没有抄袭 Activiti 老版本的全部配置方式而是自己搞了一套MyBatisInterceptor和 XML 映射的加载逻辑。在引擎启动阶段ProcessEngineConfigurationImpl会调用initMybatisConfiguration()扫描org/flowable/db/schema和各个模块下的 Mapper XML 文件。这一步的产物是一个MyBatisConfiguration对象你可以把它理解为 MyBatis 的Configuration加强版。它里面注册了所有引擎内置的 Mapper 接口和 XML 映射比如mapper namespaceorg.flowable.common.engine.impl.persistence.entity.ProcessDefinitionEntityManager select idselectProcessDefinitionByKey parameterTypemap resultMapprocessDefinitionResultMap select * from ACT_RE_PROCDEF where KEY_ #{key} and VERSION_ (select max(VERSION_) from ACT_RE_PROCDEF where KEY_ #{key}) /select /mapper如果你做过 Flowable 的定制开发想加自己的 Mapper就需要继承ProcessEngineConfigurationImpl并重写initMybatisConfiguration在父类初始化结束后把自定义的 Mapper 追加进去。这块坑很多最典型的问题就是你加了 Mapper 但引擎不认原因大概率是 Mapper XML 没有被扫描到resource列表里。3.2 每次命令执行时 SqlSession 从哪里来DbSqlSession创建的时候会调用一个关键方法protected SqlSession getSqlSession() { return sqlSessionFactory.openSession(connection); }这里的connection从哪来是通过DataSource获取的数据库连接。Flowable 自身不做连接池管理它完全依赖外部数据源。这也是为什么你在 Spring Boot 集成时通常用 HikariCP 或 Druid 作为连接池。更关键的是这个connection会和当前命令的事务绑定。Flowable 支持两种事务模式外部事务管理模式比如 Spring 管理的TransactionalFlowable 参与已有事务连接由事务同步管理器管理。独立事务管理模式Flowable 自己管理事务命令开始时开启事务命令结束统一提交或回滚。判断依据在ProcessEngineConfigurationImpl的getTransactionContext()逻辑中。如果你自己写代码同时操作 Flowable 和业务表又不加Transactional那你可能会遇到业务数据提交了但流程数据没提交的诡异问题。反过来如果你加了事务但 Flowable 的DbSqlSession拿到了不同的连接同样会出问题。所以理解连接获取方式对排错特别关键。3.3 flush 与 close 的时机命令执行成功后CommandContext.close()会按顺序执行遍历所有Session调用flush方法DbSqlSession.flush()中执行插入、更新、删除的批量 SQL提交事务如果是独立事务模式逐项关闭所有SessionDbSqlSession.close()中释放 MyBatis 的SqlSession我见过一个典型问题是有人在监听器里修改了引擎实体但数据库始终没生效。排查了半天才发现监听器执行时机在flush之前但如果你修改的是引擎内部的Entity而不是直接操作 DB这个修改会被DbSqlSession的脏数据检查机制感知到。如果你绕过了引擎 API直接用了JdbcTemplate去改表那 Flowable 根本感知不到也就不会主动帮你处理事务最后你自己把连接提交了两边数据不一致。这里有张表总结一下各个对象的作用与生命周期对象作用生命周期CommandExecutor命令执行入口创建并管理 CommandContext每次外部调用执行一次CommandContext当前命令的上下文持有所有 Session单个命令执行期间DbSqlSession引擎与数据库之间的核心 Session单个命令执行期间MyBatis SqlSession真正的 SQL 执行器与 DbSqlSession 同步创建和关闭DataSource 连接数据库连接从连接池获取事务结束返还Entity 缓存 List暂存当前命令的实体变更命令 flush 时清空4. 核心表结构与 Mapper 源码速览4.1 运行时表、历史表、通用表的分类逻辑Flowable 的表分成几大类理解这个分类对 DB 访问层源码解读特别有帮助因为你一旦知道某类操作会命中的表你就能快速定位问题ACT_RE_*Repository 相关存储流程定义、模型、部署信息数据相对静态ACT_RU_*Runtime 相关存储运行中的实例、任务、变量、作业数据动态变化流程结束后删除ACT_HI_*History 相关存储历史数据流程结束后保留用于统计和审计ACT_GE_*General 通用数据比如ACT_GE_BYTEARRAY存二进制资源ACT_GE_PROPERTY存引擎属性设计上把运行时数据与历史数据分离根本原因是两种数据的生命周期完全不同。运行时数据要求高并发读写、低延迟通常可以清理历史数据要求完整、不可变、可追溯需要长期保存。放在同一张表会导致相互干扰索引策略和清理策略都会打架。4.2 从 Mapper XML 看ACT_RU_EXECUTION的插入逻辑ACT_RU_EXECUTION是 Flowable 最核心的运行时表。流程实例启动时引擎会往这张表插入数据。它的 Mapper XML 对应关系在ExecutionEntityManager和ExecutionEntityManagerImpl中。核心插入 SQL 类似于insert idinsertExecution parameterTypeorg.flowable.engine.impl.persistence.entity.ExecutionEntityImpl insert into ACT_RU_EXECUTION ( ID_, REV_, PROC_INST_ID_, BUSINESS_KEY_, PROC_DEF_ID_, ACT_ID_, IS_ACTIVE_, IS_CONCURRENT_, IS_SCOPE_, IS_EVENT_SCOPE_, PARENT_ID_, SUPER_EXEC_, ROOT_PROC_INST_ID_, SUSPENSION_STATE_, TENANT_ID_, NAME_, START_TIME_, START_USER_ID_, LOCK_TIME_, IS_COUNT_ENABLED_, EVT_SUBSCR_COUNT_, TASK_COUNT_, JOB_COUNT_, TIMER_JOB_COUNT_, SUSP_JOB_COUNT_, DEADLETTER_JOB_COUNT_, VAR_COUNT_, ID_LINK_COUNT_ ) values ( #{id}, #{revision}, #{processInstanceId}, #{businessKey}, #{processDefinitionId}, #{activityId}, #{active}, #{concurrent}, #{scope}, #{eventScope}, #{parentId}, #{superExecutionId}, #{rootProcessInstanceId}, #{suspensionState}, #{tenantId}, #{name}, #{startTime}, #{startUserId}, #{lockTime}, #{countEnabled}, #{eventSubscriptionCount}, #{taskCount}, #{jobCount}, #{timerJobCount}, #{suspensionJobCount}, #{deadLetterJobCount}, #{variableCount}, #{identityLinkCount} ) /insert注意两个关键点ID_是手动传入的Flowable 不依赖数据库自增主键而是使用IdGenerator生成 UUID 或自定义 ID。这个设计的好处是引擎可以在应用层生成 ID便于批量插入和分布式扩展同时不绑定具体数据库方言。REV_是乐观锁版本号。Flowable 每次更新这行数据时SQL 会加上where REV_ #{oldRevision}更新成功后REV_ 1。这个机制保证了多线程并发更新同一流程实例时只有一个线程能成功其他线程会抛出乐观锁异常。4.3 历史数据如何写入异步与同步路径ACT_HI_PROCINST的写入有时并不和运行时表写入同步发生。Flowable 提供了历史级别配置historyLevel比如none、activity、audit、full。默认audit级别会记录流程实例、活动实例和任务实例的历史。历史数据的写入由HistoryManager负责它内部同样通过DbSqlSession完成插入操作。如果开启了异步历史asyncHistoryEnabled历史数据会通过JobExecutor异步写入这个时候你会看到ACT_RU_ACTJOB或ACT_RU_JOB表里多出一些待执行的历史任务记录。这个机制在官方文档里有说明但真正排查问题时会比较绕流程实例已经结束了但历史数据还没出现因为异步任务还没来得及执行。遇到这种情况不要慌先看 Job 表里有没有任务再看 JobExecutor 是否正常运行。5. DB 访问的性能调优与连接池实践5.1 连接池参数和 Flowable 的关联Flowable 本身不管理连接池但这不代表连接池参数对引擎没有影响。恰恰相反连接池的很多参数直接决定了引擎在高并发场景下的表现。我给的参考配置maximumPoolSize建议根据并发命令数设置。Flowable 的每个 Command 在独立事务中执行一个命令占用一个连接。如果你同时启动 100 个流程实例连接池至少要能提供足够的连接否则会排队等待。minimumIdle建议和maximumPoolSize一致或者接近。Flowable 的实时性要求比较高如果连接池经常把连接回收冷启动时会有明显的延迟对接口响应时间不友好。connectionTimeout至少 30000ms。Flowable 有些命令会包含批量操作或长时间运行的锁等待太短的连接超时会导致误杀正在执行的命令。maxLifetime不要超过数据库的wait_timeout。MySQL 默认 8 小时连接池设成 30 分钟比较常见避免数据库主动断开连接后连接池还傻傻地继续用。5.2 批量场景下的 flush 行为如果你的系统有批量发起流程实例的需求建议直接调用 Flowable 的批量 API比如runtimeService.createProcessInstanceBuilder().processDefinitionKey(xxx).businessKey(yyy).start()循环调用这种方式每次调用都是一个独立 Command也就意味着每次都有独立事务和连接获取释放。真正高效的方式是使用ProcessInstanceBatch或者自己控制命令上下文。源码层面你可以仿照ProcessInstanceHelper的实现在一个CommandContext中批量创建多个实例这样实体缓存 List 会在最后统一 flush插入操作会被批量化执行。也不是所有场景都值得这么做。批量创建流程实例如果单个实例之间没有共享初始化逻辑性能瓶颈更多在 DB 单条插入本身但如果有共享的监听器、共享的历史记录写入那批处理的优势就很明显了。5.3 乐观锁与悲观锁的实际影响Flowable 的运行时表大量使用REV_版本号实现乐观锁。这意味着两个线程同时操作同一个执行实例时后提交的线程会因为where REV_ ?匹配不到记录而抛出FlowableOptimisticLockingException。这个异常在分布式任务和并行网关场景出现频率很高。解决思路增加重试机制捕获异常后重新查询最新数据再执行操作通过ACT_RU_EXECUTION的LOCK_TIME_字段实现悲观锁效果在更新前先执行select ... for update评估业务是否可以接受后写覆盖前写如果不能就引入应用层分布式锁我测试过的经验是通过FlowableOptimisticLockingException做固定次数重试是最稳妥的但要注意重试次数不能太多否则会导致命令执行时间过长并压满连接池。6. 踩坑记录常见 DB 访问异常与完整排查链路6.1 “表不存在”不一定是建表问题有次帮人排查一个启动报错ACT_RU_EXECUTION表明明存在但 Flowable 一直报Table xxx.ACT_RU_EXECUTION doesnt exist。后来查了下 MySQL 的大小写敏感配置。Linux 下 MySQL 默认对表名大小写敏感而建表脚本和 Mapper SQL 里的表名统一是大写。如果连接串里没有加lowerCaseTableNames1或者数据库里的表实际是小写那 MyBatis 执行select * from ACT_RU_EXECUTION就直接报错。排查建议打开 MyBatis 的 SQL 日志确认实际执行的 SQL 语句用同样的连接信息在命令行执行该 SQL看是否复现查看实际表名大小写show tables;确认 MySQL 的lower_case_table_names配置这个问题的隐蔽之处在于 Flowable 自动建表成功说明它有权限创建表但创建出来的表名大小写可能因为数据库配置和你预期的不一致。6.2 死锁与锁等待超时Flowable 高并发更新同一流程实例时如果没有处理好乐观锁很容易出现死锁。实际表现为Deadlock found when trying to get lock; try restarting transaction。排查链路打开 InnoDB 状态SHOW ENGINE INNODB STATUS\G查看最近死锁事务的 SQL 语句确认两个并发请求在更新哪些表通常是ACT_RU_EXECUTION、ACT_RU_TASK、ACT_RU_VARIABLE这几张表的组合锁检查代码中是否有多个操作在交叉更新同一实例的父子和兄弟节点解决方式可以设置innodb_lock_wait_timeout适当调大但更根本的方案是保证同一流程实例的操作在应用层串行化。比如对流程实例 ID 做哈希取模后分发到固定的工作线程或者使用分布式锁保护同一个PROC_INST_ID_的所有操作。6.3 大事务导致连接耗尽还有一种常见现象事务里批量处理大量任务比如一个命令里循环审批几百个任务每个任务都涉及ACT_RU_TASK更新、ACT_HI_TASKINST插入、变量变更等。因为 Flowable 的实体会先放入缓存列表统一在命令结束时 flush如果中途没有触发强制 flush那所有 SQL 都在最后一次性执行单个事务的持锁时间会很长。如果这时候还有其他请求想访问同一批任务就会阻塞等待而每个阻塞请求都占用一个连接池连接。连接池一旦被打满新请求直接超时整个服务就僵住了。这类问题的排查思路数据库层面看SHOW PROCESSLIST看大量Sleep还是Locked状态的连接确认是否存在超长事务information_schema.innodb_trx表里看trx_query和持续时间代码层面看是否有大批量循环操作引擎 API而不使用批量命令我的经验是如果单次命令处理超过 50 个任务就该考虑拆分批次了。Flowable 内置的Batch模块就是为了处理这种场景设计的。你可以构建一个Batch引擎会拆分执行并且记录执行状态。虽然带来了额外的复杂度但在大数据量场景下能避免把连接池压垮。6.4 自定义 Mapper 不生效的问题我自己做过一次 Flowable 扩展加了自定义表ACT_CUS_*在ProcessEngineConfigurationImpl子类中用addCustomMapper注册。但启动后执行自定义 SQL 一直报找不到 Mapper。最终原因addCustomMapper只是注册了 Mapper 接口对应的 XML 文件需要放在 MyBatis 扫描路径下并且mapperLocations必须包含它。Flowable 的initMybatisConfiguration里有专门的资源扫描逻辑默认只扫描内置的classpath*:/org/flowable/**/*.xml。你的自定义 XML 如果放在com/example/mapper/*.xml默认扫描不到。解决办法是重写配置文件显式追加资源public class CustomProcessEngineConfiguration extends ProcessEngineConfigurationImpl { Override protected void initMybatisConfiguration() { super.initMybatisConfiguration(); // 追加自定义 Mapper XML resourceFinder.addCandidateResource(com/example/mapper/CustomMapper.xml); } }这种问题不调试到源码层面几乎不可能定位因为你看到的报错信息只是Invalid bound statement (not found)没有指向 XML 路径问题。我个人在实际项目里越来越觉得Flowable 用得好不好很大程度上取决于你对它的 DB 访问层理解到什么程度。很多表面上看起来千奇百怪的报错比如偶发的乐观锁异常、连接池被打满、事务提交顺序问题追到根上都是因为不清楚引擎在命令执行期间如何管理 Session、缓存实体、批量 flush。最后分享一个排错习惯排查 Flowable 数据库问题时第一步永远是看 MyBatis 日志里实际的 SQL 执行顺序和时间点而不是先猜业务逻辑哪里不对。把一次流程启动涉及的 SQL 全部打出来从select到insert到update的时间线捋一遍大部分问题都能对应到源码层面。这个思路帮我省了非常多的时间。