404 Not Found

404 Not Found


nginx

スケジュールタスクと非同期処理

スケジュールタスクと非同期処理はバックエンド効率向上の鍵です。スケジュールすべきものはスケジュールし, 並行実行できるものは並行実行することで, ユーザーを不必要に待たせないようにします。

1. 学ぶ内容


2. ビジネス開発者のリアルストーリー

(1) ペインポイント:未支払い注文の蓄積

AliceはOrderFlowで大量の未支払い注文の蓄積を発見しました。ユーザーが注文を置いたまま支払わず, 在庫がロックされ, 他のユーザーが購入できなくなっていました。Charlieの報告では, 1日約2,000件のタイムアウト注文があり, 合計50,000ドル相当の在庫がロックされていました。以前は30分ごとにデータベースを確認して手動でキャンセルしており, 非効率で見落としが発生していました。

(2) @Scheduledによる解決策

Spring Bootにはスケジュールタスクが内蔵されており, 1つのアノテーションで実装できます:

JAVA
@Scheduled(fixedRate = 5 * 60 * 1000)  // 5分ごと
public void cancelExpiredOrders() {
    List<Order> expired = orderRepository.findByStatusAndCreatedAtBefore(
        "PENDING", Instant.now().minus(30, ChronoUnit.MINUTES));
    expired.forEach(order -> cancelOrder(order));
    log.info("Cancelled {} expired orders", expired.size());
}

(3) 成果

Aliceがスケジュールタスクを実装した後, 期限切れ注文は5分ごとに自動的にクリーンアップされ, 在庫は自動的に解放され, タイムアウト注文の蓄積は1日2,000件から0件に減少しました。同時に, @Asyncで通知メールを非同期送信に切り替えたことで, 注文APIの応答時間は800msから50msに短縮されました。


3. @Scheduledスケジュールタスク

(1) 3つのスケジューリング方法

方法 パラメータの説明 意味
FixedRate fixedRate 固定間隔 (前回の開始から計測) 5分ごとに実行
FixedDelay fixedDelay 固定遅延 (前回の終了から計測) 前回終了後5分待つ
Cron cron Cron式 毎日午前2時に実行
100%
gantt
    title FixedRate vs FixedDelay タイミング
    dateFormat X
    axisFormat %s

    section FixedRate
    Task A (5min) :a1, 0, 5
    Task B (5min) :a2, 5, 8
    Task C (5min) :a3, 10, 14

    section FixedDelay
    Task A (5min) :b1, 0, 5
    wait 3min     :b2, 5, 8
    Task B (5min) :b3, 8, 13
    wait 3min     :b4, 13, 16
次元 FixedRate FixedDelay Cron
実行間隔 固定 (前回の所要時間に関係なく) 固定 (前回終了後) 指定時刻
タスクの重複 重複する可能性あり 重複しない 重複する可能性あり
ユースケース ポーリング データ処理 定時バッチ処理

(1) ▶ サンプル:FixedRateスケジュールタスク

JAVA
@Component
@EnableScheduling
public class OrderScheduleTask {

    private final OrderService orderService;
    private static final Logger log = LoggerFactory.getLogger(OrderScheduleTask.class);

    public OrderScheduleTask(OrderService orderService) {
        this.orderService = orderService;
    }

    @Scheduled(fixedRate = 5 * 60 * 1000)  // 5分ごと
    public void cancelExpiredOrders() {
        log.info("期限切れ注文のクリーンアップを開始...");
        int cancelled = orderService.cancelExpiredOrders(Duration.ofMinutes(30));
        log.info("Cancelled {} expired orders", cancelled);
    }
}

出力:

TEXT
// 実行成功

(2) ▶ サンプル:Cron式によるスケジュールタスク

JAVA
@Component
public class DailyReportTask {

    @Scheduled(cron = "0 0 2 * * ?")  // 毎日午前2時
    public void generateDailyReport() {
        // 日次注文統計レポートの生成
    }

    @Scheduled(cron = "0 0 0 1 * ?")  // 毎月1日の深夜0時
    public void generateMonthlyReport() {
        // 月次レポートの生成
    }
}

出力:

TEXT
// 実行成功
Cronフィールド 意味 有効値
0-59
0-59
時間 0-23
月内の日 1-31
1-12
曜日 曜日 0-7 (0/7 = 日曜)
一般的なCron式 意味
0 0 2 * * ? 毎日午前2時
0 */5 * * * ? 5分ごと
0 0 0 1 * ? 毎月1日の深夜0時
0 0 9-17 * * MON-FRI 平日の午前9時〜午後5時 (毎時)

4. @Async非同期処理

(1) 同期 vs 非同期

(1) ▶ サンプル:通知の並行送信 (ブロッキング)

JAVA
@Service
public class OrderService {

    public Order createOrder(CreateOrderRequest request) {
        Order order = saveOrder(request);
        emailService.sendConfirmation(order);   // 500msブロック
        smsService.sendNotification(order);       // 200msブロック
        return order;  // 合計:700msの無駄
    }
}

出力:

TEXT
// 実行成功

(2) ▶ サンプル:通知の非同期送信 (ノンブロッキング)

JAVA
@Service
@EnableAsync
public class NotificationService {

    @Async
    public void sendOrderConfirmation(Order order) {
        // 別スレッドで実行される
        // 呼び出し元をブロックしない
        emailService.sendConfirmation(order);
        log.info("Confirmation email sent for order {}", order.getId());
    }

    @Async
    public CompletableFuture<String> sendSmsNotification(Order order) {
        smsService.sendNotification(order);
        return CompletableFuture.completedFuture("SMS sent");
    }
}

// OrderService内
@Service
public class OrderService {

    private final NotificationService notificationService;

    public Order createOrder(CreateOrderRequest request) {
        Order order = saveOrder(request);
        notificationService.sendOrderConfirmation(order);  // ノンブロッキング
        return order;  // 即座に返却
    }
}

出力:

TEXT
// 実行成功
次元 同期 非同期
呼び出し方法 ブロックして結果を待機 即座に返却, バックグラウンドで実行
応答時間 全操作の合計時間 コア業務操作の時間のみ
エラー処理 直接try-catchで処理 AsyncUncaughtExceptionHandler
ユースケース 結果の待機が必要な操作 通知, ログ, 非重要操作

5. カスタムスレッドプール

(1) デフォルトスレッドプールの問題点

Spring BootはデフォルトでSimpleAsyncTaskExecutorを使用しますが, これはタスクごとに新しいスレッドを作成し, スレッド数に制限がないため, OutOfMemoryErrorが発生する可能性があります。

(1) ▶ サンプル:カスタムThreadPoolTaskExecutor

JAVA
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("orderflow-async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(60);
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -> {
            log.error("Async exception in method {}: {}", method.getName(), ex.getMessage(), ex);
        };
    }
}

出力:

TEXT
// 実行成功
パラメータ 意味 推奨値
corePoolSize コアスレッド数 (常駐) CPUコア数
maxPoolSize 最大スレッド数 CPUコア数 x 2
queueCapacity キュー容量 100-1,000
threadNamePrefix スレッド名プレフィックス プロジェクト名-async-
rejectedExecutionHandler 拒否ポリシー CallerRunsPolicy
拒否戦略 動作 適用シナリオ
AbortPolicy RejectedExecutionExceptionをスロー デフォルト, フェイルファスト
CallerRunsPolicy 呼び出し元スレッドで実行 劣化するが破棄しない
DiscardPolicy サイレントに破棄 許容可能な損失
DiscardOldestPolicy 最も古いタスクを破棄 リアルタイム性を優先

6. 非同期例外処理

(1) ▶ サンプル:AsyncUncaughtExceptionHandler

JAVA
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

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

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (throwable, method, objects) -> {
            log.error("Async method [{}] threw exception: {}",
                method.getName(), throwable.getMessage(), throwable);
            // 監視システムにパブリッシュすることも可能
        };
    }
}

出力:

TEXT
// 実行成功
📌 重要ポイント: @Asyncのvoidメソッドがスローする例外は呼び出し元に伝播されず, AsyncUncaughtExceptionHandlerで処理する必要があります。CompletableFutureを返すメソッドがスローする例外はfuture.exceptionally()で処理できます。


7. 総合例:OrderFlowタイムアウトキャンセル + 非同期通知

JAVA
// OrderScheduleTask.java
package com.orderflow.schedule;

import com.orderflow.service.OrderService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Duration;

@Component
public class OrderScheduleTask {

    private static final Logger log = LoggerFactory.getLogger(OrderScheduleTask.class);
    private final OrderService orderService;

    public OrderScheduleTask(OrderService orderService) {
        this.orderService = orderService;
    }

    @Scheduled(fixedRateString = "${orderflow.order.expiry-check-interval:300000}")
    public void cancelExpiredOrders() {
        int timeoutMinutes = 30;
        int cancelled = orderService.cancelExpiredOrders(Duration.ofMinutes(timeoutMinutes));
        if (cancelled > 0) {
            log.info("Cancelled {} orders expired for {} minutes", cancelled, timeoutMinutes);
        }
    }

    @Scheduled(cron = "0 0 2 * * ?")
    public void generateDailyReport() {
        log.info("Generating daily order report...");
        orderService.generateDailyReport();
    }
}

// NotificationService.java
package com.orderflow.service;

import com.orderflow.model.Order;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;

@Service
public class NotificationService {

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

    @Async
    public void sendOrderConfirmation(Order order) {
        // メール送信のシミュレーション
        log.info("Sending confirmation email for order {}", order.getId());
    }

    @Async
    public void sendCancellationNotice(Order order) {
        log.info("Sending cancellation notice for order {}", order.getId());
    }

    @Async
    public CompletableFuture<String> sendSmsNotification(Order order) {
        log.info("Sending SMS for order {}", order.getId());
        return CompletableFuture.completedFuture("SMS sent for order " + order.getId());
    }
}

// AsyncConfig.java
@Configuration
@EnableAsync
@EnableScheduling
public class AsyncConfig implements AsyncConfigurer {
    private static final Logger log = LoggerFactory.getLogger(AsyncConfig.class);

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
        exec.setCorePoolSize(5);
        exec.setMaxPoolSize(20);
        exec.setQueueCapacity(100);
        exec.setThreadNamePrefix("orderflow-async-");
        exec.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        exec.setWaitForTasksToCompleteOnShutdown(true);
        exec.setAwaitTerminationSeconds(60);
        exec.initialize();
        return exec;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) ->
            log.error("Async error in {}: {}", method.getName(), ex.getMessage(), ex);
    }
}

❓ よくある質問

Q @Scheduledメソッドはシングルスレッドでしか実行されませんか?
A デフォルトではそうです。複数の@Scheduledメソッドが単一スレッドのスケジューラを共有するため, 長時間実行されるタスクは他のタスクをブロックします。解決策:TaskSchedulerスレッドプールを設定するか, @Asyncを使って時間のかかる操作をスレッドプールに委譲してください。
Q @Asyncは同じクラス内の呼び出しで有効になりますか?
A なりません。@Transactionalと同様に, @AsyncはAOPプロキシに依存しており, 同じクラス内のメソッド間の呼び出しはプロキシをバイパスします。解決策:1)別のサービスに分割する;2)自身のプロキシを注入する。
Q 長時間実行タスクにおけるfixedRatefixedDelayの違いは何ですか?
A fixedRateは前回のタスクの開始時刻からカウントダウンします。タスクが間隔より長くかかる場合, 連続して実行されたり他のタスクと重複する可能性があります。fixedDelayは前回のタスクの終了時刻からカウントするため, タスク間の固定間隔が確保され, 重複しません。
Q 本番環境で複数インスタンス間でスケジュールタスクの重複実行を防ぐにはどうすればよいですか?
A 1)データベースの行ロック (SELECT FOR UPDATE);2)分散ロック (Redis/ShedLock);3)1インスタンスのみでスケジューリングを有効にする。ShedLockライブラリの使用をお勧めします。この問題を解決するために特別に設計されています。
Q スケジュールタスクの時刻を動的に変更するにはどうすればよいですか?
A ScheduledTaskRegistrarを使ってプログラムでタスクを登録するか, 設定センターで動的にリフレッシュできます。簡単な方法:fixedRateString = "${interval}"—外部設定で制御します。
Q CompletableFutureを返す非同期メソッドとvoidを返すメソッドの違いは何ですか?
A voidメソッドは「発火して忘れる」方式で, 呼び出し元は結果を気にせず, 例外はAsyncUncaughtExceptionHandlerで処理されます。CompletableFutureメソッドは結果と例外のチェーン処理が可能で, 呼び出し元により多くの制御を与えます。

📖 まとめ


📝 練習問題

  1. 基本問題 (難易度 ⭐):OrderFlowのスケジュールタスクを実装し, 5分ごとにPENDINGステータスで30分経過した注文を自動キャンセルしてください。

  2. 応用問題 (難易度 ⭐⭐):注文確認メールとSMS通知を@Asyncで非同期送信に変更し, カスタムスレッドプール (core=5, max=20)を設定し, 注文APIの応答時間が大幅に短縮されたことを確認してください。

  3. チャレンジ (難易度 ⭐⭐⭐):ShedLockを統合してスケジュールタスクの分散ロックを実装し, OrderFlowが複数インスタンスにデプロイされても, 常に1インスタンスのみがスケジュールタスクを実行するようにしてください。ロックの粒度とタイムアウト設計を検討してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%