
我翻流程历史的时候发现很多人把 Activiti 的 25 张表翻来覆去最后卡在ACT_HI_DETAIL这张表上。说它不起眼吧它确实是历史记录的一部分说它有用吧日常排查问题大家又总是优先去看ACT_HI_PROCINST和ACT_HI_ACTINST。但真到了这个审批金额被改过几次、分别在哪个节点被覆盖、表单提交时到底传了什么这类问题前面那几张表根本答不上来只有ACT_HI_DETAIL能还原完整过程。这篇文章就把这张历史明细表彻底拆开聊透。无论你是刚开始梳理 Activiti 25 张表的初学者还是要基于流程历史做数据分析和审计的开发甚至是被变量到底被谁改了折磨过的运维这篇都值得认真看一遍。我会从表的设计定位、字段逐列含义、数据写入链路、实战查询 SQL 到生产环境归档策略一次性讲清楚。1. 为什么有了 ACT_HI_VARINST还要再来一张 ACT_HI_DETAIL1.1 ACT_HI_系列的历史分工Activiti 的历史数据不是一张大表包打天下而是按实例、活动、任务、变量、详情、备注拆成了多个维度。在 25 张表体系里ACT_HI_开头的表各管一段表名职责ACT_HI_PROCINST流程实例的历史信息记录整个流程什么时候发起、什么时候结束ACT_HI_ACTINST活动实例历史记录 BPMN 中每个节点用户任务、网关、事件等的执行情况ACT_HI_TASKINST任务实例历史记录每个用户任务分配到谁、什么时候完成ACT_HI_VARINST变量实例历史记录流程实例相关变量的值ACT_HI_DETAIL历史明细记录变量更新和表单属性提交的逐条过程ACT_HI_COMMENT审批备注、评论ACT_HI_ATTACHMENT附件历史ACT_HI_IDENTITYLINK历史参与者关系如果只看名字ACT_HI_DETAIL和ACT_HI_VARINST好像都是管变量的很多人会误以为这两张表是重复的。实际完全不是它们一个管最终状态一个管变化过程。1.2 快照与轨迹两张表最本质的区别ACT_HI_VARINST里存的是变量在某一个时刻的快照。当流程实例结束时这张表里保留的是变量的最终值就像是拍了一张照片告诉你这个流程最后停在了什么状态上。比如审批金额最终是 800 元那你在这张表里只能看到 800。而ACT_HI_DETAIL走的是完全相反的路线它记录的是每次变量变化的轨迹。变量从 1000 改成 800再改成 900它会老老实实写下三条记录。这就是日志和照片的区别照片告诉你结果长什么样日志告诉你结果是怎么一步步变成这样的。很多看表结构的人会犯一个错误觉得有了ACT_HI_VARINST就没必要用ACT_HI_DETAIL。但实际上只要涉及某个变量在流程运行中经历了哪些值表单提交时到底传了什么参数这类审计需求ACT_HI_VARINST只能干瞪眼ACT_HI_DETAIL才是唯一答案。1.3 一个非它不可的业务场景我实际接手过类似这样的问题某公司的报销流程在审批过程中有人把报销金额从 5000 改成了 800业务方不承认开发也一头雾水。翻ACT_HI_PROCINST只能看到流程正常结束翻ACT_HI_VARINST只能看到最终值 800翻ACT_HI_ACTINST只能看到节点执行顺序。最后就是靠ACT_HI_DETAIL才还原了整个链路金额在哪个任务节点被修改、当时任务在谁手上、修改时间精确到秒甚至能看到这次修改对应的活动实例 ID。这些信息拼起来才让业务方心服口服。所以这张表的实战价值是在出现争议、出现数据异常、需要做流程审计的时候才真正体现出来。2. ACT_HI_DETAIL 字段逐列拆解2.1 从建表语句看基础字段先看一张常见的 MySQL 建表脚本不同 Activiti 版本字段顺序可能有差异但核心字段一致CREATE TABLE ACT_HI_DETAIL ( ID_ varchar(64) NOT NULL, PROC_INST_ID_ varchar(64) NOT NULL, EXECUTION_ID_ varchar(64), ACT_INST_ID_ varchar(64), TASK_ID_ varchar(64), NAME_ varchar(255) NOT NULL, VAR_TYPE_ varchar(255), REV_ integer, TIME_ datetime NOT NULL, DETAIL_TYPE_ varchar(30), TEXT_ varchar(4000), TEXT2_ varchar(4000), LONG_ bigint, DOUBLE_ double, BYTEARRAY_ID_ varchar(64), PRIMARY KEY (ID_) );其中ID_是主键通常由流程引擎生成。PROC_INST_ID_是最重要的查询条件它把每一条明细归到具体的流程实例下。这个字段一定要建立索引否则生产环境数据量上来之后查询会非常痛苦。EXECUTION_ID_、ACT_INST_ID_、TASK_ID_这三个字段是把明细定位到具体执行位置的关键EXECUTION_ID_对应执行实例 ID在并行网关、子流程这类复杂场景里通过它可以判断变量变化发生在哪条分支上ACT_INST_ID_对应ACT_HI_ACTINST.ID_可以通过它反查到 BPMN 中的具体节点TASK_ID_对应ACT_HI_TASKINST.ID_如果变量变化发生在某个用户任务的上下文中这个字段会带上任务 ID。2.2 DETAIL_TYPE_VariableUpdate 与 FormProperty 两条分支DETAIL_TYPE_是这张表里最值得关注的字段它把明细记录分成两类性质完全不同的数据DETAIL_TYPE_含义出现时机VariableUpdate变量更新流程运行中调用 setVariable 等方法显式设置变量FormProperty表单属性用户任务完成时提交的表单字段值这个区分在实际排查问题时非常重要。同样是NAME_相同的字段值如果它是表单属性那它代表的是用户在页面上填写并提交的内容如果它是变量更新那它可能是代码里通过 runtimeService 或者 taskService 显式写入的变量。举一个真实例子某审批任务有一个备注字段。用户在表单里填了备注然后点提交这会写入一条FormProperty类型的明细。但如果后续代码又调用了taskService.setVariable(remark, ...)则会写入一条VariableUpdate类型的明细。两种记录有可能是同一个名字却来自完全不同的来源。如果不区分DETAIL_TYPE_排查时很容易被误导。2.3 取值列TEXT_、TEXT2_、LONG_、DOUBLE_、BYTEARRAY_ID_ACT_HI_DETAIL记录变量值不像想象中那样一个字段存所有类型的值而是根据变量类型分门别类地落进不同的列。这也是很多初学 Activiti 的人最容易看晕的地方。根据我的实际使用经验可以这样理解字符串类型的变量值一般落在TEXT2_列TEXT_列也会存部分文本类型的辅助信息具体要看版本实现不能一刀切Long、Integer 等整型变量值落在LONG_列Double、Float 等浮点型变量值落在DOUBLE_列Serializable 对象或者 ByteArray 类型的变量值不会直接存在这张表里而是把对象序列化后存入ACT_GE_BYTEARRAY表然后在这张表只记录BYTEARRAY_ID_指向那串二进制数据的 ID。生产环境里90% 以上的变量其实都是 String、Long、Double 这三种基础类型。排查问题的时候如果通过 Java API 拿HistoricVariableUpdate.getValue()框架会自动帮我们反序列化出对象不需要手动处理这些列。但如果直接查数据库就要注意别把列搞混了。我踩过一次坑查一条字符串变量时习惯性地去看TEXT_列结果发现是 NULL以为数据没记录。后来用SELECT *看了整行才发现值在TEXT2_里。从此之后我查ACT_HI_DETAIL一定先不带条件看全表数据确认一下值落在哪个列再写正式的查询 SQL。3. 明细记录从哪来写入链路与触发时机3.1 setVariable 时发生的两件事每当代码里调用runtimeService.setVariable()、taskService.setVariable()这类方法时流程引擎会在同一个事务里做两件事第一更新运行期变量表ACT_RU_VARIABLE。这张表负责当前时刻这个变量等于什么是流程引擎运行过程中真正读取的变量来源。第二如果历史级别配置允许至少是audit级别会向ACT_HI_DETAIL追加一条VariableUpdate类型的明细记录。这条记录包含了变量名NAME_、变量类型VAR_TYPE_、发生时间TIME_、当时的变量值以及当前所处的活动实例、任务实例信息。这里最关键的一点是这两步是同一个事务内完成的。也就是说只要ACT_RU_VARIABLE更新成功且事务提交了ACT_HI_DETAIL里一定存在对应的明细。反过来如果后续事务回滚明细也不会残留。这个一致性保证让审计数据非常可信。3.2 任务提交时的 FormProperty 处理当用户完成任务时比如调用taskService.complete(taskId, formMap)流程引擎在处理表单数据时会走另一条分支把表单中定义的属性字段作为FormProperty类型的明细写入ACT_HI_DETAIL。这就解释了为什么有时候会在明细表里看到大量FormProperty记录而且有些变量名在流程定义里根本没有配置过。因为表单字段不一定都需要注册成流程变量它们只是用户填写的表单内容引擎照样会把它们记录到历史明细里。所以在设计流程表单的时候就要想清楚哪些字段需要真正作为流程变量哪些只是表单展示用的临时字段。如果所有表单字段一股脑全部使用ACT_HI_DETAIL的数据量会成倍增加而且后续分析时区分提交值和变量值也会变得很累。3.3 一个报销流程实例的明细记录全生命周期为了更直观地理解我模拟一个报销申请流程。流程有三个节点发起申请、部门审批、财务打款。第一步发起人提交表单amount初始值是 1000reason是项目资料采购。任务完成时明细表会写入两条FormProperty记录NAME_VALUEDETAIL_TYPE_amount1000FormPropertyreason项目资料采购FormProperty第二步部门审批人在审批页面上把金额调整成了 800然后完成审批。这时如果代码里执行了taskService.setVariable(amount, 800)明细表就会追加一条VariableUpdate记录NAME_VALUEDETAIL_TYPE_说明amount800VariableUpdate代码显式修改了变量第三步财务节点只读取金额完成打款整个流程结束。此时ACT_HI_VARINST里看到的amount是 800reason是项目资料采购。如果我们只查ACT_HI_VARINST只能知道最终金额是 800。但通过ACT_HI_DETAIL我们能知道金额最初是 1000后来在部门审批阶段被改成了 800。这两条信息在审计场景里的价值完全不一样。还有一个容易误解的点BPMN 网关上的条件表达式读取变量时不会额外写一条明细记录。只有显式调用 setVariable 或者 complete 提交变量时才会产生明细。所以不要指望明细表里能看到表达式每次求值时的变量快照。4. 实战查询从明细表复原流程的真实执行轨迹4.1 一次性拉取某个流程实例的全部变更排查问题最常用的查询是按流程实例 ID 拉取全部明细SELECT d.ID_, d.PROC_INST_ID_, d.NAME_, d.VAR_TYPE_, d.DETAIL_TYPE_, d.TEXT_, d.TEXT2_, d.LONG_, d.DOUBLE_, d.TIME_, d.ACT_INST_ID_, d.TASK_ID_ FROM ACT_HI_DETAIL d WHERE d.PROC_INST_ID_ 2501 ORDER BY d.TIME_ ASC, d.ID_ ASC;这段 SQL 是排查问题的地基。先按时间顺序把所有明细拉出来你就能看到一个流程实例在运行期间到底发生了什么。实际使用中要记住TIME_字段的精度在部分数据库里可能只到秒级。如果同一秒内有多条记录建议再加上ID_排序保证顺序稳定。4.2 追踪单个变量的历史变化曲线如果只想看某一个变量在整个流程里的变化可以加NAME_过滤条件SELECT d.TIME_, d.LONG_, d.DOUBLE_, d.TEXT2_, d.ACT_INST_ID_, d.TASK_ID_ FROM ACT_HI_DETAIL d WHERE d.PROC_INST_ID_ 2501 AND d.NAME_ amount AND d.DETAIL_TYPE_ VariableUpdate ORDER BY d.TIME_ ASC;这条 SQL 会把这个变量每一次被修改的时间、值、所在的节点全部列出来。如果发现同一秒内多次修改就要怀疑是不是有多个监听器在同时改变量这是并发问题的高发信号。特别注意这里我加了DETAIL_TYPE_ VariableUpdate这个条件。为什么因为amount可能既作为表单属性提交过也被代码当作流程变量更新过。两种类型的记录会混在一起如果不加过滤条件你会看到表单提交 1000和变量更新 800混杂反而不好分析。4.3 关联节点与操作人拼出完整时间线明细表本身只记录活动实例 ID 和任务 ID不会直接显示节点名称、操作人姓名。要拼出完整时间线需要关联ACT_HI_ACTINST和ACT_HI_TASKINSTSELECT d.TIME_, d.NAME_, d.TEXT2_, d.LONG_, d.DOUBLE_, ha.ACT_ID_, ha.ACT_NAME_, ha.ASSIGNEE_ FROM ACT_HI_DETAIL d LEFT JOIN ACT_HI_ACTINST ha ON d.ACT_INST_ID_ ha.ID_ WHERE d.PROC_INST_ID_ 2501 ORDER BY d.TIME_ ASC;通过ACT_ID_可以定位到 BPMN 节点 ID通过ACT_NAME_可以看到节点显示名称通过ASSIGNEE_可以看到当时任务的处理人。这样一来某个变量在某个节点被谁改过这个问题就有了完整的答案。这里要注意ASSIGNEE_在ACT_HI_ACTINST里是节点执行时的处理人快照如果任务后来被转办或者委派这里记录的是最终处理人。如果需要完整追踪操作人员变化链还要额外配合ACT_HI_TASKINST去查。4.4 用 Java API 而不是拼 SQL 的查询姿势如果你是开发者其实不用直接拼 SQLActiviti 提供了流式查询 APIListHistoricDetail details historyService.createHistoricDetailQuery() .processInstanceId(processInstanceId) .variableUpdates() .orderByTime() .asc() .list(); for (HistoricDetail detail : details) { HistoricVariableUpdate update (HistoricVariableUpdate) detail; String variableName update.getVariableName(); Object variableValue update.getValue(); Date time update.getTime(); // 这里可以直接拿到反序列化后的变量对象 }这个方法的好处是框架会自动帮你把 LONG_、DOUBLE_、TEXT2_、BYTEARRAY_ID_ 等字段值还原成 Java 对象。尤其遇到 Serializable 类型的变量省去了手动反序列化的麻烦。如果要查表单属性提交记录把.variableUpdates()换成.formProperties()即可。这个 API 和前面 SQL 里的DETAIL_TYPE_过滤是对应的。4.5 明细表查询容易踩的三个坑第一个坑查明细时不加PROC_INST_ID_过滤。ACT_HI_DETAIL表是全局共享的所有流程实例的明细都在这张表里。如果只按NAME_查会把大量不同流程实例的同名变量混在一起。正确做法是任何查询都要带上流程实例 ID或者至少带上时间范围。第二个坑不看数据类型就乱取值。就像前面说的字符串值可能落在TEXT2_整型值落在LONG_浮点值落在DOUBLE_。有些人统一从TEXT_列取值结果查出来的全是 NULL 或者取错值。建议先用SELECT *看一眼原始的落列情况再写正式查询。第三个坑忽视DETAIL_TYPE_的区分。如果只是模糊地知道有个字段叫 amount不去区分它是表单属性还是变量更新可能把用户填的表单值和代码修改的变量值混在一起越查越乱。这也是我反复强调要在查询条件里带DETAIL_TYPE_的原因。5. 数据增长快是必然的归档、分区与清理策略5.1 为什么明细表比别的历史表涨得快ACT_HI_DETAIL是 Activiti 历史表里增长速度最快的几张表之一。原因很简单ACT_HI_PROCINST一条记录对应一个流程实例ACT_HI_ACTINST一条记录对应一个节点执行而ACT_HI_DETAIL是每个变量每次变化一条记录。比如一个流程实例有 5 个变量运行过程中平均每个变量被更新 3 次再加上用户任务提交时的表单属性一个流程轻松产生二三十条明细。如果流程频繁启动、变量被高频 setVariable这张表的膨胀速度会非常惊人。在生产环境我见过一张不到半年的ACT_HI_DETAIL表就涨到几千万行。所以这张表的设计阶段就要考虑归档和清理策略不能等出了性能问题再补救。5.2 索引设计建议与常见误区Activiti 默认的主键是ID_。很多版本在PROC_INST_ID_上并没有默认索引这就导致按流程实例 ID 查询明细时一旦数据量上来全表扫描几乎是必然的。我建议在生产环境至少补上这样一个索引CREATE INDEX idx_hi_detail_proc_time ON ACT_HI_DETAIL (PROC_INST_ID_, TIME_);如果经常按变量名追踪变量变化还可以加上NAME_字段CREATE INDEX idx_hi_detail_proc_name_time ON ACT_HI_DETAIL (PROC_INST_ID_, NAME_, TIME_);这里需要提醒一个常见误区不要一上来就给表建太多索引。ACT_HI_DETAIL本身写入频繁索引过多会拖慢插入性能。核心场景就是按流程实例查明细所以最优先的索引组合一定是PROC_INST_ID_开头的联合索引。等实际出现按变量名追踪的性能瓶颈再考虑加第二组索引。5.3 生产环境的滚动归档方案针对ACT_HI_DETAIL这种持续膨胀的表我常用的思路是滚动归档 分段清理。第一步定义归档策略。比如只保留最近 6 个月的明细数据超过 6 个月且流程实例已结束的把相关明细迁移到历史归档表。第二步分段清理。直接执行一条大范围的 DELETE 很容易锁表引起数据库抖动。我习惯按流程实例 ID 分批处理DELETE FROM ACT_HI_DETAIL WHERE PROC_INST_ID_ IN ( SELECT PROC_INST_ID_ FROM ACT_HI_PROCINST WHERE END_TIME_ DATE_SUB(NOW(), INTERVAL 6 MONTH) LIMIT 500 );每次只删 500 个流程实例的明细用循环反复执行这样每次 DELETE 的影响范围可控不会长时间占用数据库资源。还有一种更省事的做法如果你不需要保留任何历史明细只需要让流程跑得稳可以考虑把历史级别从默认的audit降为activity。这样ACT_HI_DETAIL就不会再写入数据。但这招要慎用因为一旦关闭后续所有审计查询都会失去数据支撑。我给大多数客户的建议是保留明细数据建立定时归档任务避免在同一个表里无限堆积。归档时还要注意一个问题如果流程实例还没结束它的明细不能提前清理。因为流程还在运行后续排错、追踪还需要这些数据。所以归档条件一定要加上流程实例已结束这个限制。另外如果流程里使用了 Serializable 类型的变量明细表里的BYTEARRAY_ID_会指向ACT_GE_BYTEARRAY表里的二进制数据。清理明细时要同步清理这些失去引用的二进制数据否则ACT_GE_BYTEARRAY表也会变成一个大坑。最后再分享一个小技巧有一次排查一个流程异常代码里明明看到变量被设置成了新值但后面的节点一读取就变回旧值。翻遍运行表都没发现问题最后就是靠ACT_HI_DETAIL把时间线拉出来发现某个节点有一个监听器在流程流转过程中又调了一次 setVariable把变量覆盖回了旧值。这个监听器平时根本不会有人注意但明细表里记录得清清楚楚。所以如果你负责的流程系统开始出现变量被莫名修改表单值对不上数据被人为改动这类问题不要先怀疑数据库先查ACT_HI_DETAIL。它能回答的问题远比一张普通历史表多得多。在设计和开发流程系统的时候也别忘了给这张表留好索引和归档策略等数据量大了再补代价会大很多。