☰
Quartz 定时任务 NoSuchMethodException 排查记录:一个「幽灵实例」引发的血案
2026/10/14 15:45:19 网站建设 项目流程

Quartz 定时任务 NoSuchMethodException 排查记录:一个「幽灵实例」引发的血案

问题一句话:定时任务运行报NoSuchMethodException: dailySend(),代码和sys_job配置都正确,最终定位为Quartz 集群里混入了一台跑旧代码、却一直在跳心跳的「幽灵实例」。


一、问题现象

定时任务执行时抛出如下异常:

java.lang.NoSuchMethodException:com.hnjl.inventory.core.InvWarningCronJob.dailySend()atjava.lang.Class.getMethod(Class.java:1786)atcom.hnjl.quartz.util.JobInvokeUtil.invokeMethod(JobInvokeUtil.java:60)atcom.hnjl.quartz.util.JobInvokeUtil.invokeMethod(JobInvokeUtil.java:33)atcom.hnjl.quartz.util.QuartzDisallowConcurrentExecution.doExecute(QuartzDisallowConcurrentExecution.java:19)atcom.hnjl.quartz.util.AbstractQuartzJob.execute(AbstractQuartzJob.java:43)atorg.quartz.core.JobRunShell.run(JobRunShell.java:202)...

表面含义:反射调用InvWarningCronJob.dailySend()时,找不到这个方法。


二、环境信息

项目值
框架RuoYi 3.8.8 二开(Spring Boot 2.5 + Quartz)
涉及类com.hnjl.inventory.core.InvWarningCronJob
Scheduler 名HNJLCompassScheduler
集群模式isClustered = true
实例 ID 生成instanceId = AUTO(SimpleInstanceIdGenerator)

三、排查过程

第一步:确认代码里到底有没有dailySend()方法

grep-rn"dailySend"hnjl-api/src/main/java/com/hnjl/inventory/

结果:方法存在,且是 public 无参。

@Component("invWarningCronJob")publicclassInvWarningCronJob{@ResourceprivateIInvWarningServiceinvWarningService;publicvoiddailySend(){// ← 确实存在,public、无参try{invWarningService.dailySend();}catch(Exceptione){log.error("[每天指定时间] 执行异常: {}",e.getMessage(),e);}}}

结论:代码没问题。方法名、可见性、参数列表都对得上反射调用的要求。

第二步:确认sys_job表的维护值

结论:已确认无问题,线上代码已确认是新代码。

第三步:定位「方法是谁加的」

gitlog--oneline-- hnjl-api/src/main/java/com/hnjl/inventory/core/InvWarningCronJob.javagitshow 20fa4b7ae1 -- hnjl-api/src/main/java/com/hnjl/inventory/core/InvWarningCronJob.java

结果:dailySend()是 commit20fa4b7ae1(「通知周期改造为每天指定时间 + 满足即推两种模式」)新增的。

@@ -40,4 +40,26 @@ public class InvWarningCronJob { + public void dailySend() { + invWarningService.dailySend(); + }

关键线索:dailySend()是「后来才加」的方法。如果运行中的某个 jar 还是加这个方法之前打的包,反射自然找不到。

第四步:确认是「集群」,并查集群里有多少实例

grep-rn"isClustered\|instanceId\|instanceName"hnjl-quartz/src/main/java/com/hnjl/quartz/config/ScheduleConfig.java
prop.put("org.quartz.scheduler.instanceName","HNJLCompassScheduler");prop.put("org.quartz.scheduler.instanceId","AUTO");prop.put("org.quartz.jobStore.isClustered","true");

集群实例都登记在QRTZ_SCHEDULER_STATE表,每个实例一行,周期性更新心跳。

SELECTINSTANCE_NAME,FROM_UNIXTIME(CAST(RIGHT(INSTANCE_NAME,13)ASUNSIGNED)/1000,'%Y-%m-%d %H:%i:%s')ASstart_timeFROMQRTZ_SCHEDULER_STATEWHERESCHED_NAME='HNJLCompassScheduler';

结果:

INSTANCE_NAMEstart_time
iZbp1h7way1vber0b3c1gnZ...2026-10-12 20:03:17
iZbp11gpxfapiiacintzpiZ...2026-10-12 20:05:38
izbp153j8cx9nm5c4a03kzz...2026-09-21 12:06:05

关键线索:第三台izbp153j8c...是9-21 启动、一直没重启,而dailySend()是在它启动之后才加的。这台机器极可能还加载着旧 jar。

第五步:判断第三台到底是不是「尸体」

误区:启动时间早 ≠ 已死亡。启动时间只是「什么时候启动的」,要判断存活必须看心跳时间。

SELECTINSTANCE_NAME,CHECKIN_INTERVAL,FROM_UNIXTIME(LAST_CHECKIN_TIME/1000,'%Y-%m-%d %H:%i:%s')ASlast_checkin_timeFROMQRTZ_SCHEDULER_STATEWHERESCHED_NAME='HNJLCompassScheduler'ANDINSTANCE_NAME='izbp153j8cx9nm5c4a03kzz1789963565704';

结果:

INSTANCE_NAMECHECKIN_INTERVALlast_checkin_time
izbp153j8cx9nm5c4a03kzz...150002026-10-13 09:30:59

关键结论:CHECKIN_INTERVAL = 15000ms(每 15 秒心跳一次),且「今天早上」还在跳心跳 →这台不是尸体,是活着的第三台实例。


四、根因分析

sys_job 配置 invWarningCronJob.dailySend

Quartz 集群调度

哪个实例抢到执行权

10-12 重启的两台 / 新代码

9-21 未重启的旧实例 / 旧 jar

dailySend 存在 / 正常执行

无 dailySend 方法

NoSuchMethodException

  1. dailySend()是 commit20fa4b7ae1之后才存在的。
  2. 集群里有3 个实例,其中izbp153j8c...这台9-21 启动后从未重启,加载的是旧 jar。
  3. Quartz 集群调度时,只要这台旧实例抢到dailySend这个 job 的执行权,反射调用就会NoSuchMethodException。
  4. 表象是「找不到方法」,本质是代码版本不一致+集群里混入了没纳管的旧节点。

五、解决方案

  1. 定位旧机器:

    • INSTANCE_NAME前半段izbp153j8cx9nm5c4a03kzz即主机名(阿里云 ECS 默认命名izbp...z)。
    • 在阿里云 ECS 控制台按主机名搜索定位具体服务器。
  2. 二选一处理:

    • 旧机器不该存在→ 直接下线 / 释放实例。
    • 旧机器还要用→ 重新部署含dailySend()的新包并重启,让三台代码版本一致。
  3. 处理后验证:等集群调度到dailySend不再报异常,且QRTZ_SCHEDULER_STATE中实例心跳正常。


六、常用 SQL 汇总

1. 查集群所有实例(含启动时间)

SELECTINSTANCE_NAME,FROM_UNIXTIME(CAST(RIGHT(INSTANCE_NAME,13)ASUNSIGNED)/1000,'%Y-%m-%d %H:%i:%s')ASstart_timeFROMQRTZ_SCHEDULER_STATEWHERESCHED_NAME='HNJLCompassScheduler';

2. 查实例心跳 + 存活状态

SELECTINSTANCE_NAME,CHECKIN_INTERVAL,FROM_UNIXTIME(LAST_CHECKIN_TIME/1000,'%Y-%m-%d %H:%i:%s')ASlast_checkin_time,ROUND(UNIX_TIMESTAMP(NOW())-LAST_CHECKIN_TIME/1000)ASseconds_since_checkinFROMQRTZ_SCHEDULER_STATEWHERESCHED_NAME='HNJLCompassScheduler';-- seconds_since_checkin < 30 表示此刻还活着

3. 查某个实例触发过的 job(含孤儿 fired 记录)

SELECTTRIGGER_NAME,TRIGGER_GROUP,JOB_NAME,FROM_UNIXTIME(FIRED_TIME/1000,'%Y-%m-%d %H:%i:%s')ASfired_time,STATEFROMQRTZ_FIRED_TRIGGERSWHERESCHED_NAME='HNJLCompassScheduler'ANDINSTANCE_NAME='izbp153j8cx9nm5c4a03kzz1789963565704'ORDERBYFIRED_TIMEDESC;

4. 删除确认死亡后的残留实例

DELETEFROMQRTZ_SCHEDULER_STATEWHERESCHED_NAME='HNJLCompassScheduler'ANDINSTANCE_NAME='izbp153j8cx9nm5c4a03kzz1789963565704';

七、知识点沉淀

1.QRTZ_SCHEDULER_STATE表字段

字段含义
SCHED_NAMEscheduler 名
INSTANCE_NAME实例名(AUTO 模式 = 主机名 + 启动毫秒时间戳)
LAST_CHECKIN_TIME最后心跳时间(毫秒)
CHECKIN_INTERVAL心跳间隔(毫秒)

2. Quartz 集群的几个「坑」

  • 实例停机不会删行:QRTZ_SCHEDULER_STATE记录永久残留,集群靠「心跳超时」判定死亡,而非删行。
  • 启动时间 ≠ 存活状态:判断存活必须看LAST_CHECKIN_TIME(心跳),不是看启动时间。
  • 启动时间藏在实例名里:instanceId=AUTO时,INSTANCE_NAME = 主机名 + 启动毫秒时间戳,末尾 13 位数字反解即可得启动时间(主机名以数字结尾时不准确)。
  • 时间戳是毫秒:LAST_CHECKIN_TIME、FIRED_TIME、SCHED_TIME都是毫秒,格式化前先/ 1000。
  • QRTZ_FIRED_TRIGGERS是临时表:job 跑完会删行;留下孤儿记录说明实例是「执行到一半死掉」的。

3. RuoYi 反射调用任务的关键逻辑

JobInvokeUtil.invokeMethod无参分支:

Methodmethod=bean.getClass().getMethod(methodName);method.invoke(bean);

NoSuchMethodException的类名是裸类名(无 CGLIB 代理后缀),说明bean 解析正常、分支正确,纯粹是「加载到的类里没有该方法」——即运行环境跑的是旧代码。


八、总结

这次排查最大的教训是:「代码有、配置对」不代表「运行环境有」。

集群场景下,NoSuchMethodException这类「本不该出现的反射错误」,十有八九不是代码写错,而是集群里有节点跑着旧代码。排查顺序建议:

  1. 先确认代码和sys_job配置正确;
  2. 用git log/show确认该方法「是何时加的」;
  3. 查QRTZ_SCHEDULER_STATE看集群实例数和各自启动时间;
  4. 用LAST_CHECKIN_TIME判断存活,而非启动时间;
  5. 找出「启动时间早于方法提交时间」且「还在跳心跳」的实例——那就是罪魁祸首。

文档整理时间:2026-10-13

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询