
Quartz任务调度"假死"?6大核心场景全解析,从源码到实践拯救你的调度系统
一、 故障现象特征
本文讨论的故障特指:任务到达预定触发时间点却完全不执行,且后续不再触发执行。区别于常见的任务延迟执行或重复执行,此类故障表现为任务彻底"消失"from调度队列。具体特征包括:
- 任务时间到达但不执行:系统时间已超过Cron表达式设定的触发时间,但Job未被实际执行
- 后续不再触发:任务错过一次后,后续不再尝试执行(区别于Misfire的补偿执行机制)
- 无异常日志:通常情况下Job执行逻辑无错误日志,但调度层面存在潜在问题
- 集群环境下单节点故障:在多节点集群中,某个节点的任务停止触发,而其他节点正常运行
本文将系统分析Quartz在生产环境中任务到达预定时间却完全不执行的六大核心原因:
- 线程池资源耗尽
- Misfire策略丢弃任务、job故障恢复策略未开启(requestRecovery)
- 任务状态异常(ERROR)
- 集群锁竞争死锁
- 数据库连接池耗尽
- JVM级别资源枯竭
我们将通过对Quartz源码机制、线程模型、数据库锁机制及集群原理的深入解析,提供一份足以“保命”的系统性故障诊断与解决方案。
二、 核心机制:为什么任务会“消失”?
在深入故障之前,我们必须先看透Quartz的“心脏”是如何跳动的。理解了心脏跳动的逻辑,才能明白为什么它会骤停。
2.1 线程池调度模型:SimpleThreadPool
Quartz的核心发动机是QuartzSchedulerThread。很多人以为任务到了时间就会自动弹出来,其实不然。Quartz是一个轮询拉取的模型。
默认情况下,Quartz采用SimpleThreadPool,默认只有10个工作线程。
核心源码逻辑(简化版):
// QuartzSchedulerThread.run()方法的核心循环
while (!halted.get()) {
// 1. 询问线程池:还有空闲的工人吗?
// blockForAvailableThreads() 会阻塞,直到有可用线程
int availThreadCount = qsRsrcs.getThreadPool().blockForAvailableThreads();
if (availThreadCount > 0) {
// 2. 如果有工人,去数据库(或内存)里捞一批即将触发的任务
// 注意:一次最多捞 availThreadCount 个,防止捞出来没线程跑
triggers = qsRsrcs.getJobStore().acquireNextTriggers(
now + idleWaitTime,
Math.min(availThreadCount, qsRsrcs.getMaxBatchSize()),
qsRsrcs.getBatchTimeWindow()
);
// 3. 拿到任务后,再次确认一下时间(firing)
// ... (省略部分逻辑)
// 4. 将任务封装成 Shell,扔进线程池执行
for (TriggerWrapper tw : triggers) {
JobRunShell shell = qsRsrcs.getJobRunShellFactory().createJobRunShell(bndle);
qsRsrcs.getThreadPool().runInThread(shell);
}
}
}
🩸 致命点分析:
当线程池这10个线程全部被占满(例如都在处理耗时任务)时,blockForAvailableThreads()会一直阻塞。调度器主线程停在这个方法上,根本不会去获取新的任务。
此时,时间在一秒秒流逝,原本该在10:00:00执行的任务,因为主线程被卡住,可能等到10:05:00主线程才醒过来。这时候,任务已经晚了5分钟,这就是Misfire。
2.2 Misfire机制:被系统“抛弃”的任务
当任务因为线程池阻塞而迟到,Quartz会怎么处理?这取决于Misfire策略。
重要知识点:Misfire有一个容忍阈值misfireThreshold,默认是5000毫秒(5秒)。
如果任务延迟超过5秒,Quartz就会判定为“Missed(错过)”。针对错过的任务,默认策略(SMART_POLICY)在不同场景下极其冷酷。
RAMJobStore(内存版)源码实锤:
protected boolean applyMisfire(TriggerWrapper tw) {
long misfireTime = System.currentTimeMillis();
if (getMisfireThreshold() > 0) {
misfireTime -= getMisfireThreshold(); // 当前时间 - 5秒
}
Date tnft = tw.trigger.getNextFireTime();
// 如果任务原本的触发时间 < (当前时间 - 5秒)
// 且策略不是忽略Misfire
if (tnft == null || tnft.getTime() > misfireTime
|| tw.trigger.getMisfireInstruction() == Trigger.MISFIRE_INSTRUCTION_IGNORE_MISFIRE_POLICY) {
return false;
}
// 😭 这里的 true 意味着任务被判定为 Misfire,可能被直接丢弃!
return true;
}
这意味着,如果你的线程池满了,导致任务晚了5秒才被调度器捞起来,Quartz可能直接说:“太晚了,这次别跑了,等下一次吧。”
这就是**任务“假死”**最常见的原因之一。
三、 单机环境故障:三大“隐形杀手”
3.1 杀手一:线程池资源耗尽(Thread Starvation)
这是生产环境最高频的故障。
场景复现:
你定义了一个每秒执行一次的任务(QPS=1),但你的业务逻辑写得很烂,每次执行需要休眠30秒。
- 第1秒:任务A1占用了线程1。
- ...
- 第10秒:任务A10占用了线程10。
- 第11秒:线程池空(0可用)。调度器主线程阻塞。
- 第12-40秒:新任务一直在产生,但调度器不动了。
- 第41秒:线程1终于释放。调度器醒来,发现积压了几十个任务,全部延迟超过30秒。
- 结果:触发Misfire策略,这几十个任务可能全部被丢弃!
代码级证据(问题代码):
// Job实现:执行时间30秒,但触发间隔为1秒
public class LongRunningJob implements Job {
@Override
public void execute(JobExecutionContext context) {
try {
// 模拟长耗时任务,例如调用了一个超时的第三方接口
Thread.sleep(30000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
🛠️ 解决方案:
-
扩容线程池:
不要守着默认的10个线程过日子。根据服务器性能,大胆调整。# quartz.properties org.quartz.threadPool.threadCount = 50 # 建议设置为峰值并发任务的1.5-2倍 -
业务拆分:
Job中严禁做长耗时操作! Job只负责“调度”,不负责“执行”。- ❌ 错误:在Job里从数据库读10万条数据处理。
- ✅ 正确:在Job里发一个MQ消息,或者调用一个异步Service,然后立即返回。
3.2 杀手二:Misfire策略配置不当
场景复现:
数据库备份任务,定在凌晨2点。随着数据量增长,备份时间从1小时变成了4小时。Cron表达式是0 0 2 * * ?。
如果在备份期间(线程被占用),有其他短周期的任务需要执行,它们会被阻塞。等到备份结束,短周期任务已经延迟了几小时。
如果使用默认策略,这些延迟的任务就彻底不执行了。
配置避坑指南:
-
对于允许丢失的任务(如高频数据采集):
// 错过就错过了,直接等下一次 .withMisfireHandlingInstructionDoNothing() -
对于绝不能丢失的任务(如月结报表):
// 无论晚多久,一旦有线程,必须立刻补跑 .withMisfireHandlingInstructionFireNowAndProceed()或者使用
IGNORE_MISFIRE_POLICY(所有错过的频率全部补回来,慎用,可能瞬间打爆CPU)。 -
调大容忍阈值:
给线程池一点喘息的机会。# quartz.properties # 将容忍时间从5秒增加到60秒 org.quartz.jobStore.misfireThreshold = 60000
3.3 杀手三:任务状态异常(ERROR)的永久封印
这是一个非常隐蔽的机制。当Job在实例化失败,或者抛出某些严重异常时,Quartz为了保护系统,会将该Trigger的状态在数据库中标记为ERROR。
一旦标记为ERROR,该任务永久停止触发,直到人工介入。
触发场景:
- Job类不存在:集群中部分节点未部署最新代码,导致Job找不到
- Job执行异常:Job代码中抛出未捕获异常,且未进行异常处理
- 数据不一致:数据库中的Job Data损坏,导致Job无法反序列化
- 类加载器隔离:在某些容器环境中,不同节点的类加载器配置不一致
- 依赖服务不可用:Job依赖的服务(如Redis、Kafka等)不可用,导致持续失败
- 类版本不一致:集群部署时,A节点上了新代码(Job类改名了),B节点还是旧代码。B节点抢到任务,发现找不到类,直接报错并标记Trigger为ERROR。
排查SQL:
SELECT TRIGGER_NAME, TRIGGER_STATE,FROM_UNIXTIME(PREV_FIRE_TIME/1000) 上次允许时间, FROM_UNIXTIME(NEXT_FIRE_TIME /1000) 下次运行时间
FROM QRTZ_TRIGGERS
WHERE TRIGGER_STATE = 'ERROR';
解决方案:
-
健壮的代码结构:
Job的execute方法必须包裹在try-catch中,捕获所有Exception(甚至Throwable)。不要让异常抛出到Quartz调度层。@Override public void execute(JobExecutionContext context) throws JobExecutionException { try { // 业务逻辑 } catch (Exception e) { log.error("任务执行失败,但已被捕获,不影响下次触发", e); // 只有在你确定要暂停任务时,才抛出 JobExecutionException } } -
自动恢复脚本:
编写一个定时任务,定期扫描ERROR状态的Trigger并重置,不过需谨慎,如果代码有问题即使恢复,大概率执行时还会因抛出异常状态变为ERROR)。// 伪代码:恢复ERROR任务 @Scheduled(fixedDelay = 60000) // 每分钟检查一次 public void autoRecoverErrorTriggers() { try { Scheduler scheduler = schedulerFactory.getScheduler(); Set<JobKey> jobKeys = scheduler.getJobKeys(GroupMatcher.anyGroup()); for (JobKey jobKey : jobKeys) { List<Trigger> triggers = scheduler.getTriggersOfJob(jobKey); for (Trigger trigger : triggers) { if (trigger.getState() == Trigger.State.ERROR) { log.warn("检测到ERROR状态Trigger: {},尝试恢复", trigger.getKey()); // 检查错误次数 int errorCount = getErrorCount(trigger.getKey()); if (errorCount > 3) { log.error("Trigger错误次数过多({}),跳过恢复", errorCount); continue; } // 尝试恢复 try { scheduler.resumeTrigger(trigger.getKey()); log.info("成功恢复Trigger: {}", trigger.getKey()); resetErrorCount(trigger.getKey()); } catch (SchedulerException e) { log.error("恢复Trigger失败: {}", trigger.getKey(), e); incrementErrorCount(trigger.getKey()); } } } } } catch (SchedulerException e) { log.error("自动恢复任务失败", e); } } -
手动恢复Trigger状态(需谨慎)
update qrtz_triggers set TRIGGER_STATE = 'WAITING' where TRIGGER_STATE= 'ERROR';
四、 集群环境故障:分布式锁的“死亡拥抱”
在生产环境中,我们通常开启org.quartz.jobStore.isClustered = true来实现高可用。但这引入了新的复杂性——数据库锁。
4.1 锁竞争与死锁(Deadlock)
Quartz集群保证同一时刻只有一个节点执行任务的机制,是基于数据库的行级锁(QRTZ_LOCKS表)。
源码揭秘(JobStoreSupport.executeInNonManagedTXLock):
所有的调度操作,首先都要执行:
SELECT * FROM QRTZ_LOCKS WHERE LOCK_NAME = 'TRIGGER_ACCESS' FOR UPDATE;
这行SQL会锁住Trigger的操作权。
故障场景:
当并发任务极高(例如整点秒杀,几千个任务同时触发),或者数据库性能抖动,大量节点同时发起SELECT FOR UPDATE。
- 锁等待超时:HikariCP连接池爆满,获取连接超时。
- 死锁:如果业务代码中也操作数据库,且与Quartz锁的获取顺序冲突,可能引发数据库死锁。
排查手段:
-- 查看当前正在等待锁的事务
SELECT * FROM information_schema.innodb_trx WHERE trx_state = 'LOCK WAIT';
-- 查看死锁日志
SHOW ENGINE INNODB STATUS\G;
优化建议:
不要在Quartz的JobStore事务中包含业务逻辑事务。确保org.quartz.scheduler.wrapJobExecutionInUserTransaction = false。
4.2 节点“失联”与故障恢复(Recovery)
故障机理:
Quartz集群通过QRTZ_SCHEDULER_STATE表记录调度器实例状态。如果节点状态异常(在心跳检测期间未能正常更新时间戳信息),该节点将不再参与任务调度。
触发场景:
- 节点异常退出:节点崩溃或网络分区,状态未正确更新
- 数据库连接失败:节点无法连接数据库,或者访问数据库非常慢
- 心跳超时:其他节点检测到某节点心跳超时,将其标记为
FAILED - 手动暂停节点:调用
scheduler.standby()方法将节点置于待机状态 - JVM进程 killed:进程被操作系统kill,未能正确更新状态
- 网络分区:节点与其他节点失去通信,被其他节点标记为失效
如果集群中某个节点(比如Node A)因为OOM挂了,它正在执行的任务怎么办?它未来要执行的任务怎么办?
这就涉及到了QRTZ_SCHEDULER_STATE表和requestRecovery机制。
Check-in 机制:
每个节点每隔clusterCheckinInterval(默认7.5秒)会更新这张表,告诉集群“我还活着”。
故障检测:
其他节点会定期检查这张表,如果发现Node A的LAST_CHECKIN_TIME太久没更新,就会判定A已死。
关键参数:requestRecovery
我们在定义Job时,可以设置:
JobBuilder.newJob(MyJob.class)
.requestRecovery(true) // 关键!
.build();
- 如果设为true:Node B发现Node A死了,会检测A正在执行的Job,并创建一个新的Trigger立即重新触发该Job。(前提:你的Job必须是幂等的!)
- 如果设为false(默认):Node A死的时候正在跑的任务,就彻底丢了,不会重跑。
排查节点状态SQL:
-- 查看最近一次心跳时间
SELECT INSTANCE_NAME 实例名称, SCHED_NAME 集群名称,
FROM_UNIXTIME(LAST_CHECKIN_TIME/1000) 上次登记时间,
TIMESTAMPDIFF(SECOND, FROM_UNIXTIME(LAST_CHECKIN_TIME/1000), NOW()) AS 最近一次登记时间至今间隔(秒), CHECKIN_INTERVAL 登记时间间隔(毫秒)
FROM QRTZ_SCHEDULER_STATE;
-- 查看心跳超时的节点(超过5分钟未心跳)
SELECT INSTANCE_NAME, SCHED_NAME,
TIMESTAMPDIFF(MINUTE, LAST_CHECKIN_TIME, NOW()) AS MINUTES_SINCE_CHECKIN
FROM QRTZ_SCHEDULER_STATE
WHERE TIMESTAMPDIFF(MINUTE, LAST_CHECKIN_TIME, NOW()) > 5;
实现自定义Listener处理故障转移:
public class FailoverTriggerListener implements TriggerListener {
private static final Logger log = LoggerFactory.getLogger(FailoverTriggerListener.class);
@Override
public boolean vetoJobExecution(Trigger trigger, JobExecutionContext context) {
if (context.isRecovering()) {
// 可以在这里添加其他自定义逻辑,如:发送告警、记录日志、尝试重新调度等
JobDataMap mergedJobDataMap = context.getMergedJobDataMap();
Object object = mergedJobDataMap.get(PARAM_KEY);
if (object == null) {
log.warn("恢复任务获取参数失败");
return true; // 任务参数有问题,不进行执行
}
log.info("获取到的恢复任务要传递的参数:JSON{}",
FastJsonTools.createJsonString(mergedJobDataMap));
TaskEntity taskEntity = new TaskEntity();
BeanUtils.copyProperties(object,taskEntity);
log.info("当前任务为恢复任务:{},scheduleId:{}",taskEntity.getTaskName(), taskEntity.getScheduleId());
return false; // 故障转移不拒绝执行
}
// 检查任务是否超时
Date nextFireTime = trigger.getNextFireTime();
if (nextFireTime != null) {
long now = System.currentTimeMillis();
long delay = now - nextFireTime.getTime();
// 如果任务延迟超过5分钟,可能是其他节点故障导致,也可能是因为负载过大导致任务延迟
if (delay > 5 * 60 * 1000) {
log.warn("检测到任务延迟执行,可能发生节点故障: {}, 延迟={}秒",
trigger.getKey(), delay / 1000);
// 可以在这里实现自定义的故障转移逻辑
// 如:发送告警、记录日志、尝试重新调度等
}
}
return false; // 不拒绝执行
}
@Override
public void triggerFired(Trigger trigger, JobExecutionContext context) {
// 任务开始执行时记录
log.info("任务开始执行: {}", trigger.getKey());
}
@Override
public void triggerMisfired(Trigger trigger) {
// 任务错过触发时记录
log.error("任务错过触发: {}", trigger.getKey());
}
@Override
public void triggerComplete(Trigger trigger, JobExecutionContext context, int triggerInstCode) {
// 任务执行完成时记录
log.info("任务执行完成: {}", trigger.getKey());
}
}
注册自定义Listener:
Scheduler scheduler = schedulerFactory.getScheduler();
// 创建并注册Listener
FailoverTriggerListener listener = new FailoverTriggerListener();
scheduler.getListenerManager().addTriggerListener(listener);
五、 终极杀招:自定义Listener实现“智能治理”
与其被动等待任务假死,不如主动出击。
Quartz提供了强大的TriggerListener接口。我们可以利用它实现:超时告警、并发控制、故障转移逻辑。
核心方法:
public interface TriggerListener {
// 任务是否拒绝执行(返回true拒绝执行)
boolean vetoJobExecution(Trigger trigger, JobExecutionContext context);
// 任务开始执行时调用
void triggerFired(Trigger trigger, JobExecutionContext context);
// 任务错过触发时调用
void triggerMisfired(Trigger trigger);
// 任务完成时调用
void triggerComplete(Trigger trigger, JobExecutionContext context, int triggerInstCode);
// 获取监听器名称
String getName();
}
重要说明:
- vetoJobExecution方法:此方法应快速返回,避免阻塞调度线程。长耗时任务应使用异步执行方式
- triggerFired方法:任务开始执行时调用,应快速返回,将耗时任务放到异步执行
- triggerMisfired方法:任务错过触发时调用,可以用于故障恢复和告警
- triggerComplete方法:任务完成时调用,用于记录执行结果和统计
5.1 全能型故障处理器源码
这个监听器实现了三个功能:
- 限流:防止瞬间大量任务打死下游。
- 超时监控:任务如果延迟触发超过阈值,立即报警。
- 恢复监控:感知任务是否由故障恢复触发。
public class SmartFailureHandler implements TriggerListener, JobListener {
private static final Logger log = LoggerFactory.getLogger(SmartFailureHandler.class);
// 配置参数
private final long timeoutMillis; // 超时时间
private final int maxRetries; // 最大重试次数
private final int maxConcurrent; // 最大并发数
private final Scheduler scheduler; // 调度器实例
// 故障统计
private final Map<TriggerKey, Integer> failureCounts = new ConcurrentHashMap<>();
private final Semaphore semaphore; // 并发控制
public SmartFailureHandler(Scheduler scheduler, long timeoutMillis, int maxRetries, int maxConcurrent) {
this.scheduler = scheduler;
this.timeoutMillis = timeoutMillis;
this.maxRetries = maxRetries;
this.maxConcurrent = maxConcurrent;
this.semaphore = new Semaphore(maxConcurrent);
}
// ============ TriggerListener方法 ============
@Override
public boolean vetoJobExecution(Trigger trigger, JobExecutionContext context) {
TriggerKey key = trigger.getKey();
// 1. 检查并发数
if (!semaphore.tryAcquire(1, TimeUnit.SECONDS)) {
log.warn("任务达到并发上限,拒绝执行: {}, 当前并发={}/{}",
key, maxConcurrent - semaphore.availablePermits(), maxConcurrent);
// 重新调度到下次执行
rescheduleToNext(trigger, context);
return true;
}
// 2. 检查失败次数
int failureCount = getFailureCount(key);
if (failureCount >= maxRetries) {
log.error("任务失败次数过多,停止重试: {}, 失败次数={}", key, failureCount);
sendCriticalAlert(key, failureCount);
return true;
}
// 故障转移恢复任务执行执行
if (context.isRecovering()) {
JobDataMap mergedJobDataMap = context.getMergedJobDataMap();
Object object = mergedJobDataMap.get(QuartzUtils.PARAM_KEY);
if (object == null) {
log.warn("恢复任务获取参数失败");
return true;
}
log.info("获取到的恢复任务要传递的参数:JSON{}",
FastJsonTools.createJsonString(mergedJobDataMap));
TaskEntity taskEntity = new TaskEntity();
BeanUtils.copyProperties(object,taskEntity);
log.info("当前任务为恢复任务:{},scheduleId:{}",taskEntity.getTaskName(), taskEntity.getScheduleId());
return false;
}
// 3. 检查超时
Date nextFireTime = trigger.getNextFireTime();
if (nextFireTime != null) {
long now = System.currentTimeMillis();
long delay = now - nextFireTime.getTime();
if (delay > timeoutMillis) {
log.error("任务超时,拒绝执行: {}, 延迟={}秒", key, delay / 1000);
sendTimeoutAlert(key, delay);
return true;
}
}
return false; // 允许执行
}
@Override
public void triggerFired(Trigger trigger, JobExecutionContext context) {
TriggerKey key = trigger.getKey();
log.info("任务开始执行: {}", key);
// 重置失败计数
resetFailureCount(key);
// 记录开始时间
context.put("startTime", System.currentTimeMillis());
// **重要**:不要在此处做长耗时任务,应快速返回
// 将耗时任务放到Job的execute方法中异步执行
}
@Override
public void triggerMisfired(Trigger trigger) {
TriggerKey key = trigger.getKey();
log.warn("任务错过触发: {}", key);
// 增加失败计数
incrementFailureCount(key);
// 释放并发许可
semaphore.release();
}
@Override
public void triggerComplete(Trigger trigger, JobExecutionContext context, int triggerInstCode) {
TriggerKey key = trigger.getKey();
// 计算执行时长
Long startTime = (Long) context.get("startTime");
if (startTime != null) {
long duration = System.currentTimeMillis() - startTime;
log.info("任务执行完成: {}, 耗时={}秒", key, duration / 1000);
if (duration > timeoutMillis) {
log.warn("任务执行时间过长: {}, 耗时={}秒", key, duration / 1000);
}
}
// 释放并发许可
semaphore.release();
}
// ============ JobListener方法 ============
@Override
public void jobToBeExecuted(JobExecutionContext context) {
// 任务即将执行
log.info("Job即将执行: {}", context.getJobDetail().getKey());
}
@Override
public void jobWasExecuted(JobExecutionContext context, JobExecutionException jobException) {
TriggerKey key = context.getTrigger().getKey();
if (jobException != null) {
// Job执行失败
log.error("Job执行失败: {}, 异常: {}", key, jobException.getMessage());
incrementFailureCount(key);
// 检查是否需要重试
int failureCount = getFailureCount(key);
if (failureCount < maxRetries) {
log.info("任务失败,将重试: {}, 当前失败次数={}/{}",
key, failureCount, maxRetries);
// 重新调度任务
try {
Trigger newTrigger = TriggerBuilder.newTrigger()
.withIdentity(key)
.startNow()
.withSchedule(SimpleScheduleBuilder.simpleSchedule()
.withIntervalInSeconds(60) // 1分钟后重试
.withMisfireHandlingInstructionDoNothing())
.build();
context.getScheduler().rescheduleJob(key, newTrigger);
log.info("任务已重新调度,将在1分钟后重试: {}", key);
} catch (SchedulerException e) {
log.error("重新调度任务失败", e);
}
} else {
log.error("任务失败次数已达上限,停止重试: {}", key);
sendCriticalAlert(key, failureCount);
}
} else {
// Job执行成功
log.info("Job执行成功: {}", key);
resetFailureCount(key);
}
}
@Override
public void jobExecutionVetoed(JobExecutionContext context) {
// 任务被拒绝执行
log.warn("任务被拒绝执行: {}", context.getJobDetail().getKey());
}
// ============ 辅助方法 ============
private int getFailureCount(TriggerKey key) {
return failureCounts.getOrDefault(key, 0);
}
private void incrementFailureCount(TriggerKey key) {
failureCounts.put(key, getFailureCount(key) + 1);
}
private void resetFailureCount(TriggerKey key) {
failureCounts.remove(key);
}
private void rescheduleToNext(Trigger trigger, JobExecutionContext context) {
try {
Date nextFireTime = trigger.getNextFireTime();
if (nextFireTime != null) {
Trigger newTrigger = TriggerBuilder.newTrigger()
.withIdentity(trigger.getKey())
.startAt(nextFireTime)
.withSchedule(trigger.getScheduleBuilder())
.build();
context.getScheduler().rescheduleJob(trigger.getKey(), newTrigger);
log.info("任务已重新调度到下次执行: {}, 时间={}",
trigger.getKey(), nextFireTime);
}
} catch (SchedulerException e) {
log.error("重新调度任务失败", e);
}
}
private void sendCriticalAlert(TriggerKey key, int failureCount) {
log.error("【严重告警】任务失败次数过多: {}, 失败次数: {}", key, failureCount);
// 集成企业微信、钉钉、短信、邮件等告警渠道
}
private void sendTimeoutAlert(TriggerKey key, long delay) {
log.error("【超时告警】任务执行超时: {}, 延迟: {}秒", key, delay / 1000);
// 集成告警渠道
}
@Override
public String getName() {
return "SmartFailureHandler";
}
}
注册方式:
Scheduler scheduler = schedulerFactory.getScheduler();
// 注册并关联到所有任务
scheduler.getListenerManager().addTriggerListener(new SmartFailureHandler(), EverythingMatcher.allTriggers());
六、 隐形杀手:JVM与数据库资源枯竭
有时候Quartz是无辜的,由于环境崩了,它才停摆。
6.1 数据库连接池耗尽
Quartz在集群模式下极其依赖数据库。
现象:任务不跑,日志里没有Quartz的报错,但有HikariCP的ConnectionTimeoutException。
原因:Job代码中存在连接泄漏(打开了Connection没关),或者并发量 > 连接池最大连接数。
最佳配置(HikariCP):
# 确保连接池够大!计算公式:节点数 * (线程池大小 + 3)
spring.datasource.hikari.maximum-pool-size=50
# 连接超时时间不要太长,快速失败比一直卡死好
spring.datasource.hikari.connection-timeout=30000
# 开启泄漏检测
spring.datasource.hikari.leak-detection-threshold=60000
排查方法:
-
查看线程栈:使用jstack查看线程栈,查找异常递归
jstack -l <pid> > thread_dump.log -
分析内存快照:使用jmap生成堆转储文件
jmap -dump:format=b,file=heap.hprof <pid>
6.2 JVM OOM(内存溢出)
故障机理:
当JVM内存不足(OutOfMemoryError)时,调度器线程可能被终止,导致任务调度停止。通常伴随GC频繁或直接OOM崩溃。
触发场景:
- Job内存泄漏:Job中存在未释放的资源(如集合类持续增长)
- 线程栈溢出:递归调用或过多方法调用导致栈溢出
- 堆内存配置过小:Xmx参数设置过小,无法满足业务需求
- 大量对象积压:任务执行过程中创建大量对象,未及时回收
- 静态变量泄漏:静态Map或List不断增长,未清理旧数据
如果Job中创建了大量对象(比如一次性加载所有用户列表),会导致堆内存打满,频繁Full GC。
后果:Stop-The-World时间过长,导致Quartz线程无法获得CPU时间片,不仅任务超时,甚至导致节点心跳丢失,被集群剔除。
排查命令:
# 查看GC情况
jstat -gcutil <pid> 1000
# 导出堆内存
jmap -dump:format=b,file=heap.hprof <pid>
6.3 线程数超过系统限制导致调度失败
故障机理:
当JVM中的线程数超过操作系统或JVM限制时,新线程无法创建,导致调度器无法启动新任务。
触发场景:
- 线程池过大:
org.quartz.threadPool.threadCount设置过大 - 系统限制:操作系统进程最大线程数限制(如Linux的
ulimit -u) - JVM限制:JVM参数
ThreadStackSize设置过小 - 大量守护线程:应用中创建了大量守护线程(如定时任务、异步任务)
- 线程泄漏:某些线程未正确停止,导致线程数不断增长
排查方法:
- 查看线程数:使用jps查看进程线程数
jps -q -m -l -v <pid>
- 查看系统限制:
ulimit -u # 查看用户最大进程数
- 分析线程状态:使用jstack查看线程栈,确认是否有大量
BLOCKED线程
jstack -l <pid> > thread_stack.log
grep "BLOCKED" thread_stack.log | wc -l
- 查看线程创建情况:
# 查看进程的线程数变化
watch -n 5 'ps -e -o pid,ppid,cmd,%mem,%cpu --forest | grep <pid>'
解决方案:
- 合理设置线程池大小:根据系统资源限制
org.quartz.threadPool.threadCount
# 根据系统限制,设置合理的线程池大小
org.quartz.threadPool.threadCount = 50 # 不要超过系统限制的10%
- 增加系统限制:
ulimit -u 10240 # 增加最大进程数
永久修改系统限制(Linux):
# 编辑 /etc/security/limits.conf
* soft nproc 10240
* hard nproc 20480
优化线程使用:
public class ThreadLeakJob implements Job {
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
// 错误:创建新线程不停止
new Thread(() -> {
try {
Thread.sleep(60000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
// 正确:使用线程池或确保线程正确停止
ExecutorService executor = Executors.newFixedThreadPool(10);
try {
executor.submit(() -> {
// 任务逻辑
});
} finally {
executor.shutdown(); // 确保停止
}
}
}
监控线程数变化:
// 在Job中记录线程数
public class MonitorThreadJob implements Job {
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
// 获取当前线程数
int threadCount = Thread.activeCount();
log.info("当前活跃线程数: {}", threadCount);
// 如果线程数超过阈值,发送告警
if (threadCount > 1000) {
log.warn("线程数过多: {}", threadCount);
sendAlert(threadCount);
}
}
}
七、 总结:生产环境排查SOP
当你的Quartz任务不执行时,请按以下顺序排查:
- 查状态:SQL查询
QRTZ_TRIGGERS,看是否有ERROR状态,看NEXT_FIRE_TIME是否停滞在过去。 - 查死锁:SQL查询
QRTZ_LOCKS和数据库锁等待,看是否有事务卡死。 - 查线程:
jstack查看线程堆栈,搜索QuartzSchedulerThread是否BLOCKED,搜索工作线程是否都在TIMED_WAITING(被业务阻塞)。 - 查负载:检查数据库连接数是否打满,JVM是否OOM。
- 查配置:确认
threadCount是否够用,misfireThreshold是否太小。 - 查日志:确认是否有 任务被
Clean Up,修改job的requestRecovery参数
附录:生产环境推荐配置清单 (quartz.properties)
保存这份配置,能帮你规避90%的坑。
# 参数是否使用String类型,true为必须使用string类型,false则可以使用对象进行序列化存储(但要注意实体修改后可能会引起反序列化异常)
org.quartz.jobStore.useProperties=false
# ==================== 线程池配置 ====================
# 线程池大小(默认 10)
org.quartz.threadPool.threadCount = 50
# 线程优先级(1-10,默认 5)
org.quartz.threadPool.threadPriority = 5
# 工作线程会继承创建调度器时线程的上下文类加载器
org.quartz.threadPool.threadsInheritContextClassLoaderOfInitializingThread = true
# ==================== JobStore配置 ====================
# JobStore实现类(集群模式必须使用JDBC)
org.quartz.jobStore.class = org.quartz.impl.jdbcjobstore.JobStoreTX
# 数据库驱动代理类
org.quartz.jobStore.driverDelegateClass = org.quartz.impl.jdbcjobstore.StdJDBCDelegate
# 数据库表前缀
org.quartz.jobStore.tablePrefix = QRTZ_
# 是否启用集群模式
org.quartz.jobStore.isClustered = true
# 一次处理中能够处理的最大失火触发器数量。
org.quartz.jobStore.maxMisfiresToHandleAtATime=20
# 集群实例名称
org.quartz.scheduler.instanceName = clusteredScheduler
org.quartz.scheduler.skipUpdateCheck = true
# 实例 ID(AUTO表示自动生成)
org.quartz.schedulerInstanceId = AUTO
# 自定义实例名称生成逻辑,参考之前写的公众号文章
org.quartz.scheduler.instanceIdGenerator.class=com.example.quartz.config.HostNameIpPidInstanceIdGenerator
# ==================== Misfire配置 ====================
# Misfire容忍时间(默认 5000毫秒)
org.quartz.jobStore.misfireThreshold = 60000
#调度实例失效的检查时间间隔
org.quartz.jobStore.clusterCheckinInterval=20000
org.quartz.scheduler.rmi.export = false
org.quartz.scheduler.rmi.proxy = false
org.quartz.scheduler.wrapJobExecutionInUserTransaction = false
# ==================== 其他配置 ====================
# 自定义 job 和trigger 监听
org.quartz.jobListener.NAME.class=com.example.JobListener
org.quartz.triggerListener.NAME.class=com.example.triggerListener
Q.E.D.


