1. 为什么要在Spring项目里引入Quartz
很多做Java后端的朋友,第一次接触定时任务,基本都是靠@Scheduled注解起步的。写个方法,加个注解,配个cron表达式,跑起来确实爽。但项目一旦上了规模,问题就来了:任务要持久化、要能动态改执行时间、要支持集群部署避免重复执行、要能手动暂停和恢复某个任务。这时候@Scheduled就明显不够用了,它本质上是个轻量级的本地调度器,任务信息全在内存里,重启就丢,多实例部署还会重复跑。
Quartz就是来解决这些问题的。它是一个功能完整、成熟稳定的开源作业调度框架,核心能力包括:任务持久化到数据库、支持集群模式下的故障转移和负载均衡、支持动态增删改任务、支持复杂的触发规则(cron表达式、简单间隔、日历排除等)。而Spring对Quartz做了深度集成,让我们可以用Spring的依赖注入方式去管理Job实例,不用去碰Quartz那套原始的SchedulerFactoryBean配置。
这套组合适合谁呢?如果你正在做订单超时关闭、定时对账、数据同步、报表生成、缓存刷新这类需求,并且对任务的可靠性、可管理性有要求,那Quartz加Spring就是一条很稳的路。我下面会从整体设计思路讲到具体落地,包括数据库表怎么建、Job怎么注入Spring的Bean、集群怎么配、踩过哪些坑,尽量把每个关键决策背后的原因说清楚。
2. 整体设计思路与方案选型
2.1 为什么不用@Scheduled而选Quartz
先把这个事说透。@Scheduled的底层是Spring自己的TaskScheduler,默认用单线程池,任务信息不落库,应用重启后调度状态全部丢失。它适合什么场景?适合那些“跑不跑无所谓、跑晚了也没关系”的辅助性任务,比如定期清理临时文件、刷新本地缓存。
但业务型定时任务不一样。举个例子,订单创建后30分钟未支付要自动关闭。这个任务必须可靠:应用重启了,30分钟的计时不能丢;部署了3个实例,不能每个实例都去关同一个订单。@Scheduled在这两个点上都是短板。Quartz把Trigger和JobDetail存到数据库,重启后从库里恢复调度状态;集群模式下通过数据库行锁保证同一个Trigger只有一个实例能抢到执行权。这是本质区别。
2.2 Spring集成Quartz的两种方式
Spring集成Quartz主要有两种路子。第一种是早期Spring 3.x时代常用的MethodInvokingJobDetailFactoryBean,它允许你把任意一个Spring Bean的方法直接包装成Quartz Job,不用写Job类。第二种是标准的JobDetailFactoryBean配合自定义Job类,Job类里通过@Autowired注入需要的服务。
我推荐第二种。原因很简单:第一种方式虽然省事,但Job实例不是Spring管理的,你没法在Job里直接注入Service,而且它默认不支持并发控制(可以通过concurrent属性设置,但不够灵活)。第二种方式下,Job类本身是个Spring Bean,可以正常注入依赖,逻辑写起来清爽得多。下面所有的实操都基于第二种方式展开。
2.3 持久化与集群的核心机制
Quartz持久化的核心是11张数据库表(不同版本略有差异),其中最关键的是QRTZ_JOB_DETAILS(存Job定义)、QRTZ_TRIGGERS(存触发器)、QRTZ_CRON_TRIGGERS(存cron表达式)、QRTZ_LOCKS(集群锁表)。集群模式下,每个Quartz实例启动时会先抢QRTZ_LOCKS里的TRIGGER_ACCESS行锁,抢到的实例才能触发任务,这样就避免了多实例重复执行。
这里有个关键参数叫org.quartz.jobStore.clusterCheckinInterval,默认15000毫秒,意思是每个实例每隔15秒向数据库“报到”一次。如果某个实例超过这个时间没报到,其他实例会认为它挂了,把它正在执行的任务接管过来。这个值设太小会增加数据库压力,设太大故障转移会变慢,一般保持默认或者根据任务紧急程度调到10秒左右。
3. 核心细节解析与实操要点
3.1 依赖引入与版本选择
Spring Boot项目里集成Quartz,最省事的方式是直接用spring-boot-starter-quartz。它自动帮你配好了SchedulerFactoryBean、数据源关联、事务管理等。但要注意版本匹配问题。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-quartz</artifactId> </dependency>如果你用的是Spring Boot 2.x,它默认带的Quartz版本是2.3.x,这个版本足够稳定。Spring Boot 3.x则对应Quartz 2.3.2以上。不建议自己去覆盖Quartz版本,除非有明确的bug修复需求,因为Spring Boot的自动配置对特定版本做过适配。
注意:如果你项目里同时引入了
spring-boot-starter-data-jpa或mybatis,数据源会被Quartz复用。这时候要确保Quartz用的数据源和业务数据源是同一个,否则集群锁会失效。
3.2 数据库表的初始化
Quartz的建表SQL在官方发布包的docs/dbTables目录下,按数据库类型分好了。MySQL用tables_mysql_innodb.sql,这个版本支持行锁,集群模式必须用InnoDB引擎的版本,不能用MyISAM的。
建表时有个细节容易被忽略:QRTZ_LOCKS表需要预先插入两条记录,分别是TRIGGER_ACCESS和STATE_ACCESS。有些版本的SQL脚本里带了insert语句,有些没带,如果没带要手动补上,否则集群启动时会报错找不到锁记录。
INSERT INTO QRTZ_LOCKS VALUES('TRIGGER_ACCESS'); INSERT INTO QRTZ_LOCKS VALUES('STATE_ACCESS');表名默认是大写的,在Linux环境下MySQL默认区分大小写,所以建表后不要随意改表名大小写,否则Quartz会找不到表。
3.3 Job类的编写规范
自定义Job类需要实现org.quartz.Job接口,在execute方法里写业务逻辑。但这里有个坑:Quartz默认每次执行都会new一个Job实例,所以你不能在Job类里用成员变量存状态。如果需要传参数,通过JobDataMap传。
@Component public class OrderTimeoutJob implements Job { @Autowired private OrderService orderService; @Override public void execute(JobExecutionContext context) throws JobExecutionException { JobDataMap dataMap = context.getMergedJobDataMap(); String orderType = dataMap.getString("orderType"); orderService.closeTimeoutOrders(orderType); } }注意@Component注解不能少,否则Spring容器里没有这个Bean,JobDetailFactoryBean注入时会报找不到。另外,JobDataMap里存的对象如果是自定义类,必须实现Serializable接口,因为持久化模式下它会被序列化到数据库的JOB_DATA字段里。
实操心得:如果Job执行时间可能超过Trigger的下次触发时间,一定要在Job类上加
@DisallowConcurrentExecution注解。否则同一个JobDefinition会并发执行,可能导致数据重复处理。这个注解的作用是让Quartz等上一次执行完再触发下一次。
3.4 Trigger的配置要点
Trigger分两种:SimpleTrigger用于固定间隔执行,CronTrigger用于cron表达式。业务场景里cron用得更多。配置CronTrigger时,cron表达式的格式是秒 分 时 日 月 周 年(可选),注意和Linux的crontab不一样,Quartz的秒是必填的。
@Bean public JobDetail orderTimeoutJobDetail() { return JobBuilder.newJob(OrderTimeoutJob.class) .withIdentity("orderTimeoutJob", "orderGroup") .usingJobData("orderType", "NORMAL") .storeDurably() .build(); } @Bean public Trigger orderTimeoutTrigger() { return TriggerBuilder.newTrigger() .forJob(orderTimeoutJobDetail()) .withIdentity("orderTimeoutTrigger", "orderGroup") .withSchedule(CronScheduleBuilder.cronSchedule("0 0/5 * * * ?") .withMisfireHandlingInstructionDoNothing()) .build(); }withMisfireHandlingInstructionDoNothing()这个设置很关键。它定义了“错过触发”时的处理策略。比如应用停了2小时,恢复后有24个触发点已经错过了,这个策略表示全部忽略,只从下一个正常触发点开始执行。如果不设,默认策略是MISFIRE_INSTRUCTION_SMART_POLICY,Quartz会根据情况自己判断,有时候会一次性补跑所有错过的任务,把系统打爆。
4. 完整实操流程与核心环节实现
4.1 项目结构与配置
一个典型的Spring Boot加Quartz的项目结构是这样的:config包放Quartz配置类,job包放Job实现类,service包放业务逻辑,resources下放application.yml和建表SQL。
application.yml里的核心配置:
spring: quartz: job-store-type: jdbc jdbc: initialize-schema: never properties: org: quartz: scheduler: instanceName: MyScheduler instanceId: AUTO jobStore: class: org.quartz.impl.jdbcjobstore.JobStoreTX driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate tablePrefix: QRTZ_ isClustered: true clusterCheckinInterval: 10000 useProperties: false threadPool: class: org.quartz.simpl.SimpleThreadPool threadCount: 10 threadPriority: 5instanceId: AUTO是集群模式的关键,它让每个实例自动生成唯一ID(通常是主机名加时间戳)。isClustered: true开启集群。threadCount是执行线程池大小,默认10,如果任务多且执行时间长,要适当调大,但别超过数据库连接池上限。
initialize-schema: never表示不自动建表,因为生产环境通常由DBA手动执行SQL。开发环境可以设成always,但要注意它每次启动都会尝试建表,表已存在时会报错但不会影响运行。
4.2 动态管理任务的实现
静态配置的Job在应用启动时就固定了,但业务上经常需要动态添加、修改、删除任务。这就需要注入Scheduler实例来操作。
@Service public class QuartzManageService { @Autowired private Scheduler scheduler; public void addJob(String jobName, String group, String cron, Class<? extends Job> jobClass, Map<String, Object> params) throws SchedulerException { JobDetail jobDetail = JobBuilder.newJob(jobClass) .withIdentity(jobName, group) .usingJobData(new JobDataMap(params)) .storeDurably() .build(); CronTrigger trigger = TriggerBuilder.newTrigger() .withIdentity(jobName + "Trigger", group) .forJob(jobDetail) .withSchedule(CronScheduleBuilder.cronSchedule(cron) .withMisfireHandlingInstructionDoNothing()) .build(); if (scheduler.checkExists(jobDetail.getKey())) { scheduler.deleteJob(jobDetail.getKey()); } scheduler.scheduleJob(jobDetail, trigger); } public void pauseJob(String jobName, String group) throws SchedulerException { scheduler.pauseJob(JobKey.jobKey(jobName, group)); } public void resumeJob(String jobName, String group) throws SchedulerException { scheduler.resumeJob(JobKey.jobKey(jobName, group)); } public void deleteJob(String jobName, String group) throws SchedulerException { scheduler.deleteJob(JobKey.jobKey(jobName, group)); } }这里有个细节:scheduler.scheduleJob在集群环境下,操作的是数据库里的记录,其他实例会通过数据库感知到变化。但感知有延迟,默认是clusterCheckinInterval的时间。如果业务要求实时性高,可以在修改后手动调用scheduler.triggerJob立即触发一次。
注意:动态添加的Job如果用了
@Autowired注入的Service,必须确保Job类被Spring管理。如果Job类是通过反射动态加载的(比如从数据库读类名),那它不在Spring容器里,注入会失败。解决办法是用SpringBeanJobFactory的自定义子类,在Job实例化时手动做依赖注入。
4.3 集群部署的验证方法
集群配置对不对,不能只看日志说启动成功。要验证两点:一是任务是否只执行一次,二是故障转移是否生效。
验证方法:部署两个实例,配一个每10秒执行一次的Job,在Job里打印当前实例ID和线程名。观察日志,应该看到两个实例交替执行,但同一个触发点只有一个实例执行。然后手动kill掉一个实例,等clusterCheckinInterval时间过后,另一个实例应该接管所有任务继续执行。
@Override public void execute(JobExecutionContext context) { String instanceId = context.getScheduler().getSchedulerInstanceId(); System.out.println("任务执行,实例ID:" + instanceId + ",时间:" + new Date()); }如果发现两个实例都在执行同一个触发点,检查QRTZ_LOCKS表里是否有TRIGGER_ACCESS记录,以及数据库用户是否有该表的写权限。另一个常见原因是isClustered没设成true,或者两个实例连的不是同一个数据库。
4.4 任务执行日志与监控
Quartz本身不提供执行历史查询,QRTZ_FIRED_TRIGGERS表只记录正在执行的任务,执行完就删了。要查历史,得自己建表记录。我通常会在Job的execute方法里加一段统一的日志记录逻辑,或者用Quartz的JobListener。
public class JobLogListener implements JobListener { @Override public String getName() { return "JobLogListener"; } @Override public void jobToBeExecuted(JobExecutionContext context) { // 记录开始时间、任务名、实例ID } @Override public void jobWasExecuted(JobExecutionContext context, JobExecutionException jobException) { // 记录结束时间、执行耗时、是否异常 } }Listener需要在配置类里注册到Scheduler上。这样所有Job的执行都会经过Listener,统一记录日志,不用在每个Job里重复写。
5. 常见问题与排查技巧实录
5.1 任务不执行或重复执行
这是最高频的问题。排查顺序如下:先看QRTZ_TRIGGERS表里TRIGGER_STATE字段的值,WAITING表示正常等待,PAUSED表示被暂停,ERROR表示出错了。如果是ERROR,看QRTZ_SCHEDULER_STATE表里实例状态是否正常。
重复执行的话,九成是集群配置问题。检查三个点:isClustered是否为true、instanceId是否为AUTO、数据库是否同一个。还有一个隐蔽的原因:如果用了@DisallowConcurrentExecution但Job类被Spring代理了(比如加了@Transactional),Quartz拿到的可能是代理类,注解可能不生效。解决办法是把@DisallowConcurrentExecution加在实现类上,或者确保代理模式是CGLIB。
5.2 数据库连接池耗尽
Quartz默认的线程池是10个线程,每个线程执行任务时如果涉及数据库操作,会占用业务连接池的连接。如果任务执行时间长、并发高,可能把连接池占满。表现是业务接口超时,日志里出现获取连接超时。
解决办法有两个:一是把Quartz的threadCount调小,比如设成5,限制并发;二是给Quartz配独立的数据源,和业务数据源隔离。第二种更彻底,但配置稍复杂,需要手动定义SchedulerFactoryBean并指定dataSource。
5.3 misfire策略选错导致任务堆积
前面提过withMisfireHandlingInstructionDoNothing(),但有些场景需要补跑。比如每天凌晨的对账任务,如果因为停机错过了,恢复后应该补跑一次。这时候要用withMisfireHandlingInstructionFireAndProceed(),它会在恢复后立即触发一次,然后按正常节奏走。
选错策略的后果:用DoNothing,该跑的任务没跑,数据不一致;用FireAndProceed但任务本身不支持重复执行,可能造成数据重复。所以策略选择要和业务语义匹配,不能一刀切。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 任务完全不执行 | Trigger状态为PAUSED或ERROR | 查QRTZ_TRIGGERS表 | 恢复Trigger或检查cron表达式 |
| 多实例重复执行 | 集群配置未生效 | 检查isClustered和instanceId | 改为true和AUTO |
| 任务执行时间漂移 | 线程池满或任务耗时长 | 看线程池活跃数和任务耗时 | 调大threadCount或优化任务 |
| 重启后任务丢失 | 未用JDBC存储 | 检查job-store-type | 改为jdbc并建表 |
| 动态修改不生效 | 其他实例未感知 | 等待checkin间隔 | 调小clusterCheckinInterval |
避坑技巧:生产环境上线前,一定要在预发环境模拟一次“杀实例”操作,验证故障转移。我见过太多项目集群配置写对了,但数据库权限没给全,导致锁表写不进去,集群实际没生效,上线后才发现任务重复跑。
6. 性能调优与扩展思路
6.1 线程池与数据库的平衡
Quartz的threadCount不是越大越好。每个执行线程在执行期间可能持有数据库连接,如果threadCount大于连接池最大连接数,会出现线程等连接的情况。一般建议threadCount设为连接池最大连接数的60%到70%。比如连接池最大20,Quartz线程池设12到14比较合适。
另外,org.quartz.scheduler.batchTriggerAcquisitionMaxCount这个参数控制一次从数据库取多少个待触发Trigger,默认1。在高并发场景下可以调到10到20,减少数据库交互次数。但调太大可能导致单个实例抢走太多任务,其他实例空闲,所以集群环境下要谨慎。
6.2 任务分片与并行处理
单个Job执行慢怎么办?如果任务本身可以拆分,比如处理100万条数据,可以拆成10个Job并行跑,每个处理10万条。Quartz本身不提供分片能力,但可以通过JobDataMap传入分片参数,配合多个Trigger实现。
另一种思路是用Quartz触发一个“调度Job”,这个Job只负责把大任务拆成小任务丢到消息队列或线程池里异步执行,自己快速返回。这样Quartz的线程不会被长时间占用,吞吐量更高。但要注意,这种方式下Quartz不保证子任务的执行状态,需要自己做补偿和重试。
6.3 与消息队列的配合
Quartz适合做“定时触发”,消息队列适合做“异步解耦”。两者结合的模式是:Quartz到点触发一个Job,Job只负责发一条消息到MQ,真正的业务逻辑由消费者处理。这样做的好处是Quartz的Job执行时间极短,不会占用调度线程,而且MQ自带重试和死信机制,可靠性更高。
这种模式下,Quartz的misfire策略可以设成DoNothing,因为即使漏了一次触发,下次触发时MQ里可能还有未处理的消息,不会造成数据丢失。但要注意消息的幂等性,因为MQ可能重复投递。
6.4 监控告警的接入
Quartz的Scheduler接口提供了getCurrentlyExecutingJobs()方法,可以拿到当前正在执行的任务列表。可以写一个监控接口,定期采集这些数据,推送到监控系统。关键指标包括:当前执行任务数、线程池活跃线程数、Trigger下次触发时间、misfire次数。
告警规则可以设:任务连续失败3次告警、任务执行时间超过阈值告警、Trigger超过预期时间未触发告警。这些数据Quartz本身不存,需要自己在JobListener里埋点采集。
我个人在实际操作中的体会是,Quartz加Spring这套组合,配置本身不难,难的是对集群行为和misfire策略的理解。很多问题不是代码写错了,而是对Quartz的机制理解不到位。建议在开发环境就把集群模式打开,用两个实例跑一段时间,观察数据库表的变化,比看文档理解得快得多。另外,动态管理任务的接口一定要加权限控制,否则谁都能改cron表达式,把核心任务改成每秒执行一次,数据库很快就扛不住了。