Spring中实现持久化Quartz调度任务的指南

在本文中,我们探讨了两种持久化和恢复 Quartz 作业的方法。Quartz 内置的 JDBC 持久化功能提供了一个交钥匙解决方案,它会自动将作业和触发器存储在自己的模式中,并在应用程序重启后无缝地重新加载它们。

另一方面,自定义业务作业存储库使我们能够更好地控制作业生命周期,使我们能够在自己的域模型中启用、禁用或将作业标记为已完成。

正确的选择取决于需求:如果我们只需要可靠的调度,那么 Quartz 的持久性就足够了;如果作业状态是业务工作流的一部分,那么自定义存储库可能更合适。

在构建 Spring Web 应用程序时,我们经常需要安排重复执行的任务或作业,例如发送电子邮件、生成报告或按特定间隔处理数据。Quartz Scheduler因其强大而灵活的调度功能而成为处理此类任务的热门选择。

Spring Web 应用程序的一个关键挑战是确保已调度的 Quartz 作业在应用程序重启后仍然持久化,并无缝维护其状态和调度。 通常有两种方法可以实现这一点:

  1. 让 Quartz 本身使用其 JDBC JobStore 处理持久性。
  2. 在自定义业务表中维护作业定义,并在启动时将其加载到 Quartz 中。

在本教程中,我们将探讨这两种方法。



问题
在 Spring Web 应用程序中,开发人员可以集成 Quartz 来管理计划作业。一个关键要求是将作业和触发器的详细信息存储在数据库中,以便在应用程序关闭或重启时不会丢失。

在生产环境中,应用程序经常会因为维护、更新或意外崩溃而重启。如果 Quartz 作业仅存储在内存中(默认行为),则它们会在重启期间丢失,导致执行失败或需要手动重新调度。

将作业持久化到数据库中可以确保作业的连续性,使调度程序能够从中断的地方继续执行。这对于必须按照精确时间表运行的任务(例如每日报告或时效性通知)尤为重要。



Maven依赖项
让我们首先将spring-boot-starter-quartz、spring-boot-starter-data-jdbc、spring-boot-starter-data-jpa和h2依赖项导入到我们的pom.xml中:


    org.springframework.boot
    spring-boot-starter-quartz
    3.3.2


    org.springframework.boot
    spring-boot-starter-data-jdbc
    3.3.2


    org.springframework.boot
    spring-boot-starter-data-jpa
    3.3.2


    com.h2database
    h2
    2.2.224



使用Quartz的JDBC JobStore
Quartz 通过其自己的模式( QRTZ_*表)提供内置持久性。当使用 JDBC 配置时,Quartz 会自动将所有作业、触发器和调度元数据存储在数据库中:

spring.quartz.job-store-type=jdbc

在开发过程中,我们可以使用基于 H2 文件的数据库来模拟重启后的持久化。推荐的方法是让 Spring Boot 创建一次模式,然后禁用模式初始化,这样我们的作业数据就可以被保留下来:


spring.datasource.url=jdbc:h2:file:~/quartz-db;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
First run: let Spring create the Quartz schema
spring.quartz.jdbc.initialize-schema=always
Restart runs: disable schema initialization to preserve existing data
spring.quartz.jdbc.initialize-schema=never

使用此设置,Quartz 在首次运行时将在~/quartz-db.mv.db中创建所需的QRTZ_*表。在后续重启时,我们将设置切换为“ never ”,这样现有的表和作业数据就不会被删除或重新初始化,从而允许 Quartz 自动重新加载已调度的作业。这样,作业在应用程序重启后仍能继续运行,我们可以使用一个简单的基于 H2 文件的数据库安全地测试恢复行为。

定义 Quartz 作业
让我们通过实现Job接口来创建一个作业:


public class SampleJob implements Job {
    @Override
    public void execute(JobExecutionContext context) {
        System.out.println("Executing SampleJob at " + System.currentTimeMillis());
    }
}
然后,我们使用JobDetail类定义SampleJob的实例:

@Bean
public JobDetail sampleJobDetail() {
    return JobBuilder.newJob(SampleJob.class)
      .withIdentity("sampleJob", "group1")
      .storeDurably()
      .requestRecovery(true)
      .build();
}
我们使用storeDurably()将作业持久化到 Quartz 数据库中。 此外,我们设置了requestRecovery(true)以确保在应用程序执行过程中崩溃时能够重试。

现在,我们需要定义一个触发器:


@Bean
public Trigger sampleTrigger(JobDetail sampleJobDetail) {
    return TriggerBuilder.newTrigger()
      .forJob(sampleJobDetail)
      .withIdentity("sampleTrigger", "group1")
      .withSchedule(CronScheduleBuilder.cronSchedule("0/30 * * * * ?")) // every 30s
      .build();
}
我们用 cron 表达式( 0/30 * * * * ? )定义触发器,安排作业每30秒运行一次。

重启时重新初始化作业
当 Quartz 使用 JDBC 作业存储时,JobDetail及其关联的Trigger都会持久保存在 Quartz 的 schema 中。应用程序重启时,Quartz 会自动从数据库重新加载它们,因此无需自定义初始化逻辑。

让我们创建一个单元测试:


@Test
void givenSampleJob_whenSchedulerRestart_thenSampleJobIsReloaded() throws Exception {
    // Given
    JobKey jobKey = new JobKey("sampleJob", "group1");
    TriggerKey triggerKey = new TriggerKey("sampleTrigger", "group1");
    JobDetail jobDetail = scheduler.getJobDetail(jobKey);
    assertNotNull(jobDetail, "SampleJob exists in running scheduler");
    Trigger trigger = scheduler.getTrigger(triggerKey);
    assertNotNull(trigger, "SampleTrigger exists in running scheduler");
    // When
    scheduler.standby();
    Scheduler restartedScheduler = applicationContext.getBean(Scheduler.class);
    restartedScheduler.start();
    // Then
    assertTrue(restartedScheduler.isStarted(), "Scheduler should be running after restart");
    JobDetail reloadedJob = restartedScheduler.getJobDetail(jobKey);
    assertNotNull(reloadedJob, "SampleJob should be reloaded from DB after restart");
    Trigger reloadedTrigger = restartedScheduler.getTrigger(triggerKey);
    assertNotNull(reloadedTrigger, "SampleTrigger should be reloaded from DB after restart");
}
此测试确保 Quartz 在模拟重启后能够正确地从其持久存储中重新加载作业和触发器。首先,我们验证正在运行的调度程序中是否存在sampleJob及其关联的sampleTrigger  。

接下来,我们将调度器置于待机模式,并从 Spring 上下文中获取一个新的Scheduler实例,模拟应用程序重启。启动新的调度器后,我们断言作业和触发器都再次可用。这证实了 Quartz 会自动从数据库恢复计划任务,无需额外的初始化逻辑。



使用自定义业务作业存储库
虽然 Quartz 提供了内置的持久化功能,但有时这还不够。在许多应用程序中,我们需要对作业的业务生命周期进行更多控制。例如,将它们标记为已启用、已禁用或已完成。在这些情况下,Quartz 的作业存储无法捕获足够的业务上下文,因此我们引入了自己的表来明确管理作业。

定义自定义作业表
我们可以首先创建一个 JPA 实体来表示我们业务领域中的工作:


@Entity
public class ApplicationJob {
    @Id
    private Long id;
    private String name;
    private boolean enabled;
    private Boolean completed;
}
该表独立于 Quartz 的QRTZ_*模式,完全由我们控制。它允许我们跟踪 Quartz 无法处理的作业元数据,例如作业是否已明确标记为已完成。

播种业务工作
首次启动时,我们可以预先在表中填充作业定义。例如,一个简单的播种机可能会在表为空时插入一条记录:


@Component
public class DataSeeder implements CommandLineRunner {
    private final ApplicationJobRepository repository;
    public DataSeeder(ApplicationJobRepository repository) {
        this.repository = repository;
    }
    @Override
    public void run(String... args) {
        if (repository.count() == 0) {
            ApplicationJob job = new ApplicationJob();
            job.setName("simpleJob");
            job.setEnabled(true);
            job.setCompleted(false);
            repository.save(job);
        }
    }
}
此步骤模拟一个真实的系统,其中可以通过管理 UI 或业务工作流定义或管理作业。

启动时重新初始化作业
接下来,我们需要将业务存储库与 Quartz 连接起来。在应用程序启动时,监听器可以查询ApplicationJob表并动态安排作业:


@Component
public class JobInitializer implements ApplicationListener {
    @Autowired
    private ApplicationJobRepository jobRepository;
    @Autowired
    private Scheduler scheduler;
    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        for (ApplicationJob job : jobRepository.findAll()) {
            if (job.isEnabled() && (job.getCompleted() == null || !job.getCompleted())) {
                JobDetail detail = JobBuilder.newJob(SampleJob.class)
                  .withIdentity(job.getName(), "appJobs")
                  .storeDurably()
                  .build();
                Trigger trigger = TriggerBuilder.newTrigger()
                  .forJob(detail)
                  .withSchedule(SimpleScheduleBuilder.simpleSchedule()
                    .withIntervalInSeconds(30)
                    .repeatForever())
                  .build();
                try {
                    scheduler.scheduleJob(detail, trigger);
                } catch (SchedulerException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }
}
重启后,Quartz 不会自动重新加载这些作业,因为它们没有存储在 Quartz 自己的 schema 中。相反,JobInitializer会运行,查询业务表,并确保只有标记为已启用但尚未完成的作业才会在 Quartz 中被调度。

JobInitializer使用scheduler.scheduleJob ()方法重新安排作业。

与 Quartz 内置的持久化机制相比,这种方式虽然重复了调度逻辑,但却提供了更细粒度的控制。当作业定义属于业务模型的一部分,并且需要与其他领域数据一起管理时,这种方式尤其有用。