Spring Boot 4.1で@AsyncのtraceIdを引き継ぐ方法:コンテキスト伝播入門(traceIdが途切れる問題を解決する)

Spring Bootで @Async を使うと、重い処理を別スレッドで実行できます。

たとえば、注文登録後にメール送信や外部API通知を非同期で実行するようなケースです。

@Async
public void sendOrderMail(String orderId) {
    log.info("注文メールを送信します。orderId={}", orderId);
}

ただ、ObservabilityやOpenTelemetryを入れているアプリでは、ここで少し困ることがあります。

それが、非同期処理に入った瞬間にログのtraceIdが途切れる問題です。

Spring Boot 4.1では、この問題に対応しやすくするために、@Async で別スレッドへ処理が移ってもコンテキストを伝播できる仕組みが追加されています。Spring Boot 4.1のリリースノートでも、@Async によって別スレッドで実行されるメソッドへコンテキストを自動伝播できるようになったと説明されています。

@Asyncで何が起きるのか

通常のWeb APIでは、1つのHTTPリクエストに対して traceId が付きます。

HTTP Request
  ↓
Controller
  ↓
Service
  ↓
Repository

この流れが同じスレッド内で処理されている場合、ログに同じ traceId が出ます。

[traceId=abc123] 注文を受け付けました
[traceId=abc123] 注文データを保存しました
[traceId=abc123] レスポンスを返します

ところが、途中で @Async を使うと処理が別スレッドへ移ります。

HTTP Request
  ↓
Controller
  ↓
Service
  ↓
@Async Service  ← 別スレッド

このとき、以前は非同期側のログで traceId が消えたり、トレースが分断されたりすることがありました。

[traceId=abc123] 注文を受け付けました
[traceId=abc123] 注文データを保存しました
[traceId=      ] 注文メールを送信します

Springのブログでも、@AsyncAsyncTaskExecutor でスレッドが切り替わると、ThreadLocalに保持されたコンテキストが新しいスレッドへ移らないため、ログのtrace IDやトレースのspanが失われることがあると説明されています。

つまり、@Async 自体が悪いわけではありません。
スレッドが変わったときに、観測用の情報をどう引き継ぐかが問題になります。

Spring Boot 4.1ではどう解決するのか

Spring Boot 4.1では、自動構成された AsyncTaskExecutor を使っている場合、次の設定でコンテキスト伝播を有効化できます。

spring:
  task:
    execution:
      propagate-context: true

Spring BootのObservabilityドキュメントでも、@Async メソッドで自動構成された AsyncTaskExecutor を使う場合は、spring.task.execution.propagate-context プロパティでコンテキスト伝播を有効化すると説明されています。

これにより、非同期処理側でも同じリクエストの文脈を追いやすくなります。

[traceId=abc123] 注文を受け付けました
[traceId=abc123] 注文データを保存しました
[traceId=abc123] 注文メールを送信します

本番障害の調査では、この差がかなり大きいです。
ログを見たときに「この非同期処理は、どのリクエストから始まったのか」が追えるようになります。

最小構成の設定例

まず、GradleにはWeb、Actuator、OpenTelemetry関連の依存関係を追加します。

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'

    // Spring Boot 4系のOpenTelemetryスターター
    implementation 'org.springframework.boot:spring-boot-starter-opentelemetry'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

Spring Boot 4.0以降では、Spring Boot公式のOpenTelemetryスターターとして spring-boot-starter-opentelemetry が用意されています。

次に、application.yml です。

spring:
  application:
    name: async-context-sample

  task:
    execution:
      propagate-context: true
      pool:
        core-size: 8
        max-size: 16
        queue-capacity: 100

management:
  tracing:
    sampling:
      probability: 1.0

ローカル検証では、トレースを確認しやすくするために sampling.probability: 1.0 にしています。
本番ではデータ量が増える可能性があるため、必要に応じてサンプリング率を下げます。

@Asyncを使った実装例

まず、@Async を有効化します。

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
@EnableAsync
public class AsyncContextSampleApplication {

    public static void main(String[] args) {
        SpringApplication.run(AsyncContextSampleApplication.class, args);
    }
}

次に、Controllerを作ります。

package com.example.demo.order;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

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

    private final OrderService orderService;

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

    @PostMapping("/{orderId}/complete")
    public String complete(@PathVariable String orderId) {
        log.info("注文完了APIを開始します。orderId={}", orderId);

        orderService.completeOrder(orderId);

        log.info("注文完了APIを終了します。orderId={}", orderId);
        return "OK";
    }
}

Serviceでは、注文処理の後に非同期で通知を投げます。

package com.example.demo.order;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

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

    private final OrderNotificationService orderNotificationService;

    public OrderService(OrderNotificationService orderNotificationService) {
        this.orderNotificationService = orderNotificationService;
    }

    public void completeOrder(String orderId) {
        log.info("注文を完了します。orderId={}", orderId);

        // 実際はDB更新などが入る
        orderNotificationService.sendCompleteNotification(orderId);
    }
}

非同期処理側です。

package com.example.demo.order;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class OrderNotificationService {

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

    @Async
    public void sendCompleteNotification(String orderId) {
        log.info("注文完了通知を送信します。orderId={}", orderId);

        // 実際はメール送信、Slack通知、外部API連携などを行う
        sleep();

        log.info("注文完了通知を送信しました。orderId={}", orderId);
    }

    private void sleep() {
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("interrupted", e);
        }
    }
}

この構成で spring.task.execution.propagate-context: true を有効にしておくと、Controller、Service、@Async 側のログを同じtraceIdで追いやすくなります。

自前のExecutorを使っている場合

実務では、@Async 用のExecutorを自分で定義していることがあります。

@Bean(name = "notificationExecutor")
public Executor notificationExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(8);
    executor.setMaxPoolSize(16);
    executor.setQueueCapacity(100);
    executor.setThreadNamePrefix("notification-");
    executor.initialize();
    return executor;
}

この場合、Spring Bootの自動構成された AsyncTaskExecutor ではないため、spring.task.execution.propagate-context: true だけでは期待通りにならない場合があります。

Spring Bootのドキュメントでは、自分で AsyncTaskExecutor を構成している場合は、ContextPropagatingTaskDecorator のBeanを登録する必要があると説明されています。

package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.support.ContextPropagatingTaskDecorator;

@Configuration(proxyBeanMethods = false)
public class AsyncContextPropagationConfig {

    @Bean
    public ContextPropagatingTaskDecorator contextPropagatingTaskDecorator() {
        return new ContextPropagatingTaskDecorator();
    }
}

自前Executorに明示的に設定するなら、次のようにします。

package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration(proxyBeanMethods = false)
public class AsyncExecutorConfig {

    @Bean(name = "notificationExecutor")
    public Executor notificationExecutor(
            ContextPropagatingTaskDecorator taskDecorator
    ) {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8);
        executor.setMaxPoolSize(16);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("notification-");
        executor.setTaskDecorator(taskDecorator);
        executor.initialize();
        return executor;
    }

    @Bean
    public ContextPropagatingTaskDecorator contextPropagatingTaskDecorator() {
        return new ContextPropagatingTaskDecorator();
    }
}

そして、@Async 側でExecutor名を指定します。

@Async("notificationExecutor")
public void sendCompleteNotification(String orderId) {
    log.info("注文完了通知を送信します。orderId={}", orderId);
}

ContextPropagatingTaskDecorator は、別スレッドで実行されるタスクに対してコンテキストを引き継ぐためのTaskDecoratorです。Spring FrameworkのJavadocでも、logging contextやobservation contextの復元に役立つ一方で、多数の小さなタスクを実行するアプリではオーバーヘッドに注意するよう説明されています。

実務での使いどころ

この設定が効くのは、特に次のような処理です。

・メール送信
・Slack / Teams通知
・外部API連携
・S3アップロード
・PDF生成
・監査ログ登録
・重い集計処理
・バッチ内の並列処理

たとえば、注文完了APIの中でメール送信を非同期化している場合、障害時に次のような調査ができます。

1. 注文完了APIでエラーが出る
2. ログからtraceIdを確認する
3. 同じtraceIdで非同期通知処理のログを探す
4. 外部API連携やメール送信の失敗箇所を特定する

traceIdが非同期側に引き継がれていないと、ここで調査が途切れます。

注文APIのログ:
  traceIdあり

非同期メール送信のログ:
  traceIdなし

これだと、本番障害時に「どのリクエストから発生した非同期処理なのか」を追いづらくなります。

Spring Boot 4.1の @Async コンテキスト伝播は、こういう地味だけど痛い問題を減らしてくれる機能です。

注意点

まず、@Async は同じクラス内のメソッド呼び出しでは期待通りに動かないことがあります。

@Service
public class OrderService {

    public void completeOrder(String orderId) {
        // 同じクラス内の呼び出しだと @Async が効かないことがある
        sendMailAsync(orderId);
    }

    @Async
    public void sendMailAsync(String orderId) {
        // ...
    }
}

実務では、@Async メソッドを別のSpring Beanに分けるのが安全です。

OrderService
  ↓
OrderNotificationService の @Async メソッドを呼ぶ

次に、コンテキスト伝播は便利ですが、すべての非同期処理に無条件で使えばよいわけではありません。
ContextPropagatingTaskDecorator にはタスクを包む処理が入るため、極端に短いタスクを大量に投げるような処理では、オーバーヘッドも考慮します。

また、traceIdが引き継がれることと、非同期処理の失敗を正しく扱えることは別問題です。
@Async の例外、リトライ、タイムアウト、キュー詰まりは別途設計が必要です。

・非同期処理の例外をログに出す
・必要ならリトライする
・外部API連携にはタイムアウトを設定する
・Executorのキューサイズを無制限にしない
・処理失敗時のステータス管理を考える

まとめ

Spring Bootで @Async を使うと、重い処理や後続処理を別スレッドに逃がせます。

一方で、スレッドが切り替わることでログの traceId やObservabilityのコンテキストが途切れることがあります。

Spring Boot 4.1では、spring.task.execution.propagate-context: true を設定することで、自動構成された AsyncTaskExecutor に対してコンテキスト伝播を有効化できます。自前Executorを使う場合は、ContextPropagatingTaskDecorator を登録するのがポイントです。

この記事のポイントです。

・@Asyncでは処理が別スレッドに移る
・スレッドが変わるとtraceIdや観測コンテキストが途切れることがある
・Spring Boot 4.1では@Asyncのコンテキスト伝播を有効化できる
・自動構成のAsyncTaskExecutorなら spring.task.execution.propagate-context を使う
・自前Executorなら ContextPropagatingTaskDecorator を設定する
・メール送信、外部API通知、S3連携、PDF生成など実務処理で役立つ
・ログ調査や分散トレーシングの精度が上がる

@Async は便利ですが、非同期にした瞬間に障害調査が難しくなることがあります。

Spring Boot 4.1のコンテキスト伝播を使えば、非同期処理でもリクエストの流れを追いやすくなります。
OpenTelemetryやログ監視を入れているSpring Bootアプリでは、ぜひ確認しておきたい設定です。

是非フォローしてください

最新の情報をお伝えします

類似投稿