@Scheduled で定期実行はサクッと組めますよね。ただ業務システムで運用していると、「ジョブ定義をDBに残したい」「再デプロイせずにスケジュールを変えたい」「複数インスタンスで同じジョブが二重に走るのを防ぎたい」といった壁にぶつかります。ここまで来ると @Scheduled だけでは苦しくなってきます。
そこで登場するのが Quartz Scheduler です。この記事では spring-boot-starter-quartz の導入から、JDBC JobStore による永続化、実行時の動的なジョブ操作、クラスタでの重複実行防止までをコード付きで見ていきます。最後に @Scheduled / ShedLock / Quartz の使い分けも整理します。
@Scheduledでは足りなくなる3つのシナリオ
まず、どんなときに Quartz が必要になるのかを言語化しておきましょう。
- ジョブ定義や実行状態をDBに永続化したい。
@Scheduledはコードに埋め込まれメモリ上で管理されるので、再起動すると当然リセットされます。 - 再デプロイせずにジョブを追加・変更・削除したい。cron式をコードに書いていると、変更のたびにビルドとデプロイが必要ですよね。
- クラスタ(複数インスタンス)で重複実行を防ぎたい。同じアプリを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 はいつ実行するかの定義。
SimpleTriggerとCronTriggerの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つです。instanceId を AUTO にすると各ノードに一意なIDが自動採番されます。全ノードが同一のDBを指していることが前提です。そしてクラスタでは JDBC JobStore が必須で、インメモリでは成立しません。clusterCheckinInterval は各ノードの生存確認の間隔ですが、細かいチューニングはまず不要です。
@Scheduled / ShedLock / Quartz の使い分け
3つの選択肢を並べて整理しましょう。
| 観点 | @Scheduled | ShedLock | Quartz |
|---|---|---|---|
| 永続化 | なし | なし(ロックのみ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つ永続化するところから試してみてください。