Spring Bootの@Scheduledで定期的なタスクを処理する

1. 概要

多くのアプリケーションでは定期実行が必要です。例えば、期限切れの注文を削除する、5分ごとに在庫を同期する、朝8時にレポートを送信するといったタスクです。Springは外部ライブラリを追加せず、@Scheduledアノテーションで定期実行をサポートしています。

本記事では、スケジューリングを有効にしてfixedRate、fixedDelay、cronを使い分けます。実行スケジュールを設定ファイルに出す方法、スレッドプールの調整、ジョブのテスト方法も扱います。最後に@Scheduledを使うべきではないケースを説明します。

2. スケジューリングを有効にする

@Scheduledはspring-contextに含まれているため、Spring Bootの基本的なスターターがあれば十分です。

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

次に、設定クラスに@EnableSchedulingを追加します。

@SpringBootApplication
@EnableScheduling
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

@EnableSchedulingを忘れるのは最も一般的なミスです。アプリケーションは通常どおり起動してエラー表示もありませんが、ジョブは実行されません。

注意: @Scheduledを付けたメソッドはパラメータを持たない必要があります。戻り値はvoidにすべきです。戻り値は無視されます。メソッドを含むクラスはBean(例えば@Componentや@Service)である必要があります。

3. 固定間隔での実行

3.1. fixedRate

fixedRateは前回の実行が開始した時刻からの間隔を計算します。

@Component
public class InventoryJob {

    private static final Logger log = LoggerFactory.getLogger(InventoryJob.class);

    @Scheduled(fixedRate = 5000)
    public void syncInventory() {
        log.info("Sync inventory at {}", LocalTime.now().withNano(0));
    }
}

デフォルト単位はミリ秒です。ジョブはアプリケーション起動直後に実行され、その後5秒ごとに実行されます。

INFO [   scheduling-1] InventoryJob : Sync inventory at 09:00:00
INFO [   scheduling-1] InventoryJob : Sync inventory at 09:00:05
INFO [   scheduling-1] InventoryJob : Sync inventory at 09:00:10

1回の実行が5秒より長くかかっても、次の実行は並列実行されません。前回の実行が終わってすぐ開始されます。これはデフォルトではスレッドが1つだけだからです(scheduling-1)。

3.2. fixedDelay

fixedDelayは前回の実行が終わった時刻からの間隔を計算します。外部APIの呼び出しやバッチデータ処理など、実行が重複してはいけないタスクに適しています。

@Component
public class EmailQueueJob {

    private static final Logger log = LoggerFactory.getLogger(EmailQueueJob.class);

    @Scheduled(fixedDelay = 3, initialDelay = 10, timeUnit = TimeUnit.SECONDS)
    public void sendPendingEmails() throws InterruptedException {
        log.info("Start sending emails");
        Thread.sleep(2000); // simulate work taking 2 seconds
        log.info("Done sending");
    }
}

説明:

  • initialDelay = 10:起動後10秒待ってから最初の実行を開始。起動を遅くしないための措置です。
  • timeUnit = TimeUnit.SECONDS:fixedDelayとinitialDelayの単位を秒に変更します。
  • 各実行に2秒かかり、その後3秒待ちます。連続した2つの実行の開始時刻は5秒離れています。
09:00:10 Start sending emails
09:00:12 Done sending
09:00:15 Start sending emails
09:00:17 Done sending

4. cron式でのスケジューリング

特定の時間に実行したい場合はcronを使用します。Springのcron式は6つのフィールドを持ちます。UNIXのcronより1フィールド多く、秒が最初に来ます。

┌─ 秒 (0-59)
│ ┌─ 分 (0-59)
│ │ ┌─ 時 (0-23)
│ │ │ ┌─ 日 (1-31)
│ │ │ │ ┌─ 月 (1-12 or JAN-DEC)
│ │ │ │ │ ┌─ 曜日 (0-7 or MON-SUN)
* * * * * *

例えば、月曜から金曜の朝8時にベトナム時間で売上レポートを送信する場合:

@Component
public class SalesReportJob {

    private final ReportService reportService;

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

    @Scheduled(cron = "0 0 8 * * MON-FRI", zone = "Asia/Ho_Chi_Minh")
    public void sendDailyReport() {
        reportService.sendYesterdaySales();
    }
}

zone属性は重要です。サーバーがUTC(コンテナではよくあること)で実行されている場合、zoneを指定しないとレポートが午後3時に送られてしまいます。

よく使うcron式:

0 */15 * * * *        15分ごと
0 0 2 * * *           毎日午前2時
0 0 0 1 * *           毎月1日の真夜中
0 30 9 * * SAT,SUN    土日の午前9時30分
@daily                0 0 0 * * * と同じ
@hourly               0 0 * * * * と同じ

注意: UNIXのcrontabから5フィールド形式(例:0 8 * * *)をコピーすると、Springは起動時にIllegalStateExceptionをスローします。最初に秒のフィールドを追加してください。

5. スケジュールを設定ファイルに移動する

コードにスケジュールをハードコーディングすると、変更するたびにビルドし直す必要があります。プレースホルダーを使用しましょう。数値属性の場合は...Stringバージョンを使用します。

@Component
public class OrderCleanupJob {

    private final OrderRepository orderRepository;
    private final AtomicInteger runCount = new AtomicInteger();

    public OrderCleanupJob(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Scheduled(fixedDelayString = "${jobs.order-cleanup.delay-ms:60000}")
    public void removeExpiredOrders() {
        orderRepository.deleteExpiredBefore(Instant.now().minus(Duration.ofHours(24)));
        runCount.incrementAndGet();
    }

    public int getRunCount() {
        return runCount.get();
    }
}
jobs.order-cleanup.delay-ms=30000
jobs.sales-report.cron=0 0 8 * * MON-FRI

:の後の値はデフォルト値です。プロパティが宣言されていない場合に使用されます。cronの場合は@Scheduled(cron = "${jobs.sales-report.cron}")と書きます。

コードを変更せずにcronジョブを無効化したい場合は、プロパティ値を-に設定します。

jobs.sales-report.cron=-

Springは-(定数Scheduled.CRON_DISABLED)を認識し、そのジョブを登録しません。開発環境やテスト環境でジョブを無効化したい時に便利です。

6. スレッドプールの設定

デフォルトでは、Spring Bootは1つだけのスレッドを持つThreadPoolTaskSchedulerを作成します。すべてのジョブがそのスレッドを共有します。SalesReportJobが10分間ハングすると、InventoryJobも10分間待つ必要があります。

プールサイズを増やすことができます。

spring.task.scheduling.pool.size=4
spring.task.scheduling.thread-name-prefix=shop-job-

増やすと、異なるジョブが並行実行できます。

INFO [   shop-job-1] InventoryJob   : Sync inventory at 09:00:05
INFO [   shop-job-2] EmailQueueJob  : Start sending emails

Spring Boot 3.2以降でJava 21を使用している場合は、仮想スレッドを使用できます。

spring.threads.virtual.enabled=true

この場合、BootはSimpleAsyncTaskSchedulerを使用し、各ジョブ実行は独自の仮想スレッド上で実行されます。pool.sizeプロパティは効果がありません。

注意: プールサイズを増やしても、同じジョブが自分自身と重複実行することはありません。各@Scheduledメソッドは依然として順序立てて実行されます。重複実行させたい場合は@Asyncを追加する必要があります(@EnableAsyncと共に)。ほぼこれは推奨されません。データ処理の重複が起こりやすいからです。

7. 例外処理

fixedRateまたはfixedDelayでジョブが例外をスローすると、Springはエラーをログに記録した後次回の実行時にも実行します。cronも同じです。ジョブは完全に停止しませんが、ログを監視していなければエラーが見落とされやすいです。

最も安全なアプローチは、ジョブ内で例外を捕捉して意図的に処理することです。

@Scheduled(fixedRate = 5, timeUnit = TimeUnit.MINUTES)
public void syncInventory() {
    try {
        inventoryClient.pullLatestStock();
    } catch (RestClientException ex) {
        log.warn("Could not reach warehouse, will retry next time: {}", ex.getMessage());
        alertService.notifyOps("inventory-sync-failed");
    }
}

すべてのジョブに共通のエラーハンドリングが必要な場合は、Bootのカスタマイザー経由でErrorHandlerを登録します。

@Configuration
public class SchedulingConfig {

    @Bean
    ThreadPoolTaskSchedulerCustomizer schedulerErrorHandler(AlertService alertService) {
        return scheduler -> scheduler.setErrorHandler(ex ->
                alertService.notifyOps("scheduled-job-failed: " + ex.getMessage()));
    }
}

このカスタマイザーはThreadPoolTaskSchedulerにのみ適用されます。仮想スレッドを有効にした場合は効果がありません。

8. ジョブのテストを書く

テストを2つのレイヤーに分けます。第1レイヤーはビジネスロジックをテストします。第2レイヤーはスケジューラーが実際にジョブを呼び出すことをテストします。

ビジネスロジックは通常のメソッドのようにテストされるべきです。直接呼び出し、待つ必要はありません。

class OrderCleanupJobUnitTest {

    @Test
    void shouldDeleteOrdersOlderThan24Hours() {
        OrderRepository repo = mock(OrderRepository.class);
        OrderCleanupJob job = new OrderCleanupJob(repo);

        job.removeExpiredOrders();

        ArgumentCaptor<Instant> captor = ArgumentCaptor.forClass(Instant.class);
        verify(repo).deleteExpiredBefore(captor.capture());
        assertThat(captor.getValue())
                .isBefore(Instant.now().minus(Duration.ofHours(23)));
        assertThat(job.getRunCount()).isEqualTo(1);
    }
}

スケジューリングをテストするにはAwaitilityを使用します。Thread.sleepは使わないでください。テストが遅く、不安定になります。Awaitilityはバージョンが既にSpring Bootで管理されているため、バージョンを指定する必要はありません。

<dependency>
    <groupId>org.awaitility</groupId>
    <artifactId>awaitility</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest(properties = "jobs.order-cleanup.delay-ms=100")
class OrderCleanupJobSchedulingTest {

    @MockitoBean
    OrderRepository orderRepository;

    @Autowired
    OrderCleanupJob job;

    @Test
    void shouldRunRepeatedly() {
        await().atMost(Duration.ofSeconds(2))
               .untilAsserted(() -> assertThat(job.getRunCount()).isGreaterThanOrEqualTo(3));
    }
}

このテストはproperties経由で間隔を100msに短縮します。その後、最大2秒間待ってジョブが少なくとも3回実行されるまで待ちます。Awaitilityは繰り返しチェックして条件が真になるとすぐに返すため、通常テストは約300ms で完了します。@MockitoBeanはSpring Boot 3.4以降で利用可能です。それ以前のバージョンでは@MockBeanを使用します。

他の統合テストでは、ジョブがバックグラウンドで実行されてデータが破損するのを防ぐために、スケジューリングを無効化します。2つの方法があります。1つ目はcronを-に設定すること。2つ目は@EnableSchedulingを条件付きの別の設定クラスに移動することです。

@Configuration
@EnableScheduling
@ConditionalOnProperty(name = "jobs.enabled", havingValue = "true", matchIfMissing = true)
public class SchedulingConfig { }

その場合、テストでjobs.enabled=falseを宣言するだけです。

9. @Scheduledを使うべきではないケース

@Scheduledはシンプルですが、明確な制限があります。

  • 複数インスタンスでの実行。 Kubernetesで3つのポッドがある場合、ジョブは同時に3回実行され、レポートが3回送信されます。分散ロック(例えば、データベーステーブルを使用するShedLockライブラリ)またはジョブを単一インスタンスサービスに分離する必要があります。
  • ジョブの状態を保存する必要がある。 アプリケーションが正確に午前8時にシャットダウンした場合、その実行は失われ、実行されません。Misfire処理、実行履歴、またはランタイムでの動的スケジューリングが必要な場合はQuartzを使用します。
  • 非常に重いジョブまたは数時間実行する。 Spring Batchまたはキューシステムに切り替えて、リトライ、バッチ処理、進捗追跡を実行します。
  • ユーザーが作成するスケジュール。例えば「午後3時にリマインダー」。アノテーションは起動時の固定スケジュールのみに適しています。動的スケジュールはデータベースに保存し、TaskSchedulerで プログラム的に作成するか、Quartzを使用すべきです。

その他のよくあるミス:

  • @Scheduledをprivateメソッドまたは非Beanクラスのメソッドに置く。
  • zoneを忘れる。UTPサーバーでジョブが間違った時間に実行されます。
  • 同じメソッドに@Scheduledと@Transactionalの両方を置くことは機能するかもしれません。ただし、ロジックを@Transactionalサービスに移動する方が、テストしやすく、読みやすくなります。

10. 結論

この記事では以下を学びました。

  • @EnableSchedulingでスケジューリングを有効にする。
  • fixedRate(実行開始から計算)とfixedDelay(実行終了から計算)を使い分ける。
  • タイムゾーン付きの6フィールドcron式を書く。
  • スケジュールを設定ファイルに移動し、-でジョブを無効化する。
  • スレッドプールを増やすか仮想スレッドを使用してジョブが互いにブロックしないようにする。
  • 例外を処理してAwaitilityでテストを書く。

単一インスタンスアプリケーションで固定スケジュールがある場合、@Scheduledで十分です。システムが複数インスタンスにスケール出したり、より高い信頼性が必要になったときは、本番環境で問題が発生する前にShedLockやQuartzの導入を検討してください。

spring-bootschedulingcronjava