
【导语】
你以为 scheduler.start() 就万事大吉了?在单机环境跑得欢快的 Quartz,一上生产集群就“发疯”:任务重复跑、数据库死锁、服务器重启后任务“暴走”... 今天结合实战经验,盘点 Quartz 最容易“翻车”的 5 个场景,每一个都是用加班换来的血泪教训。
🛑 场景一:集群环境下的“双重暴击”——任务重复执行
【案发现场】
项目上线,为了高可用部署了两台服务器。结果运营跑来投诉:“怎么用户收到了两条一样的营销短信?”
一看日志,好家伙,两台服务器在同一时间都执行了同一个 Job。
【原因分析】
默认情况下,Quartz 是不知道其他节点存在的。如果你的两台机器都配置了完全相同的 Job 和 Trigger,并且没有开启集群模式,它们就是各自为政,导致任务双倍执行。
【避坑指南】
必须开启 Quartz 的集群模式(Clustering),通过数据库锁机制来协调谁来执行任务。
核心配置 (quartz.properties):
# 开启集群模式
org.quartz.jobStore.isClustered = true
# 实例ID自动生成,保证唯一
org.quartz.scheduler.instanceId = AUTO
# 使用数据库存储(RAMJobStore 不支持集群)
org.quartz.jobStore.class = org.springframework.scheduling.quartz.LocalDataSourceJobStore
org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.StdJDBCDelegate
注意:一定要保证多台服务器时钟同步(NTP),否则集群争抢会出大问题!
🛑 场景二:长耗时任务的“多米诺骨牌”——并发重叠
【案发现场】
设置了一个每 5 分钟执行一次的数据同步任务。随着数据量增大,跑完一次需要 8 分钟。
结果:第一个任务还没跑完,第二个任务在第 5 分钟准时启动了。数据处理逻辑发生冲突,导致脏数据产生,甚至撑爆线程池。
【原因分析】
Quartz 默认是无状态的。它不管你上一次任务有没有跑完,到了时间就会触发下一次。
【避坑指南】
给你的 Job 类加上 @DisallowConcurrentExecution 注解。
代码示例:
import org.quartz.DisallowConcurrentExecution;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
@DisallowConcurrentExecution // 核心:禁止并发执行
public class SyncDataJob implements Job {
@Override
public void execute(JobExecutionContext context) {
// 你的业务逻辑
// 如果上次没跑完,这次触发会被跳过,直到上次执行结束
}
}
补充:如果还需要持久化 JobDataMap 的变更,记得加上 @PersistJobDataAfterExecution。
🛑 场景三:服务器重启后的“洪水猛兽”——Misfire 策略
【案发现场】
由于机房断电,服务器宕机了 2 小时。重启后,Quartz 发现过去 2 小时内有 100 个任务没执行,于是它决定瞬间全部执行。
结果:瞬间巨大的流量打崩了下游微服务,数据库 CPU 飙升到 100%。
【原因分析】
这是 Quartz 的 Misfire(失火/错失) 机制。默认策略通常是“立即执行错过的任务”。
【避坑指南】
根据业务场景,显式配置 Misfire 策略。对于周期性任务,通常我们希望“错过了就错过了,等下一次吧”。
CronTrigger 配置示例:
CronTrigger trigger = TriggerBuilder.newTrigger()
.withIdentity("trigger1", "group1")
.withSchedule(CronScheduleBuilder.cronSchedule("0 0/5 * * * ?")
// 关键点:设置 Misfire 策略
// DoNothing: 错过了就不管了,等待下一次正常触发
.withMisfireHandlingInstructionDoNothing())
.build();
注意:不同的 Trigger 类型(SimpleTrigger vs CronTrigger)支持的策略不同,务必查阅文档。
🛑 场景四:JobDataMap 的“序列化陷阱”——版本不兼容
【案发现场】
开发时为了省事,直接把一个复杂的 DTO 对象塞进了 JobDataMap。
几个月后,对这个 DTO 类加了个字段,发布上线。
结果:Quartz 任务全部启动失败,报错 SerializationException。
【原因分析】
当使用 JDBC 存储时,JobDataMap 中的对象会被序列化成 BLOB 存入数据库。当代码更新导致类结构变化(SerialVersionUID 改变),反序列化就会失败。
【避坑指南】
绝对不要在 JobDataMap 中直接存储复杂的 Java 对象!
只存储基本数据类型(String, Long, Integer)。如果是复杂对象,请转成 JSON 字符串存储。
正确姿势:
// 存
String json = JSON.toJSONString(myDto);
jobDetail.getJobDataMap().put("config", json);
// 取
String json = context.getJobDetail().getJobDataMap().getString("config");
MyDto myDto = JSON.parseObject(json, MyDto.class);
🛑 场景五:大规模任务下的“死锁泥潭”——数据库瓶颈
【案发现场】
业务扩展,系统里有上万个 Trigger。某天监控报警,数据库出现大量死锁(Deadlock),Quartz 线程全部阻塞,任务不再触发。
【原因分析】
Quartz 集群依赖数据库行锁(QRTZ_LOCKS 表)来争抢 Trigger。当大量 Trigger 在同一秒触发(例如整点报时),多个节点同时争抢行锁,极易引发数据库死锁或超时。
【避坑指南】
- 优化线程池:不要盲目调大线程池,根据 DB 承载能力调整。
- 批处理获取:减少获取锁的频率。
- 时间散列:避免所有任务都定在
00:00:00执行,尽量打散秒数(如00:00:13)。
优化配置 (quartz.properties):
# 增加每次获取 Trigger 的数量(默认是 1)
org.quartz.scheduler.batchTriggerAcquisitionMaxCount = 30
# 增加由于获取 Trigger 而持有的数据库连接时间窗口
org.quartz.scheduler.batchTriggerAcquisitionFireAheadTimeWindow = 0
# 关键:开启使用更优的锁机制(针对 JDBCJobStore)
org.quartz.jobStore.acquireTriggersWithinLock = true
🛑 场景六:幽灵般的“消失”——单任务无限阻塞
【案发现场】
运营反馈:“每天凌晨 1 点的《xxx任务》今天没跑!我看后台其他每分钟的任务都在正常跑,系统没挂啊?”
去数据库查 QRTZ_TRIGGERS 表,发现这个任务的状态竟然一直是 BLOCKED 或 ACQUIRED,而上一条执行记录甚至还在两天前。
【原因分析】
这通常发生在配置了 @DisallowConcurrentExecution(禁止并发)的任务上。
Quartz 的机制是:如果上一次任务没跑完,下一次就不会触发。
真相是: 上一次执行的任务线程卡死了(例如:HTTP 请求没设置超时时间、数据库死锁等待、Socket Read 阻塞)。Quartz 认为任务还在运行中,所以后续的调度全部被“合法”拦截了。任务就这样“幽灵般”地消失了。
【避坑指南】
- 代码层面必须设置超时:所有的网络请求(HTTP/RPC)和数据库操作,必须显式设置
ConnectTimeout和ReadTimeout。永远不要相信默认值! - 防御性编程:
在execute方法中,使用try-catch-finally确保异常被捕获。 - 运维救火:
如果已经发生了,需要手动修改数据库,将该 Trigger 的状态从BLOCKED/ERROR强制改为WAITING,或者重启服务(如果线程卡死在 JVM 堆栈中,重启通常能重置状态,但要小心业务中断)。
代码警示:
// ❌ 错误示范:无限等待
restTemplate.getForObject("http://slow-service/api", String.class);
// ✅ 正确示范:设置刚性超时
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5000); // 5秒连接超时
factory.setReadTimeout(10000); // 10秒读取超时
🛑 场景七:全线崩溃——线程池耗尽引发的“植物人”状态
【案发现场】
凌晨 2 点,监控报警:系统吞吐量归零。登录服务器发现进程还在,CPU 占用率极低,日志不再滚动。所有的 Quartz 任务(几百个)全部停止触发,仿佛整个调度器“脑死亡”了。
【原因分析】
这是典型的 Worker 线程池耗尽。
默认情况下,Quartz 的 org.quartz.threadPool.threadCount 只有 10 个。
假设你有 100 个任务,其中有 10 个任务需要调用外部接口,恰好外部接口挂了且响应非常慢(比如 60 秒超时)。
瞬间,这 10 个线程全部被这 10 个慢任务占满。Quartz 就没有空闲线程去调度剩下的 90 个任务了,导致整个调度器假死。
【避坑指南】
- 合理评估线程数:根据任务量和任务类型(IO密集型 vs CPU密集型)调整线程池大小。
# 配置文件 quartz.properties org.quartz.threadPool.threadCount = 50 - 任务异步化(核心解法):
Quartz 的 Job 逻辑应该极度轻量。不要在 Job 里直接跑长耗时业务!
最佳实践:Quartz Job 只负责“发令”,将具体的业务逻辑扔到业务侧的线程池(如ThreadPoolTaskExecutor)或消息队列(MQ)中执行。哪怕业务线程池满了,Quartz 的调度线程也能立马释放,去触发下一个任务。
架构优化图解:
- Before: Quartz Thread -> DB Query (5s) -> HTTP (10s) -> Finish. (占用 Quartz 线程 15s)
- After: Quartz Thread -> Send MQ Message -> Finish. (占用 Quartz 线程 0.01s) -> 消费者处理业务。
【总结】
Quartz 虽然强大,但绝不是“开箱即用”的傻瓜式组件。理解其集群原理、状态机制、序列化方式,是写出稳定调度系统的关键。
最后问一句:你们现在的项目里,用的是 Quartz 还是 XXL-JOB?欢迎在评论区留言 battle!
Q.E.D.


