如果你接手过Activiti工作流项目一定有过这样的经历在某个节点用delegateExecution.setVariable(orderId, xxx)存了一个变量结果到下一个节点getVariable取出来是null翻遍日志看不出原因最后打开数据库才知道——变量写在了ACT_RU_VARIABLE这张表里但绑定作用域和你以为的不一样。这张表几乎是Activiti运行期最活跃的数据表每个流程实例每次流转都会和它打交道理解它是排查一切流程变量问题的基础也是把数据库表结构吃透的必经一步。这篇文章围绕ACT_RU_VARIABLE展开我会从表定位、字段设计、作用域规则、序列化机制、生命周期到实际项目里的坑把这张表的每个设计决策都讲清楚。如果你正在接触Activiti 5/6的数据库表或者项目里已经出现变量读写问题这篇文章适合你花二十分钟读完。1. 为什么所有运行中的变量都必须集中放这一张表1.1 变量读写是整个流程流转的高频操作先想一个问题流程要流转起来靠的是什么节点之间的表达式判断靠变量会签的nrOfCompletedInstances靠变量网关的${approve true}靠变量甚至任务办理人的assignee也经常从变量里取。可以说变量是流程引擎的运行时内存。但Activiti不是把变量挂在流程实例表ACT_RU_EXECUTION的一个字段里而是单独拆了一张ACT_RU_VARIABLE表。为什么一个流程实例的变量数量不是固定的可能只有两三个也可能几十个变量类型五花八门字符串、数字、日期、序列化对象都有。如果都塞在流程实例表里这张表的行宽会失控而且每次更新任何一个变量都要去改那一行大记录锁竞争非常严重。拆成独立表一行存一个变量读写互不干扰按PROC_INST_ID_查询时也能走索引这是最务实的方案。1.2 运行时表里它和邻居们的关系在Activiti的25张表里运行时Runtime相关的表有一组ACT_RU_VARIABLE是其中之一它跟几张核心表都有外键关系关联字段关联表说明EXECUTION_ID_ACT_RU_EXECUTION执行实例ID变量挂在哪个执行分支上PROC_INST_ID_ACT_RU_EXECUTION所属流程实例ID一个流程内的所有变量都会带上它TASK_ID_ACT_RU_TASK任务ID只有任务级变量才非空BYTEARRAY_ID_ACT_GE_BYTEARRAY序列化对象的二进制内容存放位置有一点容易混淆EXECUTION_ID_和PROC_INST_ID_虽然都指向ACT_RU_EXECUTION但含义不同。一个流程实例在并行分支时会有多个执行实例子执行实例的PROC_INST_ID_还是指向根流程实例而EXECUTION_ID_指向自己。变量存的时候只要带上了EXECUTION_ID_就必然带PROC_INST_ID_反过来不一定。1.3 这张表为什么属于运行实例表Activiti表命名有规律ACT_RU_*是运行期表数据是暂时的流程结束或实例删除后会被清掉ACT_HI_*是历史表数据是永久的。ACT_RU_VARIABLE属于前者意味着它只存还没走完的流程的变量。但这里有个很多新手会踩的认知坑流程结束后ACT_RU_VARIABLE里的记录会被删除变量是不是就丢了不是。变量值在流程结束时会被同步拷贝到历史变量表ACT_HI_VARINST只是运行时表里不再保留。这个机制后面我会专门讲这里先记住一点想查历史流程的变量去历史表查而不是从这张表查。2. 逐字段拆解每个列都是踩坑后设计出来的2.1 定位字段ID_和REV_ID_是主键varchar(64)由引擎UUID生成。REV_是乐观锁版本号int类型每次更新记录时REV_自增。这个字段很多人不注意但它恰恰是并发问题的关键。Activiti对变量更新采用乐观锁更新时where ID_ ? and REV_ 旧值如果同时有两个线程对同一变量做更新后提交的那个会更新失败抛OptimisticLockingException。我遇到过这样一个场景一个会签节点多个审批人同时在各自的delegateExecution.setVariableLocal里更新同一个任务级变量并发高的时候报乐观锁异常。后来查日志发现问题不是引擎的错而是变量作用域设计不合理——本应该用setVariableLocal隔离的数据写成了全局变量逼着所有线程抢同一行记录。理解REV_能帮你快速判断这类问题是引擎bug还是自身用法问题。2.2 变量身份字段TYPE_和NAME_NAME_是变量名varchar(255)这个字段决定了getVariable(orderId)能不能取到值。TYPE_是变量类型字符串varchar(100)比如string、integer、long、boolean、double、date、serializable、custom。注意TYPE_不是Java全限定类名而是引擎内置类型转换器的简写。以serializable为例它表示这个变量是Java对象经过序列化后存储的custom表示用了自定义类型转换器。还有一个容易被忽略的点NAME_虽然看上去可以随便填但它和流程定义里的表达式是强耦合的。如果流程定义里写的是${day} 3但运行时setVariable(days, 4)那么表达式结果为false整个走向都会变。命名不统一是变量问题的第一大来源。2.3 作用域字段EXECUTION_ID_、PROC_INST_ID_、TASK_ID_这是这张表里最值得深挖的三个字段。PROC_INST_ID_标记变量属于哪个流程实例只要流程还在查询时用它做条件就能拉到整个实例的全量变量。EXECUTION_ID_标记变量属于哪个执行实例。在非并行情况下执行实例就是流程实例本身二者值相同只有出现并行网关、多实例会签时子执行实例才会出现变量就可能绑在子执行实例上。TASK_ID_标记任务级变量只有用taskService.setVariable或setVariableLocal时才会填充。三者组合起来形成了一种层级可见性关系具体规则下一节展开。在数据库排查时你可以用这条SQL快速看到某个流程实例的变量分布SELECT PROC_INST_ID_, EXECUTION_ID_, TASK_ID_, NAME_, TYPE_, TEXT_, LONG_, DOUBLE_ FROM ACT_RU_VARIABLE WHERE PROC_INST_ID_ 你的流程实例ID ORDER BY TASK_ID_ IS NULL, EXECUTION_ID_;执行结果里如果TASK_ID_为空、EXECUTION_ID_等于PROC_INST_ID_是根流程实例变量如果EXECUTION_ID_是子执行实例ID是多实例或并行的局部变量如果TASK_ID_非空是任务级变量。2.4 存储字段BYTEARRAY_ID_、DOUBLE_、LONG_、TEXT_、TEXT2_这张表最特别的地方在于它同时预留了五种存储列分别应对不同类型的变量存储列类型存什么DOUBLE_doubleDouble类型的变量值LONG_bigintLong、Integer、Short、Boolean、Date类型的值TEXT_varchar(4000)String类型值、序列化文本、Map类型的一部分TEXT2_varchar(4000)序列化对象的类型信息、Map键值对的另一部分BYTEARRAY_ID_varchar(64)关联ACT_GE_BYTEARRAY存序列化的二进制对象为什么这么设计核心目的是常用类型直接落列减少关联查询。整型和日期最多占8个字节直接放LONG_里小数放DOUBLE_字符串最长4000字符放TEXT_。只有碰到序列化对象这种可变长且结构复杂的内容才把内容丢到字节数组表这张表只存一个外键ID。字段一一对应有个好处查询任何变量引擎只需要一条SQL加一次内存映射就能还原变量值不需要去别的表捞数据。只有在BYTEARRAY_ID_非空时才需要二次查询ACT_GE_BYTEARRAY。3. 变量作用域getVariable取不到值的真凶3.1 三种作用域在表里的表现Activiti变量分为流程实例级global、执行实例级execution local、任务级task local。在表里的区分方式很直接流程实例级变量TASK_ID_为空EXECUTION_ID_等于流程实例ID。执行实例级变量TASK_ID_为空EXECUTION_ID_是某个子执行实例ID。任务级变量TASK_ID_非空。三种作用域的可视范围是递减的流程实例级全流程可见执行实例级只在当前分支可见兄弟分支看不到任务级只在当前任务范围内可见。这里我要特别强调一个细节在用户任务的delegateExecution.setVariable(key, value)存的是流程实例级变量因为入参delegateExecution指向的可能是流程实例本身而taskService.setVariable(taskId, key, value)存的是任务级变量。前者在表里体现为EXECUTION_ID_为根执行实例、TASK_ID_为null后者则是TASK_ID_非空。3.2 getVariable与getVariableLocal的查询规则Activiti的读取逻辑是有向上查找策略的。getVariable(key)的查找顺序当前执行实例/任务 → 父执行实例 → 流程实例逐级向上找到第一个就返回。这意味着即使变量只存在根流程实例上子执行实例里调用getVariable也能拿到因为最终会查上去。getVariableLocal(key)只看当前作用域自己拥有的变量查不到就返回null不向上找。这个设计的作用正是因为多实例场景下子节点和父节点的变量是隔离存档的用Local方法可以保证读取的是当前节点的副本。看这张图就明白了不画图用文字描述流程实例 ID P ├── 子执行实例 E1用户任务1 │ └── 局部变量 local1 ├── 子执行实例 E2用户任务2 │ └── 局部变量 local2 └── 全局变量 global1在E1节点里getVariable(global1)能取到getVariable(local2)取不到因为E2的局部变量不在E1的查找链路上getVariableLocal(local1)能取到getVariableLocal(global1)为null。3.3 典型的会签/子流程案例一个真实项目里的例子一个审批流程部门主管会签后转给总经理。会签节点是多实例的流程定义里会生成一个父执行实例加N个子执行实例。一开始代码里用了execution.setVariable(approved, true)结果发现所有子节点都收到了同一个变量的覆盖——多个审批人各自提交后写的覆盖先写的总经理看到的永远是最后一个提交的人的结果。后来改成execution.setVariableLocal(approved, true)每个审批人各自维护自己的副本再配合多实例的nrOfApprovedInstances聚合问题才解决。从表上验证也非常直观改之前EXECUTION_ID_全是同一个根执行实例IDTASK_ID_也相同改完之后每一行都有独立的子执行实例ID。排查变量问题时先看ACT_RU_VARIABLE里这些字段的分布能省下大量调试时间。4. 存储类型与序列化机制五种存储列如何配合工作4.1 原始类型直接落库不走字节数组先看最常见的原始类型在表里的落库情况。比如你执行了execution.setVariable(age, 30); execution.setVariable(approve, true); execution.setVariable(score, 88.5); execution.setVariable(createTime, new Date());存储后表里的状态是NAME_TYPE_LONG_DOUBLE_TEXT_BYTEARRAY_ID_ageinteger30nullnullnullapproveboolean1nullnullnullscoredoublenull88.5nullnullcreateTimedate1700000000000nullnullnull注意几个细节boolean在MySQL的bigint列里存的是1和0date存的是毫秒时间戳不是日期字符串integer实际存放在LONG_里。这些列虽然类型不匹配但引擎在读写时会自动转换所以看起来是套错列存实际上是有意为之——能用bigint存的都不用double保证精度。字符串类型的变量比较特殊长度在4000以内时直接进TEXT_超过4000怎么办Activiti会把超长字符串当作一个序列化字节数组处理写入ACT_GE_BYTEARRAY然后在这张表里只留BYTEARRAY_ID_。这点很容易被忽略等到项目里出现变量存进去后查出来是一个byte[]而不是String的时候先检查是不是字符串超长导致的。4.2 Serializable和自定义类型字节数组上场Java对象如何存进这张表以最常见的HashMap为例假设你写MapString, Object params new HashMap(); params.put(level, A); execution.setVariable(bizParams, params);此时引擎会做三件事第一把HashMap对象用Java序列化机制转成字节流第二把字节流存进ACT_GE_BYTEARRAY生成新的字节数组ID第三在ACT_RU_VARIABLE里写一行TYPE_serializableBYTEARRAY_ID_指向刚生成的ID。取的时候引擎根据BYTEARRAY_ID_把字节数组捞出来反序列化成对象再包成原始类型返回。整个过程对调用方是透明的但如果数据库里手工改过字节数组反序列化就会失败。还有一个很常见的custom类型它对应的是那种实现了Java序列化接口、但引擎识别不了的类型。比如项目里自定义的一个OrderInfo对象实现了Serializable接口引擎会把它当serializable处理如果实现了Externalizable接口或者注册了自定义类型转换器就可能走custom路径。区分它们的主要方式是TYPE_字段值的不同排查序列化问题时可以先用这个字段判断引擎走的哪条路。4.3 Map对象的两段式存储Map类型在Activiti里有一个特殊处理它不是整体序列化进字节数组而是把每个键值对拆开分别存成两行。以{level: A, count: 2}为例表里会出现两行NAME_分别叫level和countTEXT_和TEXT2_却为空——因为它们都是原始类型各存各的类型列。如果是嵌套的Map或者值为对象的Map那么TEXT_和TEXT2_就派上用场了。Activiti内部把Map展开后对每个键值对的值如果仍然是个对象会把它序列化成文本形式TEXT_存实际值TEXT2_存对象的类型信息。这在我排查一个变量从Map取出来是字符串但期望是对象的问题时很有启发TEXT2_里写着对象的全限定类名一看就知道引擎把哪一层做了文本化处理。这也解释了为什么TEXT2_字段存在——没有它反序列化时无法知道文本内容对应的类型是什么。你可以把TEXT_和TEXT2_想象成一个序列化二元组一个存内容一个存元信息。4.4 空值和极端情况的落库表现变量值为null时表里同样会保留一行记录TYPE_和NAME_都有值但所有存储列为空。不要试图删掉这些空值行否则getVariable(key)会返回null而不是null有些表达式判断结果会彻底变样。超大数值类型比如超过long范围的BigIntegerActiviti会直接把它当作serializable对象处理不走LONG_列。日期类型的时区问题也出过不少TEXT_里有时候会存时间字符串有时候只存时间戳主要取决于类型转换器的版本。如果项目跨Activiti版本升级变量表的历史数据最好先全量导出核对一遍避免类型读取时互相不兼容。5. 变量的生命周期从写入、过期到清理5.1 setVariable后表里发生了什么一次完整的变量写入不是简单insert一行它包含四个步骤引擎根据变量名在ACT_RU_VARIABLE中查找是否已有同名的记录。如果没有插入一行REV_初始化为1。如果有更新内容REV_加1更新条件和当前REV_相等。如果变量类型需要序列化先写ACT_GE_BYTEARRAY再在变量表里写BYTEARRAY_ID_。这里的查找范围取决于设置时使用的API。使用setVariable查找范围是整个流程实例使用setVariableLocal查找范围限定在当前作用域。因此同一个变量名可以同时出现在多个执行实例上每一行都是一个独立变量互不覆盖。我在一次线上问题排查中发现同一个TASK_ID_下面存在两行相同NAME_的变量看起来违反常识。后来确认是历史遗留先有任务级变量又调用了setVariable不是Local更新同一变量名引擎将新值落在了流程实例级任务级的旧值没删。这种同名多行的情况最容易引起getVariable取到意外值排查时要多留个心眼。5.2 流程结束或删除时数据如何清理流程正常走到结束事件时引擎会做一次清理删除当前流程实例下所有ACT_RU_VARIABLE记录同时把变量内容拷贝到ACT_HI_VARINST历史表。清理不是立刻物理删除Activiti在事务提交前会把删除操作封装成一条DELETE语句。所以在流程结束瞬间运行时表和历史表可能同时存在相同变量的两份数据一个是删除待提交状态一个是已经写入的历史记录。排查问题时如果发现流程明明结束了运行时表里还有数据多半是事务未提交或者使用了异步任务造成的延迟。如果流程被强制删除runtimeService.deleteProcessInstance运行时表里的变量也会被删但ACT_HI_VARINST里同样会保留一份历史快照。区别只在于历史表里会记录删除时间方便追溯。5.3 ACT_HI_VARINST与运行时表的分工Activiti刻意把运行时数据和历史数据分开核心原因是性能隔离ACT_RU_VARIABLE需要支撑高并发的读写必须短小精悍ACT_HI_VARINST需要长期保留、支持历史回溯可以做得更重。这两张表的字段几乎一一对应但历史表多了几个关键字段PROC_DEF_ID_记录变量所属的流程定义ID。START_TIME_和END_TIME_记录变量生效和失效的时间。REV_被去掉因为历史数据不需要乐观锁。一个常见的理解误区是流程结束后getVariable还能从历史表里读到旧值。这是错的getVariable只走运行时上下文。要取历史参数必须用historyService.createHistoricVariableInstanceQuery()。如果项目配置了历史级别为none那么ACT_HI_VARINST里不会有任何数据流程结束后变量就真的彻底没了。这一点在对接报表系统或者做审计追踪时尤其要确认别等到数据丢了才想起来查历史级别配置。6. 真实项目里的三个高频坑与排查思路6.1 大对象导致流程实例加载变慢一个典型的性能问题流程里每到一个节点就setVariable(historyList, someLargeList)结果列表越来越大。表面上看一切正常但流程引擎每次操作这个流程实例时都可能把整个实例的变量捞出来做缓存或表达式计算大对象会拖垮所有流转操作。从数据库层面看ACT_GE_BYTEARRAY里会积累大量二进制数据块字节数组表越来越胖。这种问题靠优化索引解决不了正确做法是大对象不放进流程变量改用业务表的业务键关联变量里只存ID。必须存的时候及时清理不再用到的序列化变量。查询条件里尽量使用PROC_INST_ID_精确匹配避免全表扫。6.2 并发更新触发乐观锁异常前面提到REV_的乐观锁机制真实场景里的触发条件通常是同一流程实例的多个节点并行各自往同一个全局变量上写值。奇数个更新成功偶数个抛异常。排查链路是这样的先看异常堆栈是不是OptimisticLockingException然后打开ACT_RU_VARIABLE看对应变量的REV_是否在极短时间内多次变大再结合代码定位是否有跨作用域的并发写同一变量名。最终修复往往落在变量作用域隔离上而不是重试机制。如果确实需要所有分支共享一个全局变量做聚合逻辑正确选择是多实例的nrOf*Instances内建变量或者把每个分支的结果存成业务表记录最后汇总读取而不是让多个线程竞争同一行。6.3 变量名和类型不一致的隐蔽问题项目里出现过一件怪事同一个变量名amount某个节点set进去的是Integer另一个节点set进去的却是Long。从业务上看值差不多但从引擎类型转换的角度看两者在TYPE_字段一个是integer一个是long表达式判断和API返回值类型都不一致。这类问题用肉眼很难发现最好用SQL批量核对同一NAME_下是否出现了多个TYPE_值。SELECT NAME_, COUNT(DISTINCT TYPE_) AS type_cnt FROM ACT_RU_VARIABLE GROUP BY NAME_ HAVING COUNT(DISTINCT TYPE_) 1;执行结果里只要出现一行就说明存在类型不一致的隐患。建议项目里在关键流程节点固定变量命名和类型或者在代码里统一用常量定义变量名不要裸写字符串。如果涉及到历史数据迁移更要先做一次这样的类型冲突排查。6.4 一张检查清单收尾把这几年的经验浓缩成一份自查清单照着检查基本能避开大部分坑检查项正确做法需要跨节点共享的数据用流程实例级变量命名统一每个分支独立的数据用执行实例级setVariableLocal某个用户任务单独的数据用taskService.setVariableLocal大对象、超长字符串业务表存ID变量里不放大对象并发写同一全局变量重新设计作用域避免同一行竞争读取历史流程变量查ACT_HI_VARINST不查运行时表类型一致性每个变量名固定类型禁用裸字符串拼接ACT_RU_VARIABLE这张表表面上是25张表里不起眼的一张但因为离业务最近几乎成了所有流程疑难杂症的照妖镜。我自己排查线上问题有个习惯不管报错日志是超时、空指针还是网关走错第一件事永远是先拉这张表的数据分布。看清了变量的作用域、类型和生命周期大多数问题已经能猜出七八分。中途遇到过的那种节点B读不到节点A的变量的经典问题我在后续项目里总结了一个万能的调试步骤在报错节点打印getVariable和getVariableLocal的返回值再对比数据库里那几行记录的TASK_ID_、EXECUTION_ID_和PROC_INST_ID_几乎一查一个准不用瞎猜。如果你正在做Activiti相关的开发建议花时间把这25张表挨个过一遍尤其是运行时表。表结构本身就是文档每个字段的取舍背后都有故事。看懂了ACT_RU_VARIABLE你就能理解Activiti为什么敢在运行期频繁读写变量也才能在它出问题时不慌不忙地定位到根因。