接手有流程审批的系统,我最先关注的表永远是ACT_RU_TASK。它不是最复杂的一张表,但绝对是最频繁被查询的一张。用户登录后看到的"我的待办",十有八九就来自这张表;任务超时提醒、待办数量角标、撤回重办,最终也要落到这张表上。这张表叫"运行时任务表",属于Activiti的15张运行实例表之一,存的是"正在进行的任务"——只要任务没办完,记录就躺在这里;一旦流程节点往下走,这条记录又会马上被搬进历史表。数据量通常不大,但它最容易出问题:索引漏建、时区写错、权限过滤没加,都会让待办查询变得又慢又乱。这篇文章我就把ACT_RU_TASK从字段结构、生命周期、联表查询到避坑经验完整过一遍,给正在做工作流开发、需要自己写待办逻辑或者排查流程异常的同行做个参考。
1. 运行实例表里的那张任务表
1.1 Activiti的25张表到底怎么分
Activiti把数据库表分成几类,这个心里要先有个谱:
- 流程定义表(ACT_RE_*):存流程模型、流程部署包,这类表只随部署变化,不太会频繁增删。
- 运行实例表(ACT_RU_*):存流程实例、任务、变量、作业、身份关系、事件订阅等运行期数据。整类共15张,ACT_RU_TASK就是其中之一。
- 历史表(ACT_HI_*):流程实例结束、任务完成后生成的归档记录,用于追溯和统计分析。
- 通用表(ACT_GE_*):存系统属性和字节码数据。
- 身份信息表(ACT_ID_*):存用户、组、关系等身份数据。
这种拆分思路和大多数工作流引擎一致:把"当前状态"和"历史归档"分开。运行表的数据强调实时性和一致性,追求的是快速读写;历史表的数据只增不改,追求的是完整追溯。明白这个分层,后面看ACT_RU_TASK的所有设计点都顺了。
1.2 ACT_RU_TASK在运行表中的核心定位
ACT_RU_TASK对应的业务概念是"待办任务"。流程实例每次流转到用户任务节点,引擎就会在这里插入一条任务记录。它和ACT_RU_EXECUTION的关系很多人容易混:EXECUTION表代表流程执行的"令牌",表示当前流程跑到了哪个节点;TASK表代表需要由人来处理的具体工作项。流程走到一个用户节点,通常既有EXECUTION记录,也会产生TASK记录。但两者不是绑死的——并行网关下,一个执行实例可能挂着多个任务;而流程走到排他网关时,执行实例在跑,但未必有任务。因此引擎的待办中心开发,任务表的查询逻辑不会只看执行表,而是直接以任务表为主表。
ACT_RU_TASK还承担了一个隐含作用:引擎内部判断流程是否可推进的依据之一。任务如果未完成,用户节点后面的连线不会触发;只有TASK记录被删掉(即完成),引擎才会沿着当前执行实例往下找下一个节点。所以这张表的增删记录,某种意义上就是流程推进的"鼓点"。
2. ACT_RU_TASK字段逐段拆解
2.1 字段总览
我常用的是Activiti 6.0的结构,这里把核心字段整理成一张表,不同版本之间可能有小差异,但主体不会变:
| 字段 | 类型 | 说明 | 我关心的程度 |
|---|---|---|---|
| ID_ | varchar(64) | 任务主键 | 高 |
| REV_ | int | 版本号,乐观锁用 | 高 |
| EXECUTION_ID_ | varchar(64) | 执行实例ID | 高 |
| PROC_INST_ID_ | varchar(64) | 流程实例ID | 高 |
| PROC_DEF_ID_ | varchar(64) | 流程定义ID | 中 |
| NAME_ | varchar(255) | 任务名称,如"部门经理审批" | 高 |
| TASK_DEF_KEY_ | varchar(255) | 流程模型里用户节点的Key | 中 |
| PARENT_TASK_ID_ | varchar(64) | 父任务ID,子任务用 | 中 |
| DESCRIPTION_ | varchar(255) | 任务描述 | 低 |
| OWNER_ | varchar(255) | 任务的拥有人 | 中 |
| ASSIGNEE_ | varchar(255) | 当前办理人 | 极高 |
| DELEGATION_ | varchar(64) | 委派标记:POSSIBLE或RESERVED | 中 |
| PRIORITY_ | int | 优先级,默认50 | 中 |
| CREATE_TIME_ | datetime | 任务创建时间 | 高 |
| DUE_DATE_ | datetime | 到期时间,催办用 | 高 |
| CATEGORY_ | varchar(255) | 任务分类 | 低 |
| SUSPENSION_STATE_ | int | 挂起状态:1激活,2挂起 | 高 |
| TENANT_ID_ | varchar(255) | 多租户标识 | 低 |
| FORM_KEY_ | varchar(255) | 待办表单标识 | 中 |
看到这张表,重点不是把字段背下来,而是理解每个字段背后的使用场景。下面我挑几个关键的展开讲。
2.2 主键、版本号与流程坐标
ID_是全局主键,引擎生成,业务系统不要试图自己构造。REV_是乐观锁版本号,每次更新都会加一。这在多人同时操作同一个任务时特别重要:比如两个窗口同时抢办同一个任务,通过REV_就能避免"最后写入覆盖先写入"的问题。引擎层会自己校验,但作为开发人员,查询时尽量以ID_作为唯一条件,不要拿业务单据号去关联任务表——那样容易出现一对一变一对多的情况。
EXECUTION_ID_、PROC_INST_ID_、PROC_DEF_ID_这三个字段就是任务的"全地址"。PROC_DEF_ID_指向流程定义,PROC_INST_ID_指向某一次具体的流程实例,EXECUTION_ID_指向当前执行位置。一般业务系统关注PROC_INST_ID_就够了,因为它能关联到具体的业务单据。但要注意一个细节:同一个流程实例在并行网关下可能拆出多个执行实例,此时如果按EXECUTION_ID_去关联流程变量,需要先搞清楚变量挂在哪个执行实例下,否则容易查漏。
TASK_DEF_KEY_是流程模型里的节点标识。这个字段在做"节点权限"或者"节点配置"时非常有用。举个例子:某个流程里"部门经理审批"和"总经理审批"都叫审批,但对应不同权限,通过TASK_DEF_KEY_就能精确区分当前待办是哪一个节点产生的,而不是靠任务名称去猜。
2.3 人、权限与委派
ASSIGNEE_是我个人认为最重要、也最容易翻车的字段。它的值表示当前任务的直接办理人。大多数待办查询都长这样:
SELECT * FROM ACT_RU_TASK WHERE ASSIGNEE_ = 'zhangsan' AND SUSPENSION_STATE_ = 1;但实际项目里,流程配置经常出现"候选人"而不指定"办理人"的情况。流程建模时,如果把用户节点的办理人设置为一个候选组,比如"部门经理角色组",那么在任务表里ASSIGNEE_是NULL,任务实际上是通过ACT_RU_IDENTITYLINK表(运行时身份关系表)来关联候选人的。这时候查待办,只过滤ASSIGNEE_就会漏数据,必须把身份关系表关联进来一起查。
OWNER_和ASSIGNEE_的区别也容易混淆。OWNER_是任务的创建人或拥有人,ASSIGNEE_是当前实际办理人。正常审批时两者可能相同;但发生委派后,OWNER_保留原办理人,ASSIGNEE_变成被委派的人。DELEGATION_字段会标记这次操作是POSSIBLE(委派给他人,原办理人还可以干预)还是RESERVED(完全转交)。看到这里你应该明白了:权限判断不能只看ASSIGNEE_,有时候要结合DELEGATION_判断任务是不是被临时委托出去的。
2.4 时间、状态与优先级
CREATE_TIME_和DUE_DATE_支撑了"任务超时提醒"这一类需求。主题表结构简单,但时区是个坑,我后面单列一节说。SUSPENSION_STATE_表示流程实例或任务是否被挂起。流程实例被挂起后,它下面的任务一般也会被置为挂起状态,值变为2。待办查询如果不加这个过滤条件,有可能把已经挂起流程里的任务也查出来,用户点了办理还会报错。PRIORITY_默认是50,我见过有些项目用这个字段做紧急标识,效果还行,不过我更习惯用DUE_DATE_加自定义业务字段做催办,因为时间维度更直观。
3. 任务从生到死的旅程
3.1 任务创建时发生了什么
流程运行到用户任务节点时,引擎会做三件事:在执行实例表里定位或生成当前执行记录,在ACT_RU_TASK插入一条新任务,同时在ACT_RU_IDENTITYLINK插入任务与办理人的关联记录(如果有候选人或候选组的话)。
这时候去观察数据库,通常能看到一条执行实例ID相同、任务ID不同的记录。任务ID是新的独立主键,不是执行实例ID。很多初学者会把EXECUTION_ID_当成任务ID去查,结果发现查不到,就是因为这两者的粒度不一样。
任务的名称和描述通常来自流程模型里节点配置的name和description,但业务系统也经常在流程发起时动态指定任务名称,比如把单据编号拼进去:"报销单[REQ-2024-001] 部门审批"。这在待办列表里非常好用——不用再点进详情看是哪条单据,列表页直接展示出来。
3.2 认领、委派与转办对表的影响
装配候选人的任务在ASSIGNEE_为NULL的状态下等待认领。第一个抢到任务的用户点下"认领",引擎把ASSIGNEE_写成该用户,相当于完成了一次常见的"抢单"操作。从这一点可以推断:如果你要用自己的待办列表实现"抢单模式",其实不需要额外的业务字段,依靠ASSIGNEE_是否为NULL即可。
委派操作会让DELEGATION_字段值变为POSSIBLE或RESERVED,同时ASSIGNEE_换人。转办和委派在业务上看起来差不多,但在Activiti引擎里,转办通常是把ASSIGNEE_改成另外一个人,DELEGATION_保持不设置,语义上更像"把活儿交出去"。这两个操作如果自己通过Service API实现,要注意任务权限校验——是否允许当前操作人执行,否则会造成流程权限漏洞。
加签和减签在引擎层面更复杂:加签往往需要新插入子任务记录,并设置PARENT_TASK_ID_指向主任务;减签则需要把多余的任务记录删除或完成。这里给个经验:能用引擎原生API完成的操作,尽量别直接改表。直接在数据库里UPDATE ASSIGNEE_,会导致引擎的权限缓存、历史记录对不上,后续流程追溯时非常头疼。
3.3 任务完成与历史归档
任务完成后,引擎会删除ACT_RU_TASK里的这条记录,同时向ACT_HI_TASKINST写入一条完整的历史任务记录。所以你会发现一个规律:运行表里查不到已完成的任务,这是正常现象,不是数据丢了。历史表和运行表的字段结构高度相似,但历史表不会删除,还会多出几个字段,比如END_TIME_、DELETE_REASON_(删除原因)、DURATION_(耗时)。
做统计报表时,比如算某个节点的平均审批时长,就应该查ACT_HI_TASKINST而不是ACT_RU_TASK。我有一次就是没分清,业务方说"审批平均耗时看不到历史数据",结果排查发现是报表SQL一直在查运行表——任务一完成记录就没了,当然查不到历史。所以设计查询方案前,先确认自己是查"当前待办"还是"历史已办",两张表绝对不能混用。
4. 待办查询与联表查询实践
4.1 最基础的待办查询
一条最普通的待办SQL,过滤条件就三个:办理人、激活状态、创建时间排序。
SELECT t.ID_ AS taskId, t.NAME_ AS taskName, t.CREATE_TIME_ AS createTime, t.DUE_DATE_ AS dueDate, t.PROC_INST_ID_ AS procInstId, t.FORM_KEY_ AS formKey FROM ACT_RU_TASK t WHERE t.ASSIGNEE_ = 'zhangsan' AND t.SUSPENSION_STATE_ = 1 ORDER BY t.CREATE_TIME_ DESC;很多团队还会要求加一个权限控制:只看当前用户有权限的流程分类。这个条件如果直接关联ACT_RU_IDENTITYLINK,要小心一个坑:任务有多个候选人或候选组的情况下,身份关系表里会有多行记录,不加DISTINCT会造成待办列表重复。我一般在SQL层面这样处理:
SELECT DISTINCT t.ID_, t.NAME_, ... FROM ACT_RU_TASK t LEFT JOIN ACT_RU_IDENTITYLINK li ON li.TASK_ID_ = t.ID_ WHERE t.ASSIGNEE_ = 'zhangsan' OR (t.ASSIGNEE_ IS NULL AND (li.USER_ID_ = 'zhangsan' OR li.GROUP_ID_ IN ('manager_group'))) AND t.SUSPENSION_STATE_ = 1;这段SQL的逻辑就是:把ASSIGNEE_直接命中的人,和通过候选关系关联到的人合并查询。注意在OR条件组合时加上括号,AND和OR的优先级是初学者最容易踩的坑。
4.2 关联流程定义和业务表
待办列表通常不止展示任务表本身的字段,还要展示"这是什么流程""对应哪个单据"。标准做法是关联流程定义表和业务表:
SELECT t.ID_ AS taskId, t.NAME_ AS taskName, t.CREATE_TIME_, pd.KEY_ AS processKey, pd.NAME_ AS processName, e.BUSINESS_KEY_ AS businessKey FROM ACT_RU_TASK t LEFT JOIN ACT_RU_EXECUTION e ON t.PROC_INST_ID_ = e.PROC_INST_ID_ AND e.PARENT_ID_ IS NULL LEFT JOIN ACT_RE_PROCDEF pd ON t.PROC_DEF_ID_ = pd.ID_ WHERE t.ASSIGNEE_ = 'zhangsan' AND t.SUSPENSION_STATE_ = 1;这里的关联逻辑要注意:ACT_RU_EXECUTION表里,一个流程实例往往有主执行实例和子执行实例,主执行实例的PARENT_ID_为NULL。如果直接按EXECUTION_ID_关联,可能取不到BUSINESS_KEY_,因为业务流程变量经常挂主执行实例上。用PROC_INST_ID_去匹配,再过滤PARENT_ID_为空,是最稳妥的取单号方式。
4.3 超时、挂起与催办场景
催办功能一般基于DUE_DATE_。比如找出所有已经过期但尚未完成的任务:
SELECT t.ID_, t.NAME_, t.ASSIGNEE_, t.DUE_DATE_, t.PROC_INST_ID_ FROM ACT_RU_TASK t WHERE t.DUE_DATE_ IS NOT NULL AND t.DUE_DATE_ < NOW() AND t.SUSPENSION_STATE_ = 1;需要注意两点:第一,DUE_DATE_为NULL的任务不参与超时判断,有些流程可能根本就没设截止时间,这种任务理论上不该被催办;第二,数据库时间与应用服务器时间必须保持一致,否则"过期判断"会提前或延后,业务方最反感的就是明明没过期却收到催办短信。
挂起任务的排查也很常见。流程管理员可以把某个实例暂停,对应任务表的SUSPENSION_STATE_变成2。此时待办查询加上AND t.SUSPENSION_STATE_ = 1就能把它们排除。但如果业务方要求"挂起流程的待办也展示,只是不能办理",那查询就不应过滤这个字段,而是在前端按钮上做置灰处理。判断能不能办理,不能只看这个字段,还要通过引擎API校验流程状态,这样更安全。
4.4 通过Java API查询的推荐做法
大部分场景我建议直接用Activiti的TaskQuery,不要手写SQL。因为引擎内部会处理权限、租户、历史记录等逻辑,手写SQL容易遗漏状态条件。
List<Task> tasks = taskService.createTaskQuery() .taskAssignee("zhangsan") .active() .orderByTaskCreateTime().desc() .listPage(0, 20);如果需要查询候选任务,用taskCandidateUser,引擎会自动去身份关系表匹配:
List<Task> tasks = taskService.createTaskQuery() .taskCandidateUser("zhangsan") .active() .list();但有些场景必须手写SQL,比如大数据量的报表查询、跨库联表导出、或者部门维度的统计。这时候才走JDBC或MyBatis。我的原则是:在线交互操作走引擎API,离线统计和运维SQL直接走数据库,两边结合才算完整方案。
5. 常见问题排查与避坑
5.1 ASSIGNEE_为NULL导致待办漏查
这是我在实际项目里排查最多的问题。现象是:明明某个用户有权限审批,打开待办却看不到那条任务。查任务表,发现ASSIGNEE_是NULL,但ACT_RU_IDENTITYLINK里确实有候选记录。原因就是查询SQL只按ASSIGNEE_过滤,没处理候选关系。
处理办法见前面4.1那段SQL。但还有一个容易被忽视的坑:候选人可能挂在"组"下面,业务系统在查询时必须先把用户所属的全部组查出来,再用li.GROUP_ID_ IN (...)匹配。组数据往往在ACT_ID_GROUP里,如果业务系统有自己的组织架构表,要注意与Activiti身份模块同步,否则新建用户进组后待办权限会不一致。
5.2 任务表数据与执行实例不一致
有次我遇到一个诡异现象:ACT_RU_TASK里任务还挂着,但对应的ACT_RU_EXECUTION记录不见了。流程界面显示流程实例结束,待办里却还留着那条任务,用户点都点不开。后来排查发现是某次历史数据清理脚本误删了执行实例表,而任务表没同步清理。所以任何针对运行表的清理脚本,必须同时考虑执行实例、任务、身份关系、变量表的一致性。我自己执行清理前,一定会先备份,并且按流程实例ID逐个检查四张表:ACT_RU_EXECUTION、ACT_RU_TASK、ACT_RU_VARIABLE、ACT_RU_IDENTITYLINK。宁可慢,不能错。
5.3 时间字段的时区陷阱
CREATE_TIME_和DUE_DATE_在数据库里存的是datetime。如果数据库连接参数没设置好,JDBC驱动写入时可能按服务器时区转换,读取时又按另一套时区转换,导致任务创建时间相差8小时。这种问题在日志里不容易发现,但用户会明显感觉到"我上午提交的审批,显示创建时间是凌晨"。
排查思路很直接:先确认数据库服务器时区、应用服务器时区、JDBC连接参数(特别是serverTimezone配置)三者一致。再拿一条已知时间的数据对比,判断偏差是恒定若干小时还是只有几秒。我踩过之后养成了习惯:在应用启动时打印当前时间和数据库时间,做一次显式校准,然后统一使用应用服务器的标准时区。DUE_DATE_的写入也一样,所有接口传参必须带时区信息,不能传"2024-01-01 00:00:00"这种裸字符串,否则web容器和数据库容器时区不同,又会差出几个小时。
5.4 运行表数据膨胀
按说ACT_RU_TASK只存运行中任务,任务完成就删,数据量不会爆炸。但在高并发、流程异常、或者外部系统反复驳回的场景下,这张表可能会积压大量未完成任务,甚至变成几百万行。主要原因通常有三类:
- 任务没人认领,流程一直挂在用户节点;
- 流程实例被异常中断,子任务没有正确归档;
- 业务系统并发发起大量流程,审批节奏跟不上。
性能下降的表现很直接:待办查询越来越慢。不要急着加索引解决全部问题,先去看看积压任务到底是哪些节点产生的,是不是业务上已经不再需要,把它们做挂起、作废或批量删除。我见过一个项目,ACT_RU_TASK长期超过几百万行,加了索引后查询速度恢复一段时间,但根本问题没解决,下个月又慢了。治理数据膨胀的核心不是加索引,而是让业务闭环,按时完成任务、及时作废僵尸流程。
5.5 索引选择与慢查询优化
索引是按业务查询模式设计的。一般来说,ACT_RU_TASK上默认有主键索引,但实际业务经常需要这些组合索引:
CREATE INDEX idx_ru_task_assignee ON ACT_RU_TASK(ASSIGNEE_); CREATE INDEX idx_ru_task_proc_inst ON ACT_RU_TASK(PROC_INST_ID_); CREATE INDEX idx_ru_task_due_date ON ACT_RU_TASK(DUE_DATE_);建索引时要看实际SQL的WHERE条件和JOIN条件。比如查询经常带ASSIGNEE_ + SUSPENSION_STATE_ + CREATE_TIME_排序,那把三个字段做成联合索引的效果,比每个字段建独立索引好得多:
CREATE INDEX idx_ru_task_assignee_state_time ON ACT_RU_TASK(ASSIGNEE_, SUSPENSION_STATE_, CREATE_TIME_ DESC);这里要注意,不同数据库对索引顺序的敏感程度不同,但总体原则是:等值查询字段放前面,范围查询和排序字段放后面。也别为了优化把所有字段都放进索引,写多读少的表,过多的索引反而拖慢插入和更新,因为每次DML都要维护索引。我给过一个项目做优化,只加了一个联合索引,待办查询从2秒掉到200毫秒以下,效果非常明显。
6. 日常维护与设计上的一些建议
6.1 建立任务归档与清理机制
即使运行表设计上有自动删除,也建议单独建一张业务侧的任务归档表,把已经完成的任务和业务单据编号一起留存。我不是说ACT_HI_TASKINST不够用,而是很多团队会需要"按业务类型+时间范围+处理人"的查询,如果直接查历史表,历史表也会越来越大。我常用的方案是:
- 业务层在任务完成回调里,把关键信息写入业务自己的归档表。
- 每周定时任务扫描ACT_RU_TASK,把超过90天未完成且流程实例已结束的孤儿任务捞出来,做异常处理。
- 对ACT_HI_TASKINST做分区,按月分区,时间长的走归档库。
这套机制坚持下去,ACT_RU_TASK的体积基本可以稳定控制,历史查询也不会拖垮主库。
6.2 日常巡检SQL
我建议运维侧每天跑一次下面几条SQL,提前发现问题:
-- 当前待办总数 SELECT COUNT(*) FROM ACT_RU_TASK WHERE SUSPENSION_STATE_ = 1; -- 超过7天未完成的任务 SELECT t.ID_, t.NAME_, t.ASSIGNEE_, t.CREATE_TIME_, t.PROC_INST_ID_ FROM ACT_RU_TASK t WHERE t.SUSPENSION_STATE_ = 1 AND t.CREATE_TIME_ < DATE_SUB(NOW(), INTERVAL 7 DAY);如果待办总数突然翻倍,或者大批任务超过7天无人处理,说明流程链路上要么人员配置有问题,要么某个节点配置错了(比如候选人算出来是空集)。这两个指标比CPU、内存更早暴露问题。
6.3 一些长期维护的经验
做流程引擎,最重要的原则是:能让引擎管的事,别自己写SQL去改。ACT_RU_TASK的增删改,特别是删除,一定要走引擎的complete、deleteTask等API,否则跳过引擎的内部逻辑,轻则历史记录查不到,重则流程实例状态错乱。我在一个项目里为了批量清理历史测试数据,直接DELETE了ACT_RU_TASK,结果导致一批执行实例无法归档,后来花了一晚上才修复。从那之后我给自己定了条铁律:线上环境,运行表不做直接DELETE,即使要清理,也要先保证流程实例状态完全停掉,并且对该PROC_INST_ID下的所有运行表数据一并处理。
另外,改表结构这种操作要非常谨慎。ACT_RU_TASK的字段虽然不多,但Activiti引擎启动时会对表结构做校验,如果你把某个字段长度改小了,或者删掉了引擎版本依赖的字段,启动大概率会失败。遇到需要扩展信息的需求,优先把业务扩展字段放在自己的业务表里,以PROC_INST_ID_关联,而不是往ACT_RU_TASK上随便加列。这样升级引擎版本的时候,你就不用纠结兼容性问题了。
最后分享一个排查待办问题的小技巧吧:当用户报"待办不见了",我一般不看应用日志,优先查两张表——ACT_RU_TASK和ACT_RU_IDENTITYLINK。先确认任务还在不在,再确认任务和人到底是怎么关联的。任务在、关联丢了,就是权限配置问题;任务都不在了,就看是流程推进走了还是被误删了。这两步做完,八成的问题都能定位。工作流项目里,数据本身会说话,把表和业务逻辑对应起来,排查效率真的会高很多。