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.javaprop.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_NAME | start_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_NAME | CHECKIN_INTERVAL | last_checkin_time |
|---|---|---|
izbp153j8cx9nm5c4a03kzz... | 15000 | 2026-10-13 09:30:59 |
关键结论:
CHECKIN_INTERVAL = 15000ms(每 15 秒心跳一次),且「今天早上」还在跳心跳 →这台不是尸体,是活着的第三台实例。
四、根因分析
dailySend()是 commit20fa4b7ae1之后才存在的。- 集群里有3 个实例,其中
izbp153j8c...这台9-21 启动后从未重启,加载的是旧 jar。 - Quartz 集群调度时,只要这台旧实例抢到
dailySend这个 job 的执行权,反射调用就会NoSuchMethodException。 - 表象是「找不到方法」,本质是代码版本不一致+集群里混入了没纳管的旧节点。
五、解决方案
定位旧机器:
INSTANCE_NAME前半段izbp153j8cx9nm5c4a03kzz即主机名(阿里云 ECS 默认命名izbp...z)。- 在阿里云 ECS 控制台按主机名搜索定位具体服务器。
二选一处理:
- 旧机器不该存在→ 直接下线 / 释放实例。
- 旧机器还要用→ 重新部署含
dailySend()的新包并重启,让三台代码版本一致。
处理后验证:等集群调度到
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_NAME | scheduler 名 |
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这类「本不该出现的反射错误」,十有八九不是代码写错,而是集群里有节点跑着旧代码。排查顺序建议:
- 先确认代码和
sys_job配置正确; - 用
git log/show确认该方法「是何时加的」; - 查
QRTZ_SCHEDULER_STATE看集群实例数和各自启动时间; - 用
LAST_CHECKIN_TIME判断存活,而非启动时间; - 找出「启动时间早于方法提交时间」且「还在跳心跳」的实例——那就是罪魁祸首。
文档整理时间:2026-10-13