
做工作流开发的人总有一天会被迫直接面对数据库表。线上某个流程实例在节点上卡了好几天查待办列表发现数据对不上或者领导突然问一句“这个单子现在到底在谁手里”逼着你不得不从代码里跳出来打开数据库客户端去看那几张以 ACT 开头的大写表。翻来翻去最后几乎都会落在一张表上——ACT_RU_TASK。这个系列我准备把 Activiti 引擎的25张表按前缀家族逐个拆开讲第一篇先挑运行实例表里最常打交道、也最容易给人造成误解的ACT_RU_TASK。我们平时调用 taskService 接口拿待办、审批、驳回、转办底层数据基本都在操作这张表。理解它的字段含义和数据生命周期等于掌握了 Activiti 运行期任务体系的地基。这篇文章适合刚从 API 层面入手的开发者也适合已经写了很久工作流但遇到疑难杂症时习惯打开表却不一定看得透的同行。1. 为什么一篇讲表的文章要先把25张表的地图摊开很多人学习 Activiti 时是先看代码后看表最后才去理解表跟表之间的关系。结果就是被各种 ID 字段绕晕不知道什么时候该查 ACT_RU_ 家族什么时候该查 ACT_HI_ 家族。所以在拆 ACT_RU_TASK 之前我想先用一节的篇幅把地图铺开让大家知道这张表在整个引擎中处于什么位置。1.1 引擎表分类五个前缀背后的设计意图Activiti 的数据库表不是随便起名的前缀本身就表达了设计意图。遇到 ACT_RE_ 开头是流程定义、模型、部署这类静态资源ACT_GE_ 是通用数据存放字节数组和全局属性ACT_ID_ 是身份管理用户、用户组、关系的存放地ACT_HI_ 是历史数据流程跑完以后留下的痕迹都在这里ACT_RU_ 是运行期数据流程正在执行过程中需要被快速读写的状态都落在这类表里。运行实例表之所以数量最多、有15张是因为运行中的流程形态太多样了——执行实例、任务、变量、身份关系、作业、定时器、死信、暂停任务每一种状态都要有一张独立的表去承载。这种设计天然的考虑是运行期的表访问频率极高必须结构紧凑方便按 ID 查同时流程一旦结束运行表的记录就该删除保证运行表始终只保留“此刻还未结束”的数据。这也是 ACT_RU_ 表普遍体量不大、而 ACT_HI_ 表会不断膨胀的根本原因。1.2 ACT_RU_家族里ACT_RU_TASK 为什么这么重要整个运行家族的15张表里最容易被直接接触到的就是 ACT_RU_TASK。你登录系统看“我的待办”后端查的几乎一定是这张表你审批一个环节引擎改的也是这张表你想知道一个流程实例现在卡在哪最终答案还是要落到这张表上。其他运行表反而藏在背后比如变量表在参数传递时被读写身份关系表在候选人查询时被 join作业表由后台线程定时扫描。用户感知最强的“任务”就是 ACT_RU_TASK。但这张表也是最容易被误用的。很多人把它当成一张“普通业务表”去直接 update 修改办理人、直接 delete 伪造已完成完全绕过了引擎的 API结果引发一连串的数据不一致问题。要理解为什么不能这么干得先把这张表从头到尾看明白。2. 逐个拆开 ACT_RU_TASK从主键到租户先放一张我常用的字段清单。以下结构基于我实际生产环境常用的5.x和6.x版本Activiti 7 虽然也保留了 ACT_RU_TASK但字段上有一些调整我会在后面的版本差异部分补充说明。字段名类型含义ID_varchar(64)任务主键全局唯一通常由引擎生成REV_int乐观锁版本号每次更新自增1EXECUTION_ID_varchar(64)当前执行实例ID关联 ACT_RU_EXECUTIONPROC_INST_ID_varchar(64)流程实例IDPROC_DEF_ID_varchar(64)流程定义IDNAME_varchar(255)任务名称给用户看的展示名PARENT_TASK_ID_varchar(64)父任务ID子任务场景下使用DESCRIPTION_varchar(4000)任务描述TASK_DEF_KEY_varchar(255)任务节点定义KeyOWNER_varchar(255)任务发起人/所属人ASSIGNEE_varchar(255)当前办理人DELEGATION_varchar(64)委派状态PENDING/RESOLVEDPRIORITY_int优先级默认50CREATE_TIME_datetime任务创建时间DUE_DATE_datetime截止时间CATEGORY_varchar(255)类别一般存业务分类SUSPENSION_STATE_int挂起状态1活跃2挂起TENANT_ID_varchar(255)租户IDFORM_KEY_varchar(255)表单KeyCLAIM_TIME_datetime签收时间有了清单之后我想重点拆几个容易看走眼的字段这些字段恰恰是排查问题的关键。2.1 流程上下文四个ID谁的任务在路上EXECUTION_ID_ 和 PROC_INST_ID_ 这两个字段从名字上很像实际含义差得很远。PROC_INST_ID_ 是“整个流程实例”的 ID一个流程从发起走到结束始终不变而 EXECUTION_ID_ 指向的是一条具体的执行路径。简单流程里一个实例只对应一个 execution所以两个值一样。一旦出现并行网关、会签、子流程主 execution 会分裂出多个子 execution每个子 execution 都可能对应一条独立任务这时候 EXECUTION_ID_ 就各不相同了。我经常用一句生活化的话来解释把一次完整的流程审批看成是一辆车从起点开到终点PROC_INST_ID_ 是整个行程的编号而 EXECUTION_ID_ 是在某个岔路分出去的某一条路线编号。并行网关相当于一路上分了三条岔路同时走每条岔路都有自己的路线编号但行程编号只有一个。再看 PROC_DEF_ID_它对应的是流程定义也就是你部署上去的那个 BPMN 版本的 ID。同一个流程定义可以发起一千个流程实例每个实例都有不同的 PROC_INST_ID_但它们共享同一个 PROC_DEF_ID_。在排查版本更新问题的时候用 PROC_DEF_ID_ 分组去统计实例数量是常见操作。TASK_DEF_KEY_ 则来自 BPMN 文件里 userTask 节点的 id 属性。比如我定义一个userTask idmanager_approve name经理审批 /那么 TASK_DEF_KEY_ 就是 manager_approveNAME_ 就是“经理审批”。前者用来在代码里定位节点逻辑后者用来展示给用户看。在流程回退、跳转到指定节点的场景中TASK_DEF_KEY_ 是核心锚点。2.2 办理人三件套OWNER_、ASSIGNEE_、DELEGATION_这三个字段是日常开发中问得最多的组合。ASSIGNEE_ 是当前任务的办理人也是最直接的“这活归谁干”字段。查个人待办列表时最简单的 SQL 就是拿当前登录人用户名去匹配 ASSIGNEE_。在提交任务时引擎支持直接指定办理人也可以不指定靠流程变量动态计算或者等到运行时再通过 taskService.claim() 签收。OWNER_ 的定位相对模糊在官方语义里它表示任务的创建者或发起人但实际使用中大家更常把它当“上一个办理人”用。尤其是在“转办”场景A 把任务转给 B引擎默认行为是把 ASSIGNEE_ 从 A 改成 BOWNER_ 往往保留 A。将来要从 B 转回给 A开发者可以通过读取 OWNER_ 来恢复原处理人。这个字段设计得很轻巧但在很多企业的“退回归原办理人”需求里非常有用。DELEGATION_ 就更冷门了而且很多人用了委派 API 却不知道底层到底发生了什么。委派和转办不一样转办是彻底把办理权交给了另一个人委派则是“你帮我办一下最终结果还是要我看一眼”。引擎层面delegateTask() 会把 DELEGATION_ 置为 PENDING同时把 ASSIGNEE_ 改成被委派人被委派人处理完调用 resolveTask()DELEGATION_ 变成 RESOLVED任务重新回到原办理人手里。大家如果业务里没有委派需求可以不关注它但看到 DELEGATION_ 等于 PENDING 的数据时至少要能反应过来这是委派中间态而不是普通任务。2.3 时间与状态CREATE_TIME_、DUE_DATE_、SUSPENSION_STATE_CREATE_TIME_ 是任务落地时间经常用于优先级排序。比如待办列表里按创建时间倒序把最新到达的任务排在前面。DUE_DATE_ 是截止时间通常由业务根据流程节点上配置的时限计算出来或者由前端传进来。要注意的是DUE_DATE_ 本身只是一个“提示性字段”引擎不会因为它到期就自动做任何事。如果你期望“超过 DUE_DATE_ 自动跳下一个节点”那是定时器事件或异步 continuation 的职责纯靠这个字段是做不到的。超时总要有一个人去扫表、去触发逻辑通常是 JobExecutor 在背后做定时扫描配合定时器事件一起工作。SUSPENSION_STATE_ 容易理解1 是活跃2 是挂起。当整个流程实例被 suspend 时这个字段会跟着变查询待办时如果不小心过滤掉挂起状态会把用户实际上根本无法办理的任务也拉出来造成点击下一步报错的尴尬。3. 跟着一次正常流转看任务的生老病死字段看明白了还不够。我自己学表结构时最大的感受是如果不知道一条数据是怎么产生、怎么变化、怎么消失的记住再多字段名也拼不成完整的画面。所以这一节我们走一遍任务的一生。3.1 创建抵达用户节点的一刻假设一个审批流程刚发起某人提交了一个报销单流程实例随之启动引擎根据 BPMN 定义寻找第一个节点发现是一个 userTask 节点此时它会做几件事。第一步在 ACT_RU_EXECUTION 里建立执行实例第二步在 ACT_RU_TASK 插入任务记录把 PROC_DEF_ID_、PROC_INST_ID_、EXECUTION_ID_、TASK_DEF_KEY_ 关联起来第三步把 ASSIGNEE_ 字段写入当前办理人如果用户节点配置了候选组还会往 ACT_RU_IDENTITYLINK 写入候选人和任务的关系。这个多表写入是在同一个引擎事务里完成的要么全部成功要么全部回滚。这也是为什么我不建议大家手动对着运行表找缺漏数据的原因你看着像是“缺了一条”背后可能是一个完整事务的一部分。任务一旦创建CREATE_TIME_ 就有了值用户打开系统时看到的待办就是从这个时刻开始存在的。3.2 签收、转办、委派中间态是怎么被记录下来的如果任务没有事先指定办理人就会经历“签收”这一步。签收的逻辑很简单调用 taskService.claim(taskId, userId)引擎把 ASSIGNEE_ 从 null 改成当前用户同时写入 CLAIM_TIME_。签收之后其他候选人再查待办就看不到这个任务了因为它已经“名花有主”。这里有个细节值得拿出来说CLAIM_TIME_ 不是所有版本都有。我记得5.15版本之前签收只更新 ASSIGNEE_之后才加了这个字段用于跟踪签收时间。如果你的运行库里有 CLAIM_TIME_ 列但全是 null先别急很可能你的业务模型里所有任务都直接指定了办理人根本不需要签收也有可能是版本较老压根不维护这个字段。转办操作在数据层面就是 update 一下 ASSIGNEE_但正确的姿势是调用 taskService.setAssignee()。引擎在更新 ASSIGNEE_ 的同时还会通过内部事件机制去同步 ACT_RU_IDENTITYLINK、重置任务的签收状态、刷新历史表等。直接写 SQL 改表改的只是一个字段引擎内部的缓存和其他表根本不知道。同样委派我们前面说过更新的是 DELEGATION_ 和 ASSIGNEE_ 两个字段的组合。3.3 完成与删除运行表怎么“消失”任务真正完成后你调用 taskService.complete() 传入任务ID和相关变量引擎做的事情可以简单概括为先校验任务存在、乐观锁版本、流程状态接着从 ACT_RU_TASK 里删除这条任务记录同时在历史表里给 ACT_HI_TASKINST 写入结束时间并继续推动流程向下一个节点执行。如果下一个节点也是 userTask那就会再插入一条新的 ACT_RU_TASK如果流程结束了ACT_RU_EXECUTION 里对应的执行实例也会被清理掉。所以很多人会发现一个规律ACT_RU_TASK 里永远只存在“当下还没办完”的任务办完就没了。这也是它跟业务表的本质区别——业务表讲究留痕运行表讲究时效。如果你需要审计、统计某个人一年内办了多少任务正确的数据源是 ACT_HI_TASKINST而不是任何运行表。想通这一点很多“为什么查不到已办记录”的疑问就自然解开了。4. 实战 SQL那些我反复用的查询和排查套路理解表结构是为了解决问题。这一节我会把日常工作中反复用到的 SQL 整理出来包括标准待办查询、候选组任务查询、节点卡滞定位、超时任务统计。这些 SQL 在我排查线上问题时几乎每周都会用到省了不知道多少事。4.1 标准待办查询与安全写法大多数人写待办列表第一条 SQL 大概长这样SELECT * FROM ACT_RU_TASK WHERE ASSIGNEE_ zhangsan AND SUSPENSION_STATE_ 1 ORDER BY CREATE_TIME_ DESC;这条 SQL 能跑但我不建议直接照抄到生产。首先字符串比较是大小写敏感的你存入的用户名是统一小写就千万保证查询时也转成小写。其次应该按需查字段而不是SELECT *运行表字段多全字段查询在网络开销上完全没必要。最后真正的待办列表通常还要关联流程定义表查出流程名称并且要过滤掉已挂起的流程。安全写法可以这样SELECT t.ID_ AS task_id, t.NAME_ AS task_name, t.PROC_INST_ID_, t.CREATE_TIME_, t.DUE_DATE_, p.NAME_ AS process_name FROM ACT_RU_TASK t LEFT JOIN ACT_RE_PROCDEF p ON t.PROC_DEF_ID_ p.ID_ WHERE t.ASSIGNEE_ #{userId} AND t.SUSPENSION_STATE_ 1 ORDER BY t.CREATE_TIME_ DESC;如果是候选人的待办那就需要 join 身份关系表了。当一个用户被指定为某个任务的候选人的时候这个关系会记录在 ACT_RU_IDENTITYLINK 里USER_ID_ 存候选人TASK_ID_ 关联任务ID。查询起来也比较直接SELECT t.* FROM ACT_RU_TASK t INNER JOIN ACT_RU_IDENTITYLINK i ON t.ID_ i.TASK_ID_ WHERE i.USER_ID_ #{userId} AND i.TYPE_ candidate AND t.SUSPENSION_STATE_ 1;注意这里的 TYPE_ 条件identitylink 表里有Assignee、Candidate、Participant、Owner 等多种类型查询候选人待办时务必带上 candidate否则会把一堆历史关系捞出来还可能出现重复任务。4.2 节点滞留、超时任务、会签任务的实际排查线上最头疼的问题可能就是“某个流程卡住不走了”。这种时候我的排查顺序是先用流程实例ID去查卡在哪个任务节点SELECT t.ID_ AS task_id, t.TASK_DEF_KEY_, t.NAME_ AS task_name, t.ASSIGNEE_, t.CREATE_TIME_, e.ACT_ID_ AS current_activity_id FROM ACT_RU_TASK t INNER JOIN ACT_RU_EXECUTION e ON t.EXECUTION_ID_ e.ID_ WHERE t.PROC_INST_ID_ #{procInstId} AND t.SUSPENSION_STATE_ 1;这条 SQL 能同时给出当前任务节点和当前活动节点。如果没有返回任何行说明这个流程实例并不停留在用户任务上那就要去 ACT_RU_EXECUTION 里看 ACTIVITY_ID_判断是不是卡在网关、异步节点或者等待状态上。顺便提一句真正想查流程实例当前节点的官方推荐方式是 runtimeService.getActiveActivityIds()但对生产事故来说SQL 往往更快更直接。超时任务统计也用得很多特别是做超时提醒或者绩效看板SELECT t.ID_ AS task_id, t.NAME_ AS task_name, t.ASSIGNEE_, t.CREATE_TIME_, t.DUE_DATE_, TIMESTAMPDIFF(HOUR, t.CREATE_TIME_, t.DUE_DATE_) AS limit_hours, TIMESTAMPDIFF(HOUR, t.DUE_DATE_, NOW()) AS overdue_hours FROM ACT_RU_TASK t WHERE t.SUSPENSION_STATE_ 1 AND t.DUE_DATE_ IS NOT NULL AND t.DUE_DATE_ NOW() ORDER BY t.DUE_DATE_ ASC;关于时间的时区问题要格外小心。如果你的数据库连接串没有配置 serverTimezoneMySQL 默认会按服务器时区处理 datetime而 Activiti 写入的时间用的是 JVM 时区。一旦 JVM 和数据库不在同一个时区DUE_DATE_ 就会整体偏移几个小时导致该超时的任务不超时、不该超时的任务提前报警。排查这类时间问题时第一件事是先确认两边的时区是否一致。会签任务在 ACT_RU_TASK 里也会展现出明显的特征同一个 PROC_INST_ID_ 下会有多条 TASK_DEF_KEY_ 相同的任务记录。比如三层会签三个人各一个 ASSIGNEE_每条记录的 EXECUTION_ID_ 往往是独立的子执行实例。统计会签完成情况时要用分组计数SELECT PROC_INST_ID_, TASK_DEF_KEY_, COUNT(*) AS task_count, SUM(CASE WHEN ASSIGNEE_ IS NOT NULL THEN 1 ELSE 0 END) AS claimed_count FROM ACT_RU_TASK WHERE SUSPENSION_STATE_ 1 GROUP BY PROC_INST_ID_, TASK_DEF_KEY_ HAVING COUNT(*) 1;5. 和 ACT_RU_TASK 打过几年交道后才明白的坑前面几节是方法论最后这节我想把这些年踩过的坑和不加思考就动手的教训直接摆出来。这些经验在官方文档里读不到但每一条都源自真实的线上事故。5.1 千万别绕过引擎直接改表这是最重要的一条底线。我看到过不止一个团队为了赶工期直接跑一条 SQL 把 ACT_RU_TASK 的 ASSIGNEE_ 更新成另一个人的名字或者干脆 delete 掉某条任务记录想让流程自动跳过节点。表面上看流程确实往前走了一步但代价是灾难性的。第一引擎有自己的缓存和校验逻辑。你改了表引擎内存里的任务实例还停留在旧状态用户点按钮时拿到的是旧的 ASSIGNEE_要么审批按钮是灰的要么报“不允许该用户操作”。第二历史表不会自动同步审批记录里办理人依旧是最初那个人将来审计时问题就来了。第三ACT_RU_IDENTITYLINK 里的候选人关系也没更新候选人面板的数据是脏的。正确的做法永远是调用 API。转办用 taskService.setAssignee()委派用 taskService.delegateTask()跳节点用 runtimeService.createChangeActivityStateBuilder()。哪怕只是看起来“很小”的修改也要通过引擎门面去做。5.2 乐观锁 REV_ 在并发场景下的真实作用REV_ 这个字段平时没人注意但它是高并发场景下的隐形守护者。ACT_RU_TASK 的每一次 update 操作引擎都会带着 REV_ 去做 where 条件比如UPDATE ACT_RU_TASK SET ASSIGNEE_ ?, REV_ ? WHERE ID_ ? AND REV_ ?。如果两个线程同时抢同一个任务后提交的那个 update 会发现 REV_ 已经不是预期值更新 0 行引擎随即抛出乐观锁异常。我曾经在一个抢单场景里见过这个问题两个审批人同时点了某个任务的“我要办理”结果只有一个人成功另一个人报错。这不是 Bug而是乐观锁的正常保护。生产环境如果需要处理这种并发冲击一是要在前端做任务抢占确认二是后端要捕获异常后友好提示。另外千万注意别把两个 Activiti 节点同时连同一个数据库那样 REV_ 保护机制会被削弱分布式锁才是正确的办法。5.3 版本差异与运行表膨胀问题版本差异这个坑比较大。5.x 时代ACT_RU_TASK 的索引要靠手动脚本去补很多团队遗漏了 TASK_DEF_KEY_ 或 PROC_INST_ID_ 上的索引导致数据一旦上万查询就慢到不可接受。6.x 已经默认建好了合理的索引但如果你是从老版本迁移过去建议检查一下现有索引是否齐全。Activiti 7 之后引擎架构变化不小运行表字段也有调整从老项目迁移时不要只拷贝建表语句还要看引擎初始化脚本里的全套 DDL。运行表膨胀问题同样值得提前思考。ACT_RU_TASK 理论上是不会无限膨胀的因为任务完成就删。但如果你的系统里有大量流程长期停滞在某个审批节点这些节点上的任务记录就会一直留在运行表里。日积月累ACT_RU_TASK 越来越大查询待办越来越慢最终拖垮整个引擎。处理方式不是随意清表而是从业务层面治理——自动催办、超时提醒、自动撤回机制让流程要么往前走要么被终止让运行表真正“活起来”。最后再说一点个人体会。搞懂 ACT_RU_TASK 的价值不只在于“会查”更在于设计流程时能预判数据会怎样流动。同一个字段在不同场景下含义会有差异同一个表在不同版本里结构也不同没有一种解法包打天下。碰到疑难问题先想清楚业务动作对应的是引擎的哪个 API再去翻表结构顺序千万别搞反。