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. 永远不要信任框架的绝对可靠性,业务幂等性是最后的防线。
相关推荐
Hiter_John1 小时前
Golang的变量常量初始化
开发语言·后端·golang
小肥君2 小时前
gpu安装milvus问题解决
java·eureka·milvus
砍材农夫2 小时前
物联网实战:Spring Boot MQTT | 模拟器Paho客户端拆解高性能
java·javascript·spring boot·后端·物联网·struts
电商API_180079052472 小时前
免 TOP 入驻,第三方淘宝商品详情 API 快速接入与代码示例
java·大数据·开发语言·数据库·爬虫·数据分析
IT空门:门主2 小时前
Java AI 开发框架终极对比:Spring AI vs Spring AI Alibaba vs AgentScope-Java
java·人工智能·spring·spring ai·ai alibaba·agentscope-java
未若君雅裁2 小时前
多线程项目场景:CountDownLatch、Future、Semaphore
java
小科先生2 小时前
初学者安装java
java·开发语言
wyhwust2 小时前
如何让maven帮我们去下载合适的包
java·maven
ID_180079054733 小时前
小红书笔记评论 API 接口深度解析(带全套 JSON 示例・技术实战版)
java·开发语言·windows