1. 从25张表到ACT_HI_VARINST:它到底在历史体系里扮演什么角色
先说个我自己的经历。去年接手一个用了Activiti的老系统,线上跑了一年多,流程历史数据已经攒了几百万条。用户反馈说:"历史流程详情页打开特别慢,有时候要等好几秒。"我第一反应是去查ACT_HI_PROCINST和ACT_HI_TASKINST,结果索引都没问题。最后一步步排查,发现罪魁祸首是ACT_HI_VARINST——这张表膨胀得厉害,单表快两千万行了,而详情页上有好几个查询都在关联它。从那天起我就把Activiti的25张表从头到尾梳理了一遍,今天先讲历史表组里最容易被人忽略、却最影响存储和查询性能的一张:ACT_HI_VARINST。
ACT_HI_VARINST的官方定位很朴素:存放流程历史中的变量快照。凡是在流程运行过程中产生过的变量,只要历史级别允许,最终都会在这张表里落一份。它和ACT_RU_VARIABLE的核心区别就一句话:运行表存的是"当前这一刻谁还活着",历史表存的是"曾经发生过什么,哪怕流程早就跑完了"。这个区别决定了它的几个关键特性——数据只增不减、同一变量名可能有多条记录、查询必须靠时间或实例ID来区分。
这篇文章适合两类人看:一类是刚接触Activiti,想把25张表结构吃透的初学者;另一类是已经在生产环境被历史数据膨胀、查询变慢、变量对不上等问题折磨过的开发者。我会把这张表的字段设计、存储逻辑、生命周期、以及我在实际项目中踩过的坑全部摊开来讲,尽量让你看完之后能直接拿去做排查和优化。
2. 字段逐个拆解:三个关联ID、五个类型字段、两个时间字段
2.1 建表语句与字段总览
先看Activiti 6.x在MySQL下的标准建表语句,这张表的结构在5.x到7.x之间基本稳定,只有小改动。
CREATE TABLE ACT_HI_VARINST ( ID_ varchar(64) NOT NULL, PROC_INST_ID_ varchar(64) DEFAULT NULL, EXECUTION_ID_ varchar(64) DEFAULT NULL, TASK_ID_ varchar(64) DEFAULT NULL, NAME_ varchar(255) NOT NULL, VAR_TYPE_ varchar(100) DEFAULT NULL, REV_ int DEFAULT NULL, BYTEARRAY_ID_ varchar(64) DEFAULT NULL, DOUBLE_ double DEFAULT NULL, LONG_ bigint DEFAULT NULL, TEXT_ varchar(4000) DEFAULT NULL, TEXT2_ varchar(4000) DEFAULT NULL, CREATE_TIME_ datetime DEFAULT NULL, LAST_UPDATED_TIME_ datetime DEFAULT NULL, PRIMARY KEY (ID_) );光看字段名,很多人第一反应是"这不就是ACT_RU_VARIABLE换了个前缀吗"。但细看有几个关键差异:一是多了CREATE_TIME_和LAST_UPDATED_TIME_两个时间字段,二是NAME_被标成了NOT NULL。下面我把每个字段按功能分组讲清楚。
2.2 三个关联ID的语义:实例、执行实例、任务
这三个字段是最容易搞混的,我见过不止一次因为理解错误导致SQL关联出错的案例。
- PROC_INST_ID_:流程实例ID,表示这条历史变量属于哪个流程实例。它是查询时最常用的过滤条件。
- EXECUTION_ID_:执行实例ID,表示这条变量是在哪个执行分支上产生的。
- TASK_ID_:任务ID,只有任务级变量(task local variable)才会填这个字段,流程级变量该字段为空。
举个例子:一个审批流程里,流程级变量applyUser只会填PROC_INST_ID_和EXECUTION_ID_;而某个审批节点上的经办人变量handler如果是以任务级变量保存的,那么TASK_ID_就会有值。因为每个任务都有自己的变量作用域,同一个变量名在不同任务上可能值不同,所以ACT_HI_VARINST里会出现"同一个流程实例、同一个变量名、多条记录"的情况。这是和运行表很不一样的地方:ACT_RU_VARIABLE里一个流程实例的变量名通常只有一条,而在历史表里,只要作用域不同,就会有多条。
2.3 五个类型字段:值到底存在哪一列
你可能注意到,这张表把"值"拆到了五列里:BYTEARRAY_ID_、DOUBLE_、LONG_、TEXT_、TEXT2_。这样设计的原因很务实:不同类型的数据用不同的列存,既省空间,又方便SQL层面直接按类型过滤。具体路由规则如下表:
| 变量类型 | 主要存储字段 | 说明 |
|---|---|---|
| string / 长文本 | TEXT_、TEXT2_ | 字符串存TEXT_,超过4000字符的部分截到TEXT2_ |
| integer / long / boolean / date | LONG_ | 统一转成Long存储,boolean存0或1,date存时间戳 |
| double | DOUBLE_ | 浮点数直接存 |
| serializable / custom 对象 | BYTEARRAY_ID_ | 指向ACT_GE_BYTEARRAY表,真正的字节流存在那张表里 |
| 6.x之后新增的JSON序列化对象 | BYTEARRAY_ID_ 或 TEXT_ | 取决于类型实现,部分可读文本会落入TEXT_ |
这里有个非常容易被坑的点:如果你用SELECT * FROM ACT_HI_VARINST WHERE LONG_ IS NOT NULL去查所有整型变量,结果里会混入布尔类型和时间戳。因为boolean和date在存储层面都是LONG_。正确做法是结合VAR_TYPE_字段一起过滤,比如VAR_TYPE_ = 'integer'。
2.4 REV_、时间字段与版本演进
REV_是乐观锁版本号,和运行表一样,用来保证并发更新时的数据一致性。但历史表里的REV_实际作用不大,因为历史变量理论上写入之后就很少改,除非变量值在流程运行中被反复更新。每次变量值变化,这条历史记录的REV_会递增,LAST_UPDATED_TIME_也会跟着刷新。
这里插一个版本差异:Activiti 5.x的ACT_HI_VARINST其实没有CREATE_TIME_和LAST_UPDATED_TIME_,这两个字段是6.0版本才加的。如果你在老项目里看到一张没有时间字段的历史变量表,不要惊讶,那是版本决定的。但在6.x之后,这两个时间字段对排查问题帮助极大——比如你想知道某个变量是什么时候写入的、最后一次更新是何时,直接查时间字段就行。
3. 变量类型与存储路由:为什么会有TEXT2_,序列化对象去了哪里
3.1 内置类型的存储逻辑
Activiti的变量系统设计得像一个"类型路由分发器",每种类型都对应一个Type实现类,负责把Java对象转换成存储字段。以最常用的string为例,源码里的逻辑大致是这样的:
if (stringValue.length() > 4000) { setTextValue(stringValue.substring(0, 4000)); setTextValue2(stringValue.substring(4000)); } else { setTextValue(stringValue); }也就是说,TEXT2_不是为了存"第二种字符串",而是为了存"超长字符串的第二段"。这是历史遗留的设计,因为很多数据库的单字段长度限制在4000字符左右(比如Oracle的VARCHAR2早期版本),Activiti为了兼容这些数据库,才把长字符串拆成两段。所以在查询时,如果你要拿到完整的字符串,必须自己做拼接:
SELECT CONCAT(TEXT_, IFNULL(TEXT2_, '')) AS full_text FROM ACT_HI_VARINST WHERE ID_ = 'xxx';有个细节要提醒:MySQL的VARCHAR(4000)本身就能存4000字符,超长才会触发TEXT2_。实际项目里,我见过有人把整个JSON对象序列化成字符串塞进变量,一个变量值轻松超过8000字,然后TEXT_和TEXT2_各存一段。这种用法不是不行,但查询和拼接都很痛苦,而且历史表膨胀极快。我的建议是:超过2000字符的内容尽量别直接塞变量,改成存一个业务主键,需要时再去业务表里查。
3.2 serializable与custom类型:字节流去向
当一个变量是自定义对象时,默认行为是走Java序列化,把对象变成字节数组,存进ACT_GE_BYTEARRAY表,ACT_HI_VARINST的BYTEARRAY_ID_字段指向对应记录的ID_。这也是为什么有时候你查历史变量表,发现一行记录只有BYTEARRAY_ID_有值,其他值字段全是NULL——值在另外一张表里。
这里有一个版本相关的坑:Activiti 5.x默认用Java原生序列化,一旦你的实体类改了包名或者字段结构,历史数据可能反序列化失败。Activiti 6.x之后引入了基于Jackson的JSON序列化机制,不少默认类型改成了JSON存储。但如果你沿用老代码里的自定义Serializable类型,依然会走字节流路线。我在实际项目中遇到过一个问题:某个流程变量存的是老版本Java对象,后来代码升级改了类名,导致历史详情页解析变量时直接抛ClassNotFoundException。最后只能把历史变量当二进制数据下载,没法还原成对象。所以强烈建议:能用基础类型就用基础类型,实在要存对象,确保对象的序列化兼容性,并且加一个version字段。
3.3 变量类型和ACT_GE_BYTEARRAY的关联关系
补充一下关联查询的写法。当你需要查出变量对应的字节内容时,可以和ACT_GE_BYTEARRAY做JOIN:
SELECT hv.NAME_, hv.VAR_TYPE_, gb.DATA_ FROM ACT_HI_VARINST hv LEFT JOIN ACT_GE_BYTEARRAY gb ON hv.BYTEARRAY_ID_ = gb.ID_ WHERE hv.PROC_INST_ID_ = '2501';注意,ACT_GE_BYTEARRAY里的DATA_字段是BLOB类型,直接查出来是二进制流,一般需要通过代码转换成对象。另外,这张表不只服务于ACT_HI_VARINST,流程定义文件、流程图PNG、运行时变量也都可能指向它,所以不要试图用这个表反推变量类型——它本身不存类型信息。
4. 数据写入与清理的完整生命周期:何时有记录、何时被"遗忘"
4.1 历史级别决定了你能否看到变量记录
Activiti有历史级别(history level)的概念,常见的有四种:
| 级别 | 说明 | ACT_HI_VARINST是否有数据 |
|---|---|---|
| NONE | 不记录任何历史 | 无 |
| ACTIVITY | 只记录流程实例和活动实例 | 无 |
| AUDIT | 默认级别,记录实例、活动、任务、变量 | 有,记录变量最终值快照 |
| FULL | 在AUDIT基础上额外记录变量变更明细 | 有,且ACT_HI_DETAIL中会有每次变更记录 |
这个配置在引擎初始化时设置,最常见的坑是:项目用了默认的AUDIT级别,以为历史变量一定齐全,但实际上AUDIT级别下ACT_HI_DETAIL表几乎只有流程实例启动和结束的记录,变量的每次即时变化不会记明细。如果你需要审计"某个变量在哪一步被改成了什么值",必须用FULL级别,否则ACT_HI_VARINST只能看到最终值。切换级别很简单,但要注意:修改配置只对之后新建的流程实例生效,历史实例的缺失数据补不回来。
4.2 变量写入的三个关键时刻
根据源码行为,ACT_HI_VARINST的写入/更新主要发生在三个时机。
第一个是流程实例启动时,如果启动时通过runtimeService.startProcessInstanceById(processDefinitionId, variables)传入了初始变量,这些变量会立刻写入历史表。第二个是任务节点中设置变量时,比如在ServiceTask里调用taskService.setVariableLocal(taskId, name, value),这个动作会同步更新历史表。第三个是流程实例结束时,引擎会做一次变量的最终持久化,确保流程跑完后你在历史表里能看到完整的变量快照。
这里有个不太好理解的点:运行表ACT_RU_VARIABLE中的变量,在流程结束后会被删除;但ACT_HI_VARINST中的记录不会因为流程结束而消失。它是专门做历史存档的,你随时可以按PROC_INST_ID_查回整个流程所有变量的"最终画像"。
4.3 变量值变更时:快照更新与明细记录
当同一个变量在流程中被多次赋值,ACT_HI_VARINST的处理方式值得注意。流程级变量在历史表中只有一条记录,每次赋值会更新这条记录的对应值字段,同时REV_和LAST_UPDATED_TIME_递增。也就是说,历史表里看到的是"最新值",而非"每次变更的值"。
如果你需要追溯每次变更的旧值,就要配合FULL级别的ACT_HI_DETAIL表来看。ACT_HI_VARINST相当于"当前快照",ACT_HI_DETAIL相当于"操作流水",两者配合才能还原完整审计链路。有审计需求的场景,比如贷款审批、财务报销这类强制合规的流程,建议直接把历史级别设为FULL,否则将来监管要查"某个变量在某个节点被改了几次",你会非常被动。
顺带提一下,任务级变量的更新逻辑和流程级稍有不同:任务完成之后,任务级变量会跟着任务历史一起归档,但ACT_HI_VARINST里依然保留记录,只是TASK_ID_指向的任务已经结束了。所以查询历史详情时,条件里要不要带TASK_ID_,取决于你想看流程级还是任务级的变量。
4.4 删除与清理:历史不会自动消失
很多人以为流程跑完后,相关历史数据会随着时间自动清理。答案是否定的。ACT_HI_VARINST的数据一旦写入,除非你显式删除,否则永久保留。常见删除方式有三条路:
- 调用
historyService.deleteHistoricProcessInstance(processInstanceId),会整实例删除历史数据,包括这个流程实例下的所有历史变量记录。 - 直接操作数据库删除,但要注意外键关系,实际项目里不建议手贱去删,除非你完全清楚业务影响。
- 配置历史数据清理job,Activiti 7.x提供了一些清理机制,但历史版本里基本要靠自己写定时任务。
我在一个长期运行的项目里做过一次归档实践:每季度把超过半年的ACT_HI_VARINST数据搬到归档库,然后删除原表记录。因为这张表没有业务外键(ID_只被ACT_HI_DETAIL间接引用),搬数据相对容易,但必须先把流程实例维度的所有历史表一起搬,否则会出现"流程实例还在、变量没了"的数据割裂。另外,删除数据前一定要备份,这不是废话——我见过有人删完才发现某个报表SQL只查了历史变量表,导致报表数据全部丢失,最后从备份恢复了两个小时。
5. 实战中绕不开的坑:查询、乱码、膨胀与归档
5.1 查不到历史变量?先自查这四个原因
我在社区里被问得最多的一个问题:"为什么流程结束了,我用PROC_INST_ID_去查ACT_HI_VARINST,一条记录都没有?"这个问题基本有四个排查方向,按出现频率排序如下。
第一,历史级别太低。如果配置的是NONE或ACTIVITY级别,历史表里压根不会有变量数据,改配置后必须重新跑流程验证。第二,变量作用域没搞清。你查的是流程级变量,但实际保存的是任务级变量,需要带上TASK_ID_过滤才能看到。第三,查了运行表而不是历史表。ACT_RU_VARIABLE在流程结束后就会被清空,你得查ACT_HI_VARINST才行。第四,变量根本没保存成功。比如在表达式里声明的临时变量、瞬时变量(transient variable),这些不会进历史表。Activiti里有一类变量叫transient,它只在当前操作中有效,不持久化,自然也不会出现在历史表里。这个设计经常被误解,如果你发现某些变量"偶尔有、偶尔没有",先想想是不是用了瞬时变量。
5.2 长文本乱码与拼接错误
中文乱码问题在ACT_HI_VARINST上出现的概率比其他表高,因为它的TEXT_和TEXT2_各自独立。如果项目里出现过一次超长字符串写入,读取时只查了TEXT_,就会看到被截断的半截内容,看起来就像"乱码"或者"数据缺失"。
真正的乱码通常和数据库连接参数有关。检查一下JDBC连接串是否带了characterEncoding=utf8,以及数据库表本身的字符集是不是utf8mb4。Activiiti的建表脚本里,varchar字段没有强制指定字符集,如果你的库默认是latin1,中文就必然乱码。这个问题在初始化数据库时最容易被忽略,因为前期流程数据量小、英文变量多,等中文变量一多就会暴露出来。
我的排查建议是:先做一次简单验证,往流程里塞一个中文字符串变量,然后直接看数据库里的TEXT_值。如果库里面显示正常,那就是应用层读取编码问题;如果库里就是乱码,那就是建库或连接串问题。
5.3 表膨胀与查询性能优化
ACT_HI_VARINST是历史表组里最容易膨胀的表之一,因为每个流程实例可以产生几十上百条变量记录。我统计过一个真实项目的数据:单流程实例平均产生35条历史变量记录,日增约3万条,一年一千多万条。如果每条变量都带序列化大对象,空间占用会更加恐怖。
优化思路分三层。第一层是写入侧:尽量精简变量数量,避免把临时计算结果、中间状态塞进变量;能用单值就不要用对象;大对象走业务表ID关联。第二层是存储侧:定期归档,把超过业务保留期限的数据转移到归档库或冷存储。第三层是查询侧:给高频查询字段建联合索引,比如(PROC_INST_ID_, NAME_)或(TASK_ID_, NAME_),避免全表扫。Activiti的官方建表脚本里默认只有PROC_INST_ID_上的索引,生产环境我强烈建议补上NAME_相关的联合索引,否则一个流程实例几千条变量记录时,按变量名过滤就是慢查询。
这里分享一个优化案例。某流程详情页需要展示所有历史变量,原SQL是SELECT * FROM ACT_HI_VARINST WHERE PROC_INST_ID_ = ?,一个实例有200多条记录,页面打开平均1.5秒。后来发现主要瓶颈不在索引,而是每条记录都去关联ACT_GE_BYTEARRAY查大字段数据。我把变量列表页改成先只查非BYTEARRAY类型的字段,点击"查看详情"时才去加载大对象,响应时间直接降到200毫秒以内。这个经验的核心就一句话:大字段不要无条件加载,把查询拆成轻量列表和重量详情两段。
5.4 多条同名校验:别被"变量唯一"的惯性误导
最后再提一个容易翻车的点。很多从ACT_RU_VARIABLE转过来看历史表的人,会默认"一个流程实例下,变量名是唯一的"。这个假设在运行表里大体成立,但在ACT_HI_VARINST里完全不一定。比如会签节点有三个审批人,每个审批人都有一个同名任务级变量approver,历史表里就会出现三条同名记录,只是TASK_ID_不同。
如果你用WHERE PROC_INST_ID_ = 'xxx' AND NAME_ = 'approver'去查,会拿到多行结果。要想取"最新一条"或"任务对应的一条",必须再排序或加TASK_ID_条件。建议写SQL时养成一个习惯:查询历史变量一律显式排序,按CREATE_TIME_或REV_倒序取第一条,否则结果集顺序不稳定,线上Bug就是在这种细节里埋下的。
我记得有一次排查线上问题,运维从库里查到三条同名的历史变量,直接按数值最大的一条贴给我,结果那条是旧值,导致判断失误。后来我们统一在变量查询逻辑里强制加排序和去重,用PROC_INST_ID_ + NAME_ + MAX(CREATE_TIME_)做分组取最新值,才彻底解决了这类问题。虽然这增加了SQL复杂度,但历史表本身就是为追溯设计的,严谨一点不吃亏。