Spring Boot:整合Quartz集群部署指南

在处理定时任务(如日终跑批、数据同步、报表生成)时,我们经常会面临一个痛点:在微服务或多节点集群环境下,传统的 @Scheduled 注解会导致同一个任务在所有节点上重复执行。

为了解决这个问题,Quartz 的集群模式(Cluster Mode) 成为了 Java 生态中最经典、最可靠的分布式调度解决方案之一。


一、 Quartz 集群模式核心原理

在单机环境下,Quartz 将调度信息存储在内存中(RAMJobStore)。但在集群模式下,Quartz 采用了 "以数据库为中心的分布式协同" 架构。

Quartz Github仓库

1. 共享数据库与分布式锁

集群中的所有 Quartz 节点彼此之间不直接通信 (没有 RPC 或心跳网络),而是通过共享同一个数据库 (JobStoreTX)来感知彼此。

  • 当触发器(Trigger)到达执行时间时,各个节点会去数据库"抢锁"。
  • Quartz 利用数据库的行级悲观锁 (QRTZ_LOCKS 表)来保证同一时刻、同一个 Trigger 只能被一个节点成功获取并执行。

2. 心跳机制与故障转移(Failover)

  • 心跳上报 :每个节点会定期(默认 15 秒)向 QRTZ_SCHEDULER_STATE 表写入自己的心跳时间戳。
  • 故障接管 :如果节点 A 宕机,超过一定时间没有更新心跳,节点 B 在扫描 Trigger 时会发现节点 A 已经"失联"。如果该任务配置了 @PersistJobDataAfterExecution 或请求了恢复(requestsRecovery=true),节点 B 会接管并重新执行节点 A 未完成的任务。

二、 环境准备与数据库初始化

1. 技术栈选型

  • Java: 17+
  • Spring Boot: 3.2.x
  • Quartz: 2.3.2 (Spring Boot 默认集成版本)
  • 数据库: MySQL 8.0+

2. Maven 依赖

在 pom.xml 中引入 Spring Boot 的 Quartz Starter 以及 MySQL 驱动:

xml 复制代码
<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- Quartz Starter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-quartz</artifactId>
    </dependency>
    
    <!-- MySQL & JDBC -->
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
</dependencies>

3. 初始化 Quartz 数据库表

Quartz 集群必须 依赖数据库表。你需要在 MySQL 中创建一个数据库(如 quartz_db),并执行官方提供的 DDL 脚本。

获取脚本路径 :你可以从 Quartz GitHub 仓库 下载 tables_mysql_innodb.sql,或者在 Maven 依赖包 org/quartz/impl/jdbcjobstore/ 目录下找到它。

执行脚本后,你会看到 11 张以 QRTZ_ 为前缀的表,核心表包括:

  • QRTZ_JOB_DETAILS:Job 的详细信息。
  • QRTZ_TRIGGERS:触发器信息。
  • QRTZ_CRON_TRIGGERS:Cron 表达式信息。
  • QRTZ_FIRED_TRIGGERS:正在执行的触发器状态(集群抢锁的核心)。
  • QRTZ_SCHEDULER_STATE:节点心跳状态。
  • QRTZ_LOCKS:分布式悲观锁。

三、 Spring Boot 核心配置

在 application.yml 中进行 Quartz 集群配置。这里的配置是集群能否成功的关键:

yaml 复制代码
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/quartz_db?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root
    password: yourpassword
    driver-class-name: com.mysql.cj.jdbc.Driver

  quartz:
    job-store-type: jdbc # 必须使用 jdbc 持久化到数据库
    jdbc:
      initialize-schema: never # 表结构我们已经手动初始化,设置为 never 防止重复建表
    properties:
      org:
        quartz:
          scheduler:
            instanceName: MyClusteredScheduler # 调度器名称,所有节点必须一致
            instanceId: AUTO # 自动生成实例ID(通常为主机名+时间戳)
          jobStore:
            class: org.springframework.scheduling.quartz.LocalDataSourceJobStore # 使用Spring管理的数据源
            driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
            tablePrefix: QRTZ_
            isClustered: true # 【核心】开启集群模式
            clusterCheckinInterval: 15000 # 心跳检查间隔(毫秒),默认15秒
            misfireThreshold: 60000 # Misfire(错过触发)的容忍阈值,默认60秒
          threadPool:
            class: org.quartz.simpl.SimpleThreadPool
            threadCount: 10 # 调度线程池大小,根据并发任务量调整
            threadPriority: 5

四、 实战代码编写

1. 解决 Spring Bean 注入问题(自定义 JobFactory)

默认情况下,Quartz 通过反射实例化 Job,这会导致 Job 类无法使用 @Autowired 注入 Spring 容器中的 Service。我们需要自定义一个 JobFactory:

java 复制代码
import org.quartz.spi.TriggerFiredBundle;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
import org.springframework.scheduling.quartz.SpringBeanJobFactory;
import org.springframework.stereotype.Component;

@Component
public class AutowiringSpringBeanJobFactory extends SpringBeanJobFactory {

    @Autowired
    private AutowireCapableBeanFactory beanFactory;

    @Override
    protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception {
        Object job = super.createJobInstance(bundle);
        // 将 Job 实例纳入 Spring 容器管理,支持 @Autowired 注入
        beanFactory.autowireBean(job); 
        return job;
    }
}

将其配置到 SchedulerFactoryBean 中:

java 复制代码
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
import org.springframework.scheduling.quartz.SpringBeanJobFactory;

@Configuration
public class QuartzConfig {

    @Bean
    public SchedulerFactoryBeanCustomizer schedulerFactoryBeanCustomizer(SpringBeanJobFactory autowiringSpringBeanJobFactory) {
        return bean -> bean.setJobFactory(autowiringSpringBeanJobFactory);
    }
}

2. 编写 Job 任务

注意 :在集群模式下,Job 类必须实现 Serializable 接口(因为需要在节点间传递或持久化),并且强烈建议加上 @DisallowConcurrentExecution 注解,防止同一个 Job 定义被并发执行。

java 复制代码
import lombok.extern.slf4j.Slf4j;
import org.quartz.DisallowConcurrentExecution;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.Serializable;
import java.time.LocalDateTime;

@Slf4j
@Component
@DisallowConcurrentExecution // 禁止并发执行同一个 JobDefinition
public class DataSyncJob implements Job, Serializable {

    // 验证 Spring Bean 是否成功注入
    @Autowired
    private transient MyBusinessService myBusinessService; 

    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        String jobName = context.getJobDetail().getKey().getName();
        String instanceId = context.getScheduler().getSchedulerInstanceId();
        
        log.info("【Quartz集群】任务: {} 正在节点 [{}] 上执行, 时间: {}", 
                 jobName, instanceId, LocalDateTime.now());
                 
        // 调用业务逻辑
        myBusinessService.syncData();
    }
}

3. 编写动态调度服务 (Scheduler Service)

在实际业务中,我们通常需要通过后台界面动态管理任务,而不是写死在代码里。

java 复制代码
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.quartz.*;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class QuartzService {

    private final Scheduler scheduler;

    /**
     * 创建并启动一个 Cron 任务
     */
    public void addCronJob(Class<? extends Job> jobClass, String jobName, String cronExpression) throws SchedulerException {
        JobKey jobKey = JobKey.jobKey(jobName);
        if (scheduler.checkExists(jobKey)) {
            log.warn("任务 {} 已存在,跳过创建", jobName);
            return;
        }

        // 构建 JobDetail
        JobDetail jobDetail = JobBuilder.newJob(jobClass)
                .withIdentity(jobKey)
                .withDescription("动态创建的集群任务")
                .requestRecovery(true) // 【核心】开启故障转移,节点宕机后其他节点会接管
                .storeDurably(true)
                .build();

        // 构建 CronTrigger
        CronScheduleBuilder scheduleBuilder = CronScheduleBuilder.cronSchedule(cronExpression)
                // 设置 Misfire 策略:错过的任务合并为一次立即执行,然后按正常频率执行
                .withMisfireHandlingInstructionFireAndProceed(); 

        CronTrigger trigger = TriggerBuilder.newTrigger()
                .withIdentity(TriggerKey.triggerKey(jobName + "_trigger"))
                .forJob(jobDetail)
                .withSchedule(scheduleBuilder)
                .build();

        scheduler.scheduleJob(jobDetail, trigger);
        log.info("成功创建任务: {}, Cron: {}", jobName, cronExpression);
    }

    /**
     * 暂停任务
     */
    public void pauseJob(String jobName) throws SchedulerException {
        scheduler.pauseJob(JobKey.jobKey(jobName));
    }

    /**
     * 恢复任务
     */
    public void resumeJob(String jobName) throws SchedulerException {
        scheduler.resumeJob(JobKey.jobKey(jobName));
    }

    /**
     * 删除任务
     */
    public void deleteJob(String jobName) throws SchedulerException {
        scheduler.deleteJob(JobKey.jobKey(jobName));
    }
}

五、 集群部署与验证

1. 启动多个实例

为了验证集群,我们在本地启动两个 Spring Boot 实例(可以通过 IDEA 的 Allow parallel run 功能,或者打包成 Jar 后通过命令行指定不同端口启动):

  • Node A : java -jar app.jar --server.port=8080
  • Node B : java -jar app.jar --server.port=8081

2. 触发任务并观察

调用 QuartzService.addCronJob(DataSyncJob.class, "SyncJob_1", "0/10 * * * * ?");(每10秒执行一次)。

观察控制台日志 :

你会发现,每 10 秒钟,只有 Node A 或 Node B 其中的一个节点 会打印执行日志,绝不会同时打印。这就是数据库悲观锁在发挥作用。

不清楚悲观锁看这篇:乐观锁和悲观锁

验证故障转移(Failover):

  1. 将任务执行时间改长(例如 Thread.sleep(20000) 模拟耗时 20 秒)。
  2. 在任务执行期间,强制 Kill 掉正在执行该任务的节点进程。
  3. 观察另一个存活节点的日志,你会发现它检测到了节点失联,并自动接管执行了该任务(因为我们在 JobDetail 中设置了 requestRecovery(true))。

六、 进阶技巧

在生产环境中使用 Quartz 集群,必须注意以下几个核心问题:

1. 深入理解 Misfire(错过触发)机制

什么是 Misfire?

如果到了 Trigger 的触发时间,但由于线程池满、数据库卡顿、节点宕机重启 等原因,导致任务没有被按时触发,且延迟时间超过了 misfireThreshold(默认 60 秒),Quartz 就会判定为 Misfire。

CronTrigger 的三大处理策略:

策略方法 行为描述 适用场景
withMisfireHandlingInstructionFireAndProceed (默认) 将错过的多次触发合并为一次立即执行,后续按原计划执行。 数据同步、状态更新(不在乎中间遗漏的过程)。
withMisfireHandlingInstructionDoNothing 忽略 错过的触发,直接等待下一次正常的 Cron 触发点。 精准时间要求的报表生成(如每天凌晨0点,错过了就算了)。
withMisfireHandlingInstructionIgnoreMisfires 强制补偿 ,把过去错过的次数全部依次执行一遍。 绝对不能漏执行的金融结算、订单超时取消。

警告 :对于耗时任务,慎用 IgnoreMisfires,否则可能导致节点重启后瞬间触发成百上千次任务,直接压垮数据库。

2. 长耗时任务的"线程池饥饿"问题

Quartz 的调度线程池(默认 10 个线程)是全局共享 的。如果你的某个 Job 需要执行 1 个小时,它会一直占用这 10 个线程中的 1 个。如果有 10 个这样的长耗时任务并发,整个 Quartz 调度器将被彻底阻塞,导致其他简单的秒级任务也无法触发。

解决方案:

  • 方案 A(异步化) :Job 的 execute 方法只负责投递消息 到 RabbitMQ/Kafka,或者提交给 Spring 的 @Async 线程池,让 Job 瞬间执行完毕释放调度线程。
  • 方案 B(分离线程池) :针对长耗时任务,在业务代码内部使用自定义的 ThreadPoolExecutor 执行,但要注意控制并发量。

3. 任务的幂等性设计

虽然 Quartz 的数据库锁能保证"同一时刻只有一个节点执行",但在极端网络分区或数据库主从延迟的情况下,仍存在极小概率的重复执行 风险。

专业规范 :所有的定时任务业务逻辑必须设计为幂等的 。例如,通过数据库唯一索引、Redis 分布式锁、或者基于业务状态机(如 UPDATE orders SET status = 'PROCESSED' WHERE id = ? AND status = 'PENDING')来兜底。

4. 架构选型:Quartz vs XXL-JOB

作为专业开发者,我们需要知道何时使用 Quartz,何时使用 XXL-JOB 等新一代调度中心:

维度 Quartz 集群 XXL-JOB / PowerJob
架构 去中心化,依赖数据库行锁 中心化调度中心 + 执行器(RPC)
数据库压力 节点多、任务多时,高频抢锁会导致数据库 CPU 飙升 调度中心内存计算,对数据库压力极小
可视化管理 无(需自己开发后台界面调用 API) 自带完善的 Web 控制台、日志监控、滚动实时日志
路由策略 随机抢锁 轮询、一致性Hash、故障转移、分片广播等
适用场景 轻量级、不想引入额外中间件、强依赖本地事务 企业级微服务、海量任务调度、需要可视化运维

结论 :如果你的系统已经有完善的微服务基建,且任务量庞大(万级别),推荐使用 XXL-JOB ;如果你的系统相对独立,任务量在百级别以内,且希望零额外组件部署,Quartz 集群依然是王者。


七、 总结

Quartz 集群模式通过共享数据库 + 悲观锁 + 心跳机制,优雅地解决了分布式环境下的任务调度一致性问题。

掌握以下核心要点,足以让你应对 95% 以上的 Quartz 开发场景:

  1. 必须使用 JobStoreTX (JDBC) 并正确初始化 11 张表。
  2. 通过自定义 JobFactory 打通 Quartz 与 Spring IoC 容器的壁垒。
  3. 深刻理解并根据业务选择正确的 Misfire 策略。
  4. 警惕线程池饥饿,长耗时任务必须异步化。
  5. 永远不要信任框架的绝对可靠性,业务幂等性是最后的防线。
相关推荐
子兮曰2 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
小羊没烦恼!2 天前
微服务化的基石——持续集成
java·大数据·word·powerpoint·.net
子兮曰2 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
俊昭喜喜里2 天前
java中的继承和多态的区别
java
小羊没烦恼!2 天前
初探性能优化——2个月到4小时的性能提升
java·开发语言·windows·算法·c#
爱勇宝2 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
譕痕2 天前
JSONObject与JSONArray封装数据格式区别
java·json
胡写代码3 天前
别再前后端各写一套表单校验了
java·后端
小鱼能吃糖3 天前
缺陷修复总览 · mall电商项目:5类缺陷,1个病根,4个业务域
java·电商
此时不提桶,更待何时3 天前
01-06-A-JVM排查实战详解
java·jvm