Activiti这个系列我拖更太久了上一篇写完之后陆续有不少同行私信问我流程变量到底怎么用。说实话流程变量这个概念本身不难难的是搞清楚它在整个引擎里到底占据了什么位置——它既参与流程分支判断又影响任务分配还决定历史数据怎么落库。这篇就把流程变量的方方面面拆开讲一遍包括我实测过的各种用法和踩过的坑给正在用Activiti 6.0的兄弟们做个参考。先说适用范围。你已经跑通了Activiti 6.0的基础环境会部署流程定义、会发起流程实例但每次审批流里需要传递业务参数时就开始糊涂不知道该用RuntimeService还是TaskService也不知道变量放到哪个作用域合适更不清楚排他网关上那个${}表达式为什么取不到值。这篇文章就是冲这些痛点来的内容围绕“变量怎么存、怎么取、怎么参与流转判断”展开全程附带可直接落地的代码示例和数据库层面的现象解释。1. 流程变量的基础认知为什么它不能被业务表替代1.1 流程变量到底是个什么东西我在给团队做内部分享的时候喜欢打一个比方流程变量就是流程实例身上的一个背包。流程走到哪个节点、谁在处理、处理得怎么样需要用到什么临时数据、最终结论是什么都可以往这个背包里放。引擎默认不会替你清理背包也不会替你把它拆得七零八落它就老老实实躺在流程实例的上下文里随时等你去读写。有人可能会说我直接用业务表加一个流程实例ID关联不就行了干嘛非得用流程变量这是很多刚从传统业务系统转过来的开发者的第一反应。我用一个实际场景回答你审批门槛是“请假天数大于3天需要总监审批”。这个“天数”是用户在表单里填的如果你把它只存在业务表里Activiti的排他网关是读不到业务表数据的它只能基于流程变量做表达式判断。所以流程变量的第一个不可替代价值就是参与流程引擎自身的流转控制。另外流程变量还承担了“跨节点信息传递”的职责。任务节点A的办理人填了一个审批意见到了节点BB的办理人需要看到这个意见如果不用流程变量你就要在业务表里额外维护一套“节点间传参”的机制成本高而且容易漏。流程变量天然解决了这个问题——只要是同一个流程实例内的节点都能读到全局变量。1.2 流程变量能存什么类型从类型角度来说Activiti 6.0支持Java基础类型比如String、Integer、Long、Double、Boolean、Date也支持Serializable对象、byte数组。我列一个实际项目中常用的对照表变量类型典型用途落库表现String审批人、审批意见、业务单号ACT_RU_VARIABLE的TEXT_字段Integer/Long金额、天数、次数TEXT_字段统一以字符串存Double比例、评分TEXT_字段统一以字符串存Date申请时间、期望完成时间LONG_字段存时间戳Serializable对象表单DTO、审批详情BYTEARRAY_ID_关联二进制Byte[]附件内容不推荐BYTEARRAY_ID_关联二进制这里有个关键认知要纠正数据库表里的TEXT_、LONG_、DOUBLE_看着分得很细但引擎在读取的时候会根据变量类型做自动转换。你不需要关心底层怎么落只需要保证Java类型与你声明的类型一致。很多新手遇到“类型转换异常”基本都是因为同一变量在不同节点被塞了不同类型的数据——比如第一个节点放的是String的数字第二个节点又放了一个Integer。同一个变量名字段类型一换引擎在反序列化时直接抛错。2. 作用域才是理解流程变量的分水岭2.1 全局变量、执行实例变量、任务本地变量怎么选流程变量的作用域是Activiti新手最容易踩坑的地方也是最值得花时间搞清楚的部分。我见过不少项目不管什么变量都往流程实例上丢结果并行分支一多变量互相覆盖查问题查了一天。Activiti 6.0里变量可以分为三类全局变量流程实例变量挂在整个流程实例上任何节点、任何分支都能访问。适合放业务单号、申请人ID、最终审批结果这类贯穿整个流程的数据。执行实例变量execution变量挂在某条执行分支上。流程里有排他网关、并行网关时会分裂成多个execution每条分支可以有自己的独立变量。适合放“当前分支特有”的中间计算结果。任务本地变量task-local变量只挂在某个任务上其他任务读不到。适合放“某个节点办理人填写的临时状态”比如当前节点的审批意见草稿。画个场景你就懂了。一个请假申请申请人是A审批链是“经理审批→总监审批如果天数3→人事归档”。这里的“请假天数”必须是全局变量因为发起流程时就要写入排他网关判断要用人事归档节点还要读。而“经理临时记录的沟通纪要”就没必要全局共享用任务本地变量更干净防止后面节点被污染。选作用域的通用原则就一条数据要跨多个节点读就放全局数据只在当前任务里临时用就放本地数据是分支内部计算产生的后续只有这个分支会用就放execution。上来就全塞全局这种做法等于给流程埋雷。2.2 作用域在数据库里怎么体现ACT_RU_VARIABLE表是流程变量在运行时的物理载体。这张表里有两个关键字段EXECUTION_ID_和TASK_ID_。只看一眼这两个字段你就能判断出一个变量到底是全局、execution级还是task-local级。如果EXECUTION_ID_有值、TASK_ID_没有值全局或execution变量。如果TASK_ID_有值任务本地变量。PROC_INST_ID_永远不为空它是流程实例的主键。这张表还有一个细节同一个流程实例里出现了同名变量新值会覆盖旧值但只是逻辑层面的覆盖。底层会生成一条新的历史记录到ACT_HI_VARINST表旧的变量值可以在历史表里查到。所以做数据审计的时候别去翻运行表历史表才是完整的。3. 实操演示从启动流程到网关判断全流程跑通3.1 启动流程时初始化变量的三种方式启动流程时把初始参数带进去是最高频的操作。官方API给了三种方式别看都是设变量实际性能和语义有差异。方式一把变量直接放在Map里MapString, Object variables new HashMap(); variables.put(applyUser, 张三); variables.put(days, 5); variables.put(reason, 年假); variables.put(manager, 李四); variables.put(director, 王五); runtimeService.startProcessInstanceByKey(leaveProcess, variables);方式二启动后单独设置ProcessInstance processInstance runtimeService.startProcessInstanceByKey(leaveProcess); runtimeService.setVariable(processInstance.getId(), applyUser, 张三);方式三使用VariableMapActiviti 6.0里继承自Map的实现VariableMap variables VariableMapBuilder.create() .put(applyUser, 张三) .put(days, 5) .build(); runtimeService.startProcessInstanceByKey(leaveProcess, variables);从我的实际经验来说能用方式一就别用方式二。原因有两个一是启动流程本身是一个事务性操作变量和数据一起提交能保证一致性二是方式二多了一次数据库交互多一次网络开销。方式三相比方式一没有本质区别但语义更明确团队内如果统一风格建议固定用一种。3.2 在UserTask节点读取和写入变量任务办理节点最核心的操作是用户在前端点“同意”按钮后你把表单数据转换成变量然后完成任务。代码如下MapString, Object taskVariables new HashMap(); taskVariables.put(approveResult, 同意); taskVariables.put(approveComment, 同意按计划执行); taskVariables.put(nextApprover, 王五); taskService.complete(taskId, taskVariables);注意这里有个隐含逻辑complete时传入的变量默认是全局变量不是任务本地变量。如果你的审批意见只希望这个环节用不希望影响后面就要用setVariableLocaltaskService.setVariableLocal(taskId, temporaryComment, 这是一个临时意见);我踩过一个真实的坑当时在任务办理时把“当前节点名称”存进了变量结果流程流转到下一步时下一步网关判断恰好用到了同名变量导致流程走了完全错误的分支。排查了半天最后发现就是变量污染。从那以后我给自己定了一条铁律凡是带“临时”“当前”“上次”这种语义的字段一律用task-local不能图省事直接丢全局。3.3 服务任务ServiceTask中操作变量在服务任务里最标准的方式是实现JavaDelegate接口然后通过DelegateExecution读写变量public class CalculateDaysDelegate implements JavaDelegate { Override public void execute(DelegateExecution execution) { Integer days (Integer) execution.getVariable(days); String applyUser (String) execution.getVariable(applyUser); // 模拟业务计算如果天数大于3设置一个标志位 Boolean needDirector days 3 ? Boolean.TRUE : Boolean.FALSE; execution.setVariable(needDirector, needDirector); System.out.println(申请人: applyUser , 天数: days); } }需要留意的是在JavaDelegate里执行execution.setVariable()设置的是当前execution的变量。如果你的流程没有并行分支execution就是流程实例本身效果等于全局变量。但一旦流程上有并行网关你就要格外小心——在某个分支execution上设置的变量默认只在那个分支可见并不会自动传播到其他分支。如果你确实需要所有分支都看到这个数据建议直接操作流程实例级别的变量execution.setVariable(globalNeedDirector, needDirector);这里的关键在于如果你在一个并行子流程的execution里设置了变量另一个并行的execution是看不到的。这个细节在测试环境不一定会暴露一旦PUT到生产遇到真实并发审批就会出现诡异的分支错乱。3.4 排他网关上的表达式判断网关判断是流程变量最核心的应用场景。在Activiti 6.0的BPMN模型里排他网关的连线上可以写UEL表达式比如exclusiveGateway idgateway1 / sequenceFlow idflow1 sourceRefgateway1 targetRefmanagerTask conditionExpression xsi:typetFormalExpression ![CDATA[${days 3}]] /conditionExpression /sequenceFlow sequenceFlow idflow2 sourceRefgateway1 targetRefdirectorTask conditionExpression xsi:typetFormalExpression ![CDATA[${days 3}]] /conditionExpression /sequenceFlow注意几个容易翻车的点。第一个点${}表达式里必须用流程变量名不能用业务表的字段名。如果你在表达式里写${businessDays 3}但实际操作时变量名是days那引擎会直接报变量不存在的错误。第二个点条件顺序不是按XML里的书写顺序来的。排他网关从出线里选第一条满足条件的线走如果没有一条满足会直接抛异常。这个“异常路径”的处理要考虑好一般我会在网关表达式的最后加一条兜底连线条件写成${true}或者${days ! null}防止表达式空指针或者条件不匹配导致流程卡死。第三个点表达式里的数字比较变量必须是数值类型。如果你在Java代码里放了String类型的5表达式写成${days 3}引擎会抛类型转换错误。正确做法是启动流程时就保证类型是Integer不要图省事放字符串。4. 流程变量的高级玩法从动态审批到会签计数4.1 用变量实现动态审批人很多企业系统的审批人不是固定写死的而是根据表单内容动态决定的。这个需求用流程变量可以优雅解决。流程模型里UserTask节点的activiti:assignee属性支持UEL表达式userTask iddirectorTask name总监审批 activiti:assignee${director} /这里的director就是在启动流程时放入的一个字符串变量。如果你要交给候选组或者候选用户去签收可以用activiti:candidateUsers${candidateUsers}变量是一个逗号分隔的字符串比如zhangsan,lisi,wangwu。我实际做过的项目里审批人往往不是从用户填的表单里取而是根据业务规则查组织架构接口才能拿到。这种场景下推荐流程节点先调一个服务任务在服务任务里查询组织架构然后把审批人塞到变量里再进行UserTask分配public class QueryApproverDelegate implements JavaDelegate { Override public void execute(DelegateExecution execution) { String department (String) execution.getVariable(department); // 模拟调用组织架构查询接口 String director findDirectorByDepartment(department); execution.setVariable(director, director); } }这样流程图改动量小业务规则变更只需要改服务任务的实现不用动BPMN模型对于运维来说也友好。4.2 多实例会签场景里的内置变量多实例节点会签/或签里Activiti会提供几个只读内置变量这部分很多人压根不知道导致重复造轮子。内置变量名含义nrOfInstances实例总数nrOfActiveInstances当前活跃的实例数nrOfCompletedInstances已完成的实例数loopCounter当前循环的索引我举个会签收集审批意见的场景每个会签人办完任务后把自己的审批意见存成变量。因为是多实例并行不能直接setVariable(approveComment, comment)否则所有人的意见会互相覆盖。正确做法是把意见存成一个Listpublic class CollectCommentDelegate implements JavaDelegate { Override public void execute(DelegateExecution execution) { String comment (String) execution.getVariable(singleComment); SuppressWarnings(unchecked) ListString commentList (ListString) execution.getVariable(commentList); if (commentList null) { commentList new ArrayList(); } commentList.add(comment); execution.setVariable(commentList, commentList); } }注意这里用到了“先读取再覆盖”的套路。多实例并行的执行实例各自持有变量副本最终汇总的时候必须显式合并。如果你不懂这个原理直接在会签节点里往同一变量写数据大概率会丢数据。我见过一个生产事故就是会签审批意见只留下了最后一个审批人的前面所有人的意见都没了原因就是这个变量覆盖。4.3 监听器中读写变量事件监听器是流程变量的另一个高频使用场景。TaskListener可以在任务创建、分配、完成时触发public class TaskCreateListener implements TaskListener { Override public void notify(DelegateTask delegateTask) { String applyUser (String) delegateTask.getVariable(applyUser); // 在任务创建时给办理人发一条站内信 System.out.println(任务分配给: delegateTask.getAssignee() , 申请人是: applyUser); delegateTask.setVariableLocal(taskCreateTime, new Date()); } }这里有个细节你在TaskListener里用delegateTask.getVariable()能读取全局变量但写的时候想写全局就要用delegateTask.setVariable()想写任务本地就用delegateTask.setVariableLocal()。方法名就差一个Local语义完全不一样代码review的时候我会专门盯这个地方。5. 流程变量的最大灾难序列化问题排查实录5.1 JDK序列化的坑改类名就废了Activiti存储对象类型变量时默认用JDK序列化机制把对象塞到二进制里同时把类全限定名存到ACT_RU_VARIABLE的BYTEARRAY_ID_关联的表中。这个设计有个很要命的问题一旦你改了实体类的包名或类名甚至是字段结构反序列化直接失败。我在项目上遇到过这起事故表单DTO原先是com.example.dto.LeaveForm后来项目做模块拆分包名改成了com.example.leave.dto.LeaveForm。当时有一条残留的待办任务没办完等用户点“同意”的时候引擎尝试反序列化旧变量直接抛ClassNotFoundException流程卡死用户报障。这个问题的根治方案只有一个流程变量里尽可能不要放自定义对象只放基础类型和字符串。如果实在要放对象提前定义好全局序列化策略而且要保证版本兼容。我在另一个项目里用JSON字符串代替对象变量里只存String用的时候再解析从此再也没有序列化事故。代价是多写一点解析代码但稳定性提升明显。5.2 日期类型的“隐形陷阱”Java的Date类型存到流程变量后读取时并不会自动帮你转成特定格式。我见过团队里有人取出来直接做toString()存下来是“Mon Mar 11 10:00:00 CST 2024”然后拿这个字符串去跟数据库日期字段比对结果比对不上。另一个更隐蔽的问题是时区。引擎部署在多台机器上不同机器的系统时区不一致流程变量里的Date就会产生偏差。我们在生产环境就遇到过跨时区服务器导致审批时间错乱的问题。解决方案要么统一服务器时区要么在流程变量里不直接存Date存毫秒时间戳long展示的时候再格式化。execution.setVariable(applyTimestamp, System.currentTimeMillis());这样至少保证了变量本身是纯数值不存在时区解析问题。5.3 变量值过大会拖垮整个流程ACT_RU_VARIABLE表里TEXT_字段对超长内容会做特殊处理但如果你的变量动辄塞几万字的审批意见甚至塞一个Base64编码的文件内容数据库和引擎的处理压力都会指数级上升。实际项目里我见过一个最离谱的案例有人把整个审批附件Base64后存成了流程变量结果是每个任务节点流转时都要把这段Base64读取一遍、序列化一遍、再落库一遍流程越走越慢最后直接拖垮了流程引擎所在的应用。给一个比较合理的约定流程变量只放“控制流”和“精简业务上下文”大体积数据走业务表或对象存储流程变量里只存业务表主键ID。比如附件上传后存attachmentId要用的时候拿ID去数据库查别让流程引擎扛数据存储的工作。5.4 并发写变量的性能隐患并行网关分裂出的多个分支同时往同一个全局变量写值这里存在竞态条件。虽然Activiti的API层面每个操作都是独立事务但你无法保证两个并行分支的写入顺序最终值就是最后一次写入的那个分支的值。这种问题不是异常报错而是“静默错误”最难查。我的建议是并行分支之间不要共享可写变量。每个分支写自己的独立变量需要汇总时在所有分支汇聚后的节点做一次合并。如果确实需要全局唯一数据在流程入口一次性设置好后面只读不改。6. 排查工具与运维建议6.1 快速定位变量的数据库查询模板排查流程变量问题不要光看日志直接查数据库效率最高。我常用的三张表分别是ACT_RU_VARIABLE、ACT_HI_VARINST、ACT_HI_DETAIL。运维视角的排查SQL大致长这样-- 查看某个流程实例运行时的所有变量 SELECT * FROM ACT_RU_VARIABLE WHERE PROC_INST_ID_ 流程实例ID; -- 查看变量历史变化记录 SELECT * FROM ACT_HI_VARINST WHERE PROC_INST_ID_ 流程实例ID ORDER BY CREATE_TIME_ DESC; -- 查看某个任务上的本地变量 SELECT * FROM ACT_RU_VARIABLE WHERE TASK_ID_ 任务ID;一个实用技巧是当流程实例异常终止时ACT_RU_VARIABLE里的记录会残留数据。如果要彻底清理测试数据不要只删流程实例还要清理变量表否则下一次以相同业务单号发起流程时查询历史可能会看到脏数据影响统计报表。6.2 流程变量命名规范的重要性变量管理的混乱通常从命名混乱开始。我见过项目里变量名同时存在reason、cause、why三种写法维护的时候根本不知道哪个才是真正在用的。推荐一套做了几个项目后沉淀下来的命名规范前缀按业务模块划分比如leave_days、leave_reason避免无前缀的通用词。布尔类型统一用is_或need_开头一眼能看出含义。临时变量明确标注tmp_并且只在任务本地作用域使用。全局变量建立清单文档每次新增先查文档避免重复造同一个含义的变量。这套规范看着笨重但一旦流程数量超过20个就知道它的价值了。6.3 流程变量与表单做数据映射的推荐实践表单系统与工作流引擎对接时最常犯的错误是直接把整个表单DTO塞进流程变量。这里我推荐一个后端接口处理的模式表单提交时后端先把数据持久化到业务表然后把“业务表主键必要控制字段”转化为流程变量public void submitLeaveForm(LeaveForm form) { // 1. 保存业务数据 LeaveRecord record saveLeaveRecord(form); // 2. 构造流程变量只放精简数据 MapString, Object variables new HashMap(); variables.put(businessKey, record.getId()); variables.put(days, form.getDays()); variables.put(applyUser, form.getApplicant()); variables.put(reason, form.getReason()); variables.put(manager, findManager(form.getApplicant())); // 3. 用业务主键作为businessKey发起流程 runtimeService.startProcessInstanceByKey( leaveProcess, record.getId().toString(), variables); }业务数据与流程数据通过businessKey关联流程变量里永远只放引擎做流转判断所需的控制信息。这个模式的优点是流程引擎轻量化业务数据模型可以自由演进出了问题也容易定位。7. 几个值得固化的排查与设计习惯先说排查习惯。我一般接到流程变量相关报错后第一步不是看代码而是查ACT_HI_VARINST表先确认变量在流程的哪个节点变了值、是否被覆盖、类型是否被改掉。变量问题的线索几乎都能从历史表里翻出来省去大量现场复现时间。第二步是看BPMN XML里的表达式。逻辑问题往往不是Java代码的错而是表达式写错了变量名。表达式里少写一个字符比Java代码里报一个编译错误难查得多。我建议在流程上线前做一个“变量报文”验证把表达式涉及的所有变量打出来逐一核对与XML里UEL表达式中的名字是否完全一致。再说设计习惯。流程变量在设计阶段就该被当作“接口契约”来对待。一张变量清单表格至少要包含变量名、变量的Java类型、作用域、由哪个节点写入、由哪些节点读取、是否必需。有这张表流程引擎的演进和重构都变得可控团队协作也不会互相干扰。流程变量这个东西用好了是润滑剂用不好就是定时炸弹。我见过很多项目从“能用”到“能上线”之间卡就卡在流程变量上。它是Activiti中最容易让人忽视、又最容易埋雷的环节。希望这篇整理能帮各位少走一点弯路尤其是序列化那部分真的建议在系统设计阶段就规避掉。