スケジュールタスクと非同期処理
スケジュールタスクと非同期処理はバックエンド効率向上の鍵です。スケジュールすべきものはスケジュールし, 並行実行できるものは並行実行することで, ユーザーを不必要に待たせないようにします。
1. 学ぶ内容
@EnableSchedulingとCron / FixedRate / FixedDelay式@EnableAsyncと@Asyncによる非同期実行- カスタムスレッドプール
ThreadPoolTaskExecutorの設定 - 非同期例外処理
AsyncUncaughtExceptionHandler - Aliceが30分以内に支払われない注文を自動キャンセルするスケジュールタスクを実装
2. ビジネス開発者のリアルストーリー
(1) ペインポイント:未支払い注文の蓄積
AliceはOrderFlowで大量の未支払い注文の蓄積を発見しました。ユーザーが注文を置いたまま支払わず, 在庫がロックされ, 他のユーザーが購入できなくなっていました。Charlieの報告では, 1日約2,000件のタイムアウト注文があり, 合計50,000ドル相当の在庫がロックされていました。以前は30分ごとにデータベースを確認して手動でキャンセルしており, 非効率で見落としが発生していました。
(2) @Scheduledによる解決策
Spring Bootにはスケジュールタスクが内蔵されており, 1つのアノテーションで実装できます:
@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時に実行 |
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スケジュールタスク
@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);
}
}
出力:
// 実行成功
(2) ▶ サンプル:Cron式によるスケジュールタスク
@Component
public class DailyReportTask {
@Scheduled(cron = "0 0 2 * * ?") // 毎日午前2時
public void generateDailyReport() {
// 日次注文統計レポートの生成
}
@Scheduled(cron = "0 0 0 1 * ?") // 毎月1日の深夜0時
public void generateMonthlyReport() {
// 月次レポートの生成
}
}
出力:
// 実行成功
| 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) ▶ サンプル:通知の並行送信 (ブロッキング)
@Service
public class OrderService {
public Order createOrder(CreateOrderRequest request) {
Order order = saveOrder(request);
emailService.sendConfirmation(order); // 500msブロック
smsService.sendNotification(order); // 200msブロック
return order; // 合計:700msの無駄
}
}
出力:
// 実行成功
(2) ▶ サンプル:通知の非同期送信 (ノンブロッキング)
@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; // 即座に返却
}
}
出力:
// 実行成功
| 次元 | 同期 | 非同期 |
|---|---|---|
| 呼び出し方法 | ブロックして結果を待機 | 即座に返却, バックグラウンドで実行 |
| 応答時間 | 全操作の合計時間 | コア業務操作の時間のみ |
| エラー処理 | 直接try-catchで処理 | AsyncUncaughtExceptionHandler |
| ユースケース | 結果の待機が必要な操作 | 通知, ログ, 非重要操作 |
5. カスタムスレッドプール
(1) デフォルトスレッドプールの問題点
Spring BootはデフォルトでSimpleAsyncTaskExecutorを使用しますが, これはタスクごとに新しいスレッドを作成し, スレッド数に制限がないため, OutOfMemoryErrorが発生する可能性があります。
(1) ▶ サンプル:カスタムThreadPoolTaskExecutor
@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);
};
}
}
出力:
// 実行成功
| パラメータ | 意味 | 推奨値 |
|---|---|---|
corePoolSize |
コアスレッド数 (常駐) | CPUコア数 |
maxPoolSize |
最大スレッド数 | CPUコア数 x 2 |
queueCapacity |
キュー容量 | 100-1,000 |
threadNamePrefix |
スレッド名プレフィックス | プロジェクト名-async- |
rejectedExecutionHandler |
拒否ポリシー | CallerRunsPolicy |
| 拒否戦略 | 動作 | 適用シナリオ |
|---|---|---|
AbortPolicy |
RejectedExecutionExceptionをスロー | デフォルト, フェイルファスト |
CallerRunsPolicy |
呼び出し元スレッドで実行 | 劣化するが破棄しない |
DiscardPolicy |
サイレントに破棄 | 許容可能な損失 |
DiscardOldestPolicy |
最も古いタスクを破棄 | リアルタイム性を優先 |
6. 非同期例外処理
(1) ▶ サンプル:AsyncUncaughtExceptionHandler
@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);
// 監視システムにパブリッシュすることも可能
};
}
}
出力:
// 実行成功
@Asyncのvoidメソッドがスローする例外は呼び出し元に伝播されず, AsyncUncaughtExceptionHandlerで処理する必要があります。CompletableFutureを返すメソッドがスローする例外はfuture.exceptionally()で処理できます。
7. 総合例:OrderFlowタイムアウトキャンセル + 非同期通知
// 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);
}
}
❓ よくある質問
TaskSchedulerスレッドプールを設定するか, @Asyncを使って時間のかかる操作をスレッドプールに委譲してください。fixedRateとfixedDelayの違いは何ですか?fixedRateは前回のタスクの開始時刻からカウントダウンします。タスクが間隔より長くかかる場合, 連続して実行されたり他のタスクと重複する可能性があります。fixedDelayは前回のタスクの終了時刻からカウントするため, タスク間の固定間隔が確保され, 重複しません。ScheduledTaskRegistrarを使ってプログラムでタスクを登録するか, 設定センターで動的にリフレッシュできます。簡単な方法:fixedRateString = "${interval}"—外部設定で制御します。CompletableFutureを返す非同期メソッドとvoidを返すメソッドの違いは何ですか?voidメソッドは「発火して忘れる」方式で, 呼び出し元は結果を気にせず, 例外はAsyncUncaughtExceptionHandlerで処理されます。CompletableFutureメソッドは結果と例外のチェーン処理が可能で, 呼び出し元により多くの制御を与えます。📖 まとめ
@Scheduledの3つのスケジューリング方法:fixedRate (固定間隔), fixedDelay (固定遅延), cron (指定時刻)@Asyncは時間のかかる操作が呼び出し元をブロックするのを防ぐ。通知やログなどの非重要操作に適している- デフォルトのスレッドプールはスレッド数に制限がない。本番環境では必ず
ThreadPoolTaskExecutorを自分で設定すること AsyncUncaughtExceptionHandlerでvoid非同期メソッドの例外を処理- スケジュールタスクを複数インスタンスにデプロイする場合, 分散ロック (ShedLockなど)で重複実行を防止する必要がある
@Asyncの同じクラス内呼び出しは機能しない。クラスの分割またはプロキシの注入が必要
📝 練習問題
-
基本問題 (難易度 ⭐):OrderFlowのスケジュールタスクを実装し, 5分ごとにPENDINGステータスで30分経過した注文を自動キャンセルしてください。
-
応用問題 (難易度 ⭐⭐):注文確認メールとSMS通知を@Asyncで非同期送信に変更し, カスタムスレッドプール (core=5, max=20)を設定し, 注文APIの応答時間が大幅に短縮されたことを確認してください。
-
チャレンジ (難易度 ⭐⭐⭐):ShedLockを統合してスケジュールタスクの分散ロックを実装し, OrderFlowが複数インスタンスにデプロイされても, 常に1インスタンスのみがスケジュールタスクを実行するようにしてください。ロックの粒度とタイムアウト設計を検討してください。



