@Scheduled で定期実行はサクッと組めますよね。ただ業務システムで運用していると、「ジョブ定義をDBに残したい」「再デプロイせずにスケジュールを変えたい」「複数インスタンスで同じジョブが二重に走るのを防ぎたい」といった壁にぶつかります。ここまで来ると @Scheduled だけでは苦しくなってきます。

そこで登場するのが Quartz Scheduler です。この記事では spring-boot-starter-quartz の導入から、JDBC JobStore による永続化、実行時の動的なジョブ操作、クラスタでの重複実行防止までをコード付きで見ていきます。最後に @Scheduled / ShedLock / Quartz の使い分けも整理します。

@Scheduledでは足りなくなる3つのシナリオ

まず、どんなときに Quartz が必要になるのかを言語化しておきましょう。

  1. ジョブ定義や実行状態をDBに永続化したい@Scheduled はコードに埋め込まれメモリ上で管理されるので、再起動すると当然リセットされます。
  2. 再デプロイせずにジョブを追加・変更・削除したい。cron式をコードに書いていると、変更のたびにビルドとデプロイが必要ですよね。
  3. クラスタ(複数インスタンス)で重複実行を防ぎたい。同じアプリを3台で動かすと、@Scheduled は3台とも実行してしまいます。

Quartz はこの3つを標準機能として持っています。なお @Scheduled そのものの基本は@Scheduledで定期実行を実装する方法で解説しているので、ここでは深追いしません。

spring-boot-starter-quartzの導入と最小構成

依存を追加するところから始めます。Maven ならこれだけです。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>

Gradle なら implementation 'org.springframework.boot:spring-boot-starter-quartz' を追加します。

この状態では JobStore はインメモリ(RAMJobStore)で動きます。まずは最小の Job を書いてみましょう。

import org.quartz.JobExecutionContext;
import org.springframework.scheduling.quartz.QuartzJobBean;
import org.springframework.stereotype.Component;

@Component
public class SampleJob extends QuartzJobBean {
    @Override
    protected void executeInternal(JobExecutionContext context) {
        System.out.println("ジョブ実行: " + context.getJobDetail().getKey());
    }
}

QuartzJobBean を継承しておくと、後述する Spring の DI 連携がスムーズになります。あとは JobDetail と Trigger を Bean 登録すればアプリ起動時に自動でスケジュールされます。

Job・JobDetail・Triggerの基本

Quartz の中心概念は3つです。ここを押さえると以降がラクになります。

  • Job は処理本体。上の SampleJob がこれにあたります。
  • JobDetail はそのジョブの定義とメタデータ。JobKey(name / group)で識別します。
  • Trigger はいつ実行するかの定義。SimpleTriggerCronTrigger の2種類がよく使われます。

ジョブへパラメータを渡したいときは JobDataMap を使います。Trigger 側にも TriggerKey があり、name と group の組で一意に識別される点は JobDetail と同じ考え方です。

CronTriggerとSimpleTriggerでスケジュールを定義する

実際に JobDetail と Trigger を Bean として登録します。ビルダーで組み立てるのが基本パターンです。

import org.quartz.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class QuartzConfig {

    @Bean
    public JobDetail sampleJobDetail() {
        return JobBuilder.newJob(SampleJob.class)
                .withIdentity("sampleJob", "reports")
                .storeDurably()
                .build();
    }

    @Bean
    public Trigger sampleJobTrigger(JobDetail sampleJobDetail) {
        return TriggerBuilder.newTrigger()
                .forJob(sampleJobDetail)
                .withIdentity("sampleTrigger", "reports")
                .withSchedule(CronScheduleBuilder.cronSchedule("0 0 3 * * ?"))
                .build();
    }
}

上は「毎日3時」の cron 例です。一定間隔で回したいだけなら SimpleScheduleBuilder を使います。

.withSchedule(SimpleScheduleBuilder.simpleSchedule()
        .withIntervalInMinutes(10)
        .repeatForever())

storeDurably() を付けておくと、Trigger がなくても JobDetail が保持されます。動的登録のときに効いてくるので覚えておきましょう。

QuartzのJobとSpring Bean/DIを連携させる

ここでハマりやすいのが DI です。Quartz はデフォルトで Job インスタンスを自前で new するため、@Autowired したフィールドが null のままになります。

Spring Boot の starter を使っていれば、内部で SpringBeanJobFactory 相当の仕組みが働き、Job 生成時に AutowireCapableBeanFactory でインジェクションしてくれます。つまり QuartzJobBean を継承したジョブなら、素直に Service を注入できます。

@Component
public class ReportJob extends QuartzJobBean {

    private final ReportService reportService;

    public ReportJob(ReportService reportService) {
        this.reportService = reportService;
    }

    @Override
    protected void executeInternal(JobExecutionContext context) {
        reportService.generateDailyReport();
    }
}

同一ジョブが前回分と重なって走ってほしくない場合は、クラスに @DisallowConcurrentExecution を付けておくと多重起動を抑制できます。Service 内でトランザクションを張る話はSpring Bootのトランザクション管理も参考になります。

JDBC JobStoreでジョブをDBに永続化する

いよいよ永続化です。application.properties で JobStore を JDBC に切り替えます。

spring.quartz.job-store-type=jdbc
spring.quartz.jdbc.initialize-schema=always
spring.quartz.properties.org.quartz.scheduler.instanceName=AppScheduler
spring.quartz.properties.org.quartz.jobStore.class=org.springframework.scheduling.quartz.LocalDataSourceJobStore

Quartz は QRTZ_ プレフィックスの一連のテーブルにジョブ・Trigger・ロック情報などを保存します。initialize-schema=always にすると starter がスキーマを自動作成してくれるので開発中は便利です。

ただし本番では注意が必要です。always は起動のたびに初期化スクリプトを流そうとするため、既存データを壊すリスクがあります。本番は never にして、テーブルは事前にマイグレーションで作っておくのが安全です。

DataSource はアプリのものが共有され、LocalDataSourceJobStore が Spring のトランザクション管理と協調します。永続化したうえで再起動すると、DBに残ったジョブとスケジュールが復元されて実行が続く、というのが JDBC JobStore の肝です。

実行時にジョブを動的に登録・変更・削除する

永続化ができると、次は「再デプロイなしでジョブを操作する」がやりたくなります。Scheduler をインジェクトすれば、実行中にジョブを足したり差し替えたりできます。

@Service
public class JobAdminService {

    private final Scheduler scheduler;

    public JobAdminService(Scheduler scheduler) {
        this.scheduler = scheduler;
    }

    public void register(String name, String cron) throws SchedulerException {
        JobKey jobKey = new JobKey(name, "dynamic");
        if (scheduler.checkExists(jobKey)) {
            return; // 二重登録を防ぐ
        }
        JobDetail job = JobBuilder.newJob(ReportJob.class)
                .withIdentity(jobKey).build();
        Trigger trigger = TriggerBuilder.newTrigger()
                .withIdentity(name, "dynamic")
                .withSchedule(CronScheduleBuilder.cronSchedule(cron))
                .build();
        scheduler.scheduleJob(job, trigger);
    }

    public void reschedule(String name, String cron) throws SchedulerException {
        TriggerKey key = new TriggerKey(name, "dynamic");
        Trigger newTrigger = TriggerBuilder.newTrigger()
                .withIdentity(key)
                .withSchedule(CronScheduleBuilder.cronSchedule(cron))
                .build();
        scheduler.rescheduleJob(key, newTrigger);
    }

    public void remove(String name) throws SchedulerException {
        scheduler.deleteJob(new JobKey(name, "dynamic"));
    }
}

scheduleJob で登録、rescheduleJob で Trigger を差し替えてスケジュール変更、deleteJob で削除です。一時停止したいだけなら pauseJob も使えます。checkExists で存在チェックしておくと重複登録を避けられます。

JDBC JobStore と組み合わせていれば、これらの変更はすべてDBに反映されるので、再起動後も動的登録したジョブが生き残ります。REST コントローラからこのサービスを叩けば、管理画面からのジョブ操作も実現できますね。

isClusteredでクラスタの重複実行を防ぐ

複数インスタンスで動かすときの本命がクラスタリングです。設定はシンプルです。

spring.quartz.properties.org.quartz.jobStore.isClustered=true
spring.quartz.properties.org.quartz.scheduler.instanceId=AUTO
spring.quartz.properties.org.quartz.jobStore.clusterCheckinInterval=15000

仕組みとしては、各ノードが同じDBを共有し、DBのロック(QRTZ_LOCKS)を取り合うことで「あるジョブの1回の発火は1ノードだけが担当する」ことを保証します。あるノードが落ちても、他のノードがフェイルオーバーして引き継ぎます。

ポイントは3つです。instanceIdAUTO にすると各ノードに一意なIDが自動採番されます。全ノードが同一のDBを指していることが前提です。そしてクラスタでは JDBC JobStore が必須で、インメモリでは成立しません。clusterCheckinInterval は各ノードの生存確認の間隔ですが、細かいチューニングはまず不要です。

@Scheduled / ShedLock / Quartz の使い分け

3つの選択肢を並べて整理しましょう。

観点@ScheduledShedLockQuartz
永続化なしなし(ロックのみDB)あり(ジョブ定義ごと)
動的登録不可不可可能
クラスタ重複制御不可得意可能
実行履歴なしなしDBに残せる
導入コスト低〜中中〜高

判断の目安はこうです。固定スケジュールを軽く回すだけなら @Scheduled で十分。複数インスタンスで @Scheduled の重複だけ止めたいならShedLockが手軽です。ジョブのDB永続化・動的登録・クラスタ実行までまとめて欲しいなら Quartz、という切り分けになります。

迷ったら「再デプロイなしでジョブを増やしたいか」「実行履歴をDBに残したいか」を自問してみてください。どちらかが Yes なら Quartz が候補です。本格的なバッチ処理そのものが目的ならSpring Batchの記事も検討の価値があります。

つまずきやすいポイント

実際に導入するとよく踏むのがこのあたりです。

Job 内で Bean が null になるのは、QuartzJobBean を継承していないか、Job を Spring 管理外で生成しているケースが大半です。まずは継承と DI の経路を確認しましょう。

QRTZ_ テーブルが無いというエラーは、スキーマ未初期化が原因です。開発中は initialize-schema を見直し、本番はマイグレーションでテーブルを用意しておきます。

アプリ停止中に実行時刻を過ぎてしまった発火は misfire になります。挙動は misfire instruction で制御できるので、cron の場合は withMisfireHandlingInstructionFireAndProceed() などを Trigger に指定して意図を明示しておくと安心です。クラスタではノード間でDBやタイムゾーンがずれると挙動が乱れるので、時刻同期も忘れずに。

まとめ

Quartz を使うと、@Scheduled では届かなかった「JDBC JobStore による永続化」「Scheduler 経由の動的なジョブ操作」「isClustered による重複実行防止」がまとめて手に入ります。導入コストは相応にありますが、業務システムのジョブ管理では十分に見合うはずです。

自分の要件が固定スケジュールなら @Scheduled、重複防止だけなら ShedLock、永続化や動的登録まで必要なら Quartz、という軸で選べば大きく外しません。まずは小さな Job を1つ永続化するところから試してみてください。