ACT_RU_JOB 运行实例表全解:Activiti 作业调度与定时器排障实战 如果你在一个以 Activiti 做流程引擎的项目里待过打开数据库大概率会看到一片ACT_前缀的表。第一次看到ACT_RU_JOB时我以为它只是记录历史作业的日志表直到有一次线上流程的定时器怎么都不触发才意识到这张运行实例表承担着整个引擎的“闹钟”职责。这篇文章是 25 张表拆解系列的第 1 篇先把第 14 张运行实例表ACT_RU_JOB讲透它为什么存在、每个字段到底什么意思、作业是怎么被调度执行和删除的以及排障时最值得记住的几条 SQL。文中涉及的方法和结论大多来自我实际排查过的生产案例也参考了 Activiti 6.x/7.x 版本的默认表结构。不同小版本字段会有些差别这很正常我会把常见差异标出来你看的时候对照自己的库结构验证一下。1. ACT_RU_JOB 在运行实例表中的定位专管“还没到时候”的任务1.1 Activiti 25 张表的分类与 ACT_RU_JOB 的坐标Activiti 的表不是漫无目的堆出来的它们按职责分成几组。ACT_GE_是通用数据比如资源、字节数组ACT_RE_是流程定义和模型这类静态资源ACT_RU_是运行时数据包括执行实例、任务、变量、身份关系等ACT_HI_是历史数据ACT_ID_是用户和组信息。运行时数据里ACT_RU_EXECUTION是执行树的核心ACT_RU_TASK是待办任务ACT_RU_VARIABLE是流程变量而ACT_RU_JOB专门用来存放需要延迟执行或异步执行的作业。很多人会把这 25 张表按“是否常用”划分ACT_RU_JOB在早期项目里存在感很低因为不排查定时器问题时你几乎不会主动查看它。但它属于运行实例表意思很明确只要表里还有作业数据就说明有流程实例正在等待某个未来时刻或者某个异步步骤还没跑完。作业被成功执行后对应行会被删除所以正常情况下这张表的行数应该维持在一个比较低的水平。官方清单里它排在运行实例表的第 14 位也就是你标题里看到的“运行实例表 14”。这个编号更多是表清单的排序不代表调用顺序。真正重要的是ACT_RU_JOB是连接 BPMN 时间等待语义和底层调度线程池的桥梁。1.2 为什么要在运行时表里单独维护一份“待办调度”流程引擎里经常出现这样的场景用户完成一个任务后流程需要等 5 分钟再执行下一步或者开始事件配置了一个定时器每个工作日早上 8 点发起流程又或者某个服务任务配置了异步执行当前事务提交后再继续跑。这些需求都有一个共同点“不是现在立刻执行而是未来某个时刻执行”。如果把这些待执行的逻辑直接塞进ACT_RU_EXECUTION那张表会变得极其拥挤而且定时扫描整个执行树来判断“谁该到期了”非常低效。Activiti 的做法是把未来要执行的动作抽象成一条条作业记录单独放进ACT_RU_JOB。你可以把这理解成车站的候车大厅所有还没发车的乘客都在候车大厅排队票上写着发车时间到了时间广播会通知对应车次检票。ACT_RU_JOB就是那张写着车次、时间、目的地的待发车列表Job Executor 就是广播员。这种设计还有一个好处作业的调度频率、锁定机制、重试策略都可以独立于流程实例进行控制。比如多台应用服务器同时运行 Activiti多个 Job Executor 会同时抢这张表里的作业谁抢到锁谁执行。如果作业执行失败还可以重试。如果这些都由执行树承担开发和运维都会非常痛苦。2. 字段全景身份、锁、调度、异常四组各管什么2.1 一张表看懂 ACT_RU_JOB 的全部字段附版本差异Activiti 6.x/7.x 的ACT_RU_JOB比较完整的字段结构如下。我按常见数据库方言写类型实际在 MySQL、Oracle、PostgreSQL 上会略有差异。字段名类型说明ID_varchar(64)作业主键REV_int乐观锁版本号TYPE_varchar(255)作业类型LOCK_OWNER_varchar(255)当前锁持有者LOCK_EXP_TIME_timestamp锁过期时间EXCLUSIVE_boolean是否排他EXECUTION_ID_varchar(64)关联执行实例PROCESS_INSTANCE_ID_varchar(64)关联流程实例PROC_DEF_ID_varchar(64)关联流程定义ELEMENT_ID_varchar(255)关联 BPMN 元素 IDELEMENT_NAME_varchar(255)关联 BPMN 元素名称SCOPE_ID_varchar(255)作用域 ID新版本用于 CMMN 等SCOPE_TYPE_varchar(255)作用域类型SCOPE_DEFINITION_ID_varchar(255)作用域定义 IDRETRIES_int剩余重试次数EXCEPTION_STACK_ID_varchar(64)异常堆栈存储 IDEXCEPTION_MSG_varchar(4000)异常信息摘要DUEDATE_timestamp到期时间REPEAT_varchar(255)重复调度表达式HANDLER_TYPE_varchar(255)处理器类型HANDLER_CFG_varchar(255)处理器配置CUSTOMVALUES_ID_varchar(64)自定义值存储 IDCREATE_TIME_timestamp作业创建时间TENANT_ID_varchar(255)租户 ID如果你用的是 Activiti 5.x表里通常没有REPEAT_、CUSTOMVALUES_ID_、CREATE_TIME_、SCOPE_*、ELEMENT_*这些字段。Activiti 7 在某些版本里还把作业拆成了多张表比如定时器作业、死信作业、挂起作业等但核心字段逻辑基本沿用同一套设计。看到哪个版本就按哪个版本的元数据来网上很多旧帖子的表结构截图已经过时别直接照抄。2.2 几个容易搞混的关键字段和行为先说EXCLUSIVE_。同一流程实例关联的多个作业如果EXCLUSIVE_1Job Executor 在某个时刻只会执行其中一个避免并行执行同一流程的数据导致资源竞争。如果EXCLUSIVE_0这些作业可以被不同线程同时拉起来跑。默认情况下异步继续作业通常是排他的但在一些需要并发的场景里你会看到非排他作业。这个字段不要轻易改想要并发控制应该从 BPMN 模型层面设计。LOCK_OWNER_和LOCK_EXP_TIME_是集群锁机制的核心。Job Executor 会先执行一条类似“抢锁”的更新语句把作业的LOCK_OWNER_设为自己的节点名LOCK_EXP_TIME_设为当前时间加上锁超时时间。抢到锁的节点才算真正拿到这个作业的执行权。执行完成后立刻把作业行删除锁也就自然释放了。如果执行到一半宕机锁会等到LOCK_EXP_TIME_过期后由其他节点重新抢走防止作业永久卡住。RETRIES_表示剩余重试次数。每次执行失败引擎会把RETRIES_减 1把异常信息写进EXCEPTION_MSG_如果配置了堆栈存储还会把完整堆栈写到ACT_GE_BYTEARRAY并通过EXCEPTION_STACK_ID_关联。当RETRIES_0时作业不会自动再跑而是留在表里等待人工介入。很多线上事故的根源就是这里作业失败后没人看累积了一大堆RETRIES_0的僵尸作业。DUEDATE_是作业的执行时间点。Timer 作业的到期时间就是业务上“等 X 时间”的那个时刻。Job Executor 扫描时只关心DUEDATE_ 当前时间且未被锁定的记录。所以数据库服务器和应用服务器的时间不一致会直接导致定时器提前或延后触发。踩过这个坑的人应该深有体会。2.3 作业表和周边表的外键关系ACT_RU_JOB最频繁关联的表是ACT_RU_EXECUTION。EXECUTION_ID_指向某个执行实例PROCESS_INSTANCE_ID_指向流程实例而这个流程实例本质上也是执行树里的根执行实例。当流程实例被删除时引擎通常会级联清理掉它名下的作业但如果手工在数据库里删流程实例就很容易留下孤儿作业。PROC_DEF_ID_关联ACT_RE_PROCDEF用于定位流程定义。EXCEPTION_STACK_ID_和CUSTOMVALUES_ID_都关联到ACT_GE_BYTEARRAY一个是异常堆栈字节一个是自定义数据字节。TENANT_ID_在多租户场景下用来隔离数据跨租户排查时经常忘记了加这个条件。从数据完整性的角度说ACT_RU_JOB的很多关联字段都不是数据库外键约束而是引擎级联维护的逻辑关系。所以你直接删ACT_RU_JOB数据时数据库不一定报错但引擎内存里如果有对应的实体对象后续提交时可能因为乐观锁失败报异常。这也是为什么我一直强调能用接口就用接口尽量别绕过引擎操作表。3. 作业的一生写入、抢锁、执行、重试和删除是怎么串起来的3.1 作业从哪来三类来源的写入场景第一类最常见的来源是 BPMN 中的定时器事件。比如流程走到某个用户任务边界上挂了一个定时器事件定义是“如果 48 小时后还没处理就自动发送提醒”。引擎在到达边界事件时生成一条TYPE_为 timer 的作业DUEDATE_是当前时间加 48 小时。流程执行到定时中间捕获事件同理ACT_RU_JOB里会有一条等待只有到期后流程才能继续。第二类是异步继续。有些服务任务或执行监听器配置了asynctrue引擎不会在同一个事务里立刻执行它们而是写入一条类型为 async-continuation 的作业。核心目的是把流程执行和业务事务解耦避免长事务或接口调用阻塞整个流程事务的提交。这类作业通常很快就会被执行EXCLUSIVE_值多为 1。第三类是消息事件或服务集成产生的作业。比如调用外部系统失败后延迟重试或者 Spring 集成场景里投递的异步消息。不同版本里TYPE_的具体取值会有差异但你可以通过HANDLER_TYPE_查看实际处理器。理解来源很重要否则你看到大量 job 堆积时根本不知道它们是卡住了还是仅仅因为业务需求就是长期等待。3.2 Job Executor 如何扫描、加锁并执行Activiti 的调度核心是 Job Executor。它通常是一个后台线程池按固定间隔扫描ACT_RU_JOB找出所有“到期且没有被锁定”的作业。扫描不是一次把整个表捞出来而是按配置的最大获取数量来拉一批。找到候选作业后每个工作线程会尝试对作业加锁。加锁过程大概是一句条件更新 SQL把LOCK_OWNER_从空值改成当前节点标识LOCK_EXP_TIME_设置成当前时间加锁超时时间同时要求原LOCK_OWNER_为空或锁已过期。更新影响行数为 1 才表示抢锁成功。抢锁成功后引擎根据HANDLER_TYPE_调用对应的作业处理器执行真正的业务逻辑。如果成功作业行被删除。如果失败重试次数减一异常信息写入表。下个扫描周期这个作业再次被拉出来重试直到重试次数耗尽。这里有个容易被忽视的细节作业执行时的数据库事务边界。异步继续作业的执行通常和流程实例的推进在一个事务里Timer 作业的执行也会更新执行实例状态。如果你在作业执行的处理器里调用外部接口而这个外部接口很慢锁超时时间设得太短的话作业还没跑完锁就过期了其他节点可能认为这个作业是无人处理的又抢过去重复执行导致业务重复提交。这类问题比想象中常见尤其多节点部署时。3.3 失败重试、死信和重复调度重试并不是立刻马上再来一次而是等待下一个扫描周期。扫描周期的默认值在不同版本里可能是 5 秒、10 秒或更长和AsyncExecutor配置有关。所以失败后你看到RETRIES_从 3 变成 2可能等了十几秒才变化这很正常。如果你希望失败后尽快重试可以调小扫描周期但代价是数据库压力变大。REPEAT_字段用于重复定时器。比如你要让定时器事件每 5 分钟触发一次总共触发 3 次表达式可能是R3/PT5M。引擎处理起来并不是只存一条作业而是每执行完一次就根据表达式计算出下一次的DUEDATE_更新这条作业记录或者根据需要生成新的作业。所以你在查询ACT_RU_JOB时可能会看到一条REPEAT_不为空的作业反复出现这不是故障是正常的循环调度。新版本里如果重试次数耗尽作业可能被移动到专门的死信作业表或者留在当前表但RETRIES_0。死信的核心含义是“自动处理已经结束需要人工决策”。此时你直接修改RETRIES_把作业拉回正常队列是可以的但必须搞清楚为什么失败。如果业务代码有 bug重置再多次也没用反复失败还会给日志系统制造噪音。4. 排障实战定时器不触发、重试归零、孤儿作业和锁竞争4.1 定时器到期不触发先查这五步如果你有一个定时边界事件到期后流程没往下走第一件事不是改业务代码而是查作业表。打开数据库执行下面这条 SQL看有没有对应的作业记录SELECT ID_, TYPE_, DUEDATE_, RETRIES_, LOCK_OWNER_, LOCK_EXP_TIME_, EXCEPTION_MSG_ FROM ACT_RU_JOB WHERE PROCESS_INSTANCE_ID_ 你的流程实例ID;如果查询结果是空说明作业根本没生成问题可能出在流程定义解析或者到达定时器节点前流程就异常了。如果有一条记录但DUEDATE_比当前时间晚说明时间还没到检查服务器时钟即可。如果DUEDATE_已经到了但LOCK_OWNER_不为空且LOCK_EXP_TIME_也没过期说明有另一个节点正在执行或者刚崩过等锁超时再看。如果作业上RETRIES_0并且EXCEPTION_MSG_有内容那才是业务执行失败导致的停摆。另外别忘了确认 Job Executor 确实启动了。Activiti 配置里通常有个开关叫asyncExecutorActivate没打开的话整个调度线程都不跑作业会全部停留在ACT_RU_JOB里看起来就像定时器集体失灵。这套连锁检查我写过很多次每次都能快速定位 90% 的问题。核心原则就是先看表里有没有作业再看作业状态正不正常最后才怀疑代码。4.2 重试归零的作业如何安全重置当一条作业已经RETRIES_0时你通常会看到类似“作业执行失败”的异常信息。要恢复它理论上可以直接更新表UPDATE ACT_RU_JOB SET RETRIES_ 3, EXCEPTION_MSG_ NULL, EXCEPTION_STACK_ID_ NULL, LOCK_OWNER_ NULL, LOCK_EXP_TIME_ NULL WHERE ID_ 作业ID;但我不建议你上来就执行这条 SQL。因为异常堆栈存在ACT_GE_BYTEARRAY里如果只是把EXCEPTION_STACK_ID_置空字节数组就成了孤儿数据。更重要的是如果失败原因还没解决重置后它就会再次失败再次把堆栈写进去。比较安全的做法是用 Activiti 的管理 API把作业移到死信表或者让作业重新尝试执行如果只能手工操作务必先摘掉锁、清零异常信息并且在低峰期做。还有一个很容易踩的坑你手动重置了RETRIES_但作业的LOCK_OWNER_还挂着某个已经宕机的节点名LOCK_EXP_TIME_没超时。此时 Job Executor 不会碰这条作业重置等于没重置。一定要把锁字段一起清掉。如果节点其实还在运行只是数据库会话断了清锁会导致两个节点重复执行同一个作业。所以生产环境重置前先确认那个节点到底死没死。4.3 孤儿作业的识别与清理所谓孤儿作业就是作业还留在ACT_RU_JOB里但它关联的执行实例或者流程实例已经不存在了。常见原因是流程实例被手工从数据库删除或者历史数据被清理任务删了但作业没有级联处理。排查方法很简单SELECT j.ID_, j.PROCESS_INSTANCE_ID_, j.EXECUTION_ID_ FROM ACT_RU_JOB j LEFT JOIN ACT_RU_EXECUTION e ON j.EXECUTION_ID_ e.ID_ WHERE j.EXECUTION_ID_ IS NOT NULL AND e.ID_ IS NULL;查出来的记录基本可以判断为孤儿作业。处理时优先尝试用引擎的删除接口让引擎自己清理相关数据。如果你只能直接 SQL 操作删除前至少确认对应流程实例确实已删除不要误删还在正常等待的作业。还有一个相关场景流程实例已经结束但作业还在。正常情况下流程实例结束后作业会被清理但如果你使用了内嵌子流程、边界事件跨作用域偶尔会出现清理不干净的情况。这时候把关联的PROCESS_INSTANCE_ID_与ACT_HI_PROCINST对比如果历史里流程实例已经完成作业基本可以安全删除。4.4 多节点集群下的锁竞争问题多节点部署时锁竞争是ACT_RU_JOB最典型的麻烦。之前我在一个模拟项目里见过两个节点同时消费一批作业最初配置的锁超时时间是 5 秒而作业处理器要等一个外部接口平均耗时 8 秒。结果一个作业刚执行 5 秒锁就过期了另一个节点立刻抢走同一份作业外部接口被调用了两次下游出现重复数据。这种问题的解法不是简单的把锁超时时间调大而是要理解作业处理器的耗时分布。如果大部分作业都在几百毫秒内完成只有少数慢作业可以考虑给慢作业单独处理或者调大整体锁超时时间。另一个办法是让外部接口实现幂等这样即使重复执行也没有副作用。技术方案上避免长时间占用作业锁永远是第一原则。另外多个节点要确保时钟一致。LOCK_EXP_TIME_和DUEDATE_都是基于时间的如果一个节点时间快了三分钟它可能在锁刚过期时立刻抢回来但业务时间判断也会错乱。生产环境一定要配置统一的 NTP 时钟同步这是最小成本、最大收益的优化。4.5 作业表膨胀与参数调优如果ACT_RU_JOB长期几万行、几十万行要先看这些作业是哪种类型。定时器等待是正常的比如大量流程都等 7 天那表里必然有大量未到期作业。真正危险的是大量RETRIES_0的失败作业或者异步继续作业堆积。失败作业堆积意味着业务故障没有及时处理异步继续作业堆积则通常是线程池不够或者数据库连接池被占满。监控时可以分组统计SELECT TYPE_, COUNT(*), MAX(DUEDATE_), MIN(DUEDATE_) FROM ACT_RU_JOB GROUP BY TYPE_;如果async-continuation数量持续很高多半是 Job Executor 的线程池跑不过来。Activiti 的异步执行器有几个关键参数可以调比如核心线程数、最大线程数、每次获取的作业数量、锁超时时间等。不同版本参数名可能不完全一样但核心思路是一次不要捞太多作业线程池要扛得住突发的并发量锁超时时间要大于绝大多数作业的执行耗时。数据库层面要给ACT_RU_JOB关心的高频查询建合适的索引。比如按DUEDATE_查询到期作业按PROCESS_INSTANCE_ID_查某流程实例的作业。Activiti 的建表脚本里通常自带基础索引但如果你有很多自定义查询还是要自己确认执行计划。索引也不是越多越好写频繁的表索引太多会影响插入性能。5. 围绕 ACT_RU_JOB 的几个认知误区与日常维护清单5.1 四个容易让人误判的认知误区第一个误区是把它当成历史日志表。ACT_RU_JOB里存的是“还没有做完”或“还没到时间”的作业不是流水账。作业一旦执行成功就会被删除所以你看到这张表永远只能看到当前和未来看不到过去。有人做数据归档时想把这张表当成历史表同步走结果把等待执行的任务也搬走了流程全部卡住。第二个误区是手动清理表数据来解决积压。有人看到作业表太大直接DELETE FROM ACT_RU_JOB结果所有正在等待定时器的流程实例全部断掉而且引擎内存里的实体对象还认为作业存在后续提交直接抛乐观锁异常。想清理作业一定要先搞清楚为什么要清理。失败作业要修异常过期作业要核实流程状态孤儿作业要确认关联的流程实例已经没了。第三个误区是认为RETRIES_设置的越大越好。重试机制是用来兜底网络抖动和临时故障的不是用来掩盖业务代码异常的。设置成 100 次慢慢失败的作业会反复跑 100 个周期占用线程和数据库资源。更合理的做法是保持默认的 3 次左右配合死信表或告警通知让运维人员及时介入。第四个误区是只要有定时器就必须依赖 Job Executor。如果项目里已经有成熟的任务调度平台完全可以由外部系统在指定时间调用引擎 API 发起流程或触发事件把ACT_RU_JOB的负担减到最低。Activiti 的作业表适合流程引擎内部的轻量定时语义不适合替代专业调度系统来做海量定时任务。5.2 日常监控和维护清单日常维护不复杂但要有固定的检查节奏。我习惯每天看两条 SQL一是查失败作业数也就是RETRIES_0或者EXCEPTION_MSG_不为空的行数二是查锁定过期数也就是LOCK_OWNER_不为空且LOCK_EXP_TIME_小于当前时间的行数。失败作业数长期大于 0说明有业务问题没解决锁定过期数频繁出现说明有节点崩溃或者有耗时过长的作业。每次发版涉及定时器事件、异步任务或作业处理器改造时我会提前在测试库跑一遍完整的作业生命周期生成作业、等待到期、执行成功、确认删除再模拟一次失败确认重试次数递减和异常信息写入是否正确。这套流程虽然简单但能规避大多数低级问题。再分享最后一个排障技巧很多异常的根因不在ACT_RU_JOB本身而在它的关联数据里。我看到定时器不触发除了查作业表还会立刻查ACT_RU_EXECUTION和ACT_HI_ACTINST确认流程实例是不是已经处于奇怪的状态。只有把执行实例、任务、作业三者结合起来看才能快速还原出完整的现场。这也符合排查任何数据库表问题的通用逻辑不要只看一张表要看数据之间的关系。