How to Achieve Asynchronous Thread‑Pool Traceability in Spring Boot with MDC

In high‑concurrency microservices, Spring Boot's default async thread pool loses the MDC‑based traceId, making log correlation impossible; this article shows how to capture the traceId in a filter, propagate it with a custom ThreadPoolTaskExecutor and MDC task decorator, and extend the solution to Feign, RestTemplate, and RestClient while demonstrating a price‑aggregation use case.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
How to Achieve Asynchronous Thread‑Pool Traceability in Spring Boot with MDC

Environment: Spring Boot 3.5.0.

1. Introduction

In microservice scenarios that call multiple third‑party APIs in parallel, the default async thread pool in Spring Boot copies MDC context only for the parent thread, causing the traceId to disappear in child threads. This makes log correlation and request tracing extremely difficult.

2. Practical Implementation

2.1 Generate traceId

A TraceIdFilter creates a unique traceId (or reads X-Trace-Id from the request) and stores it in MDC before the request is processed, clearing MDC afterwards.

@Component
public class TraceIdFilter extends OncePerRequestFilter {
    private static final String TRACE_ID = "traceId";
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        String traceId = request.getHeader("X-Trace-Id");
        if (traceId == null || traceId.isEmpty()) {
            traceId = UUID.randomUUID().toString();
        }
        MDC.put(TRACE_ID, traceId);
        response.setHeader("X-Trace-Id", traceId);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.clear();
        }
    }
}

2.2 Configure a custom thread pool

The pool is defined in TaskExecutorConfig and decorates each task with MdcTaskDecorator so that the parent MDC is restored in the child thread.

@Configuration
@EnableAsync
public class TaskExecutorConfig {
    @Value("${price.pool.core-size:3}") private int corePoolSize;
    @Value("${price.pool.max-size:10}") private int maxPoolSize;
    @Value("${price.pool.queue-capacity:100}") private int queueCapacity;
    @Value("${price.pool.thread-name-prefix:price-fetch-}") private String threadNamePrefix;

    @Bean(name = "packTaskExecutor")
    public Executor priceTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(corePoolSize);
        executor.setMaxPoolSize(maxPoolSize);
        executor.setQueueCapacity(queueCapacity);
        executor.setThreadNamePrefix(threadNamePrefix);
        executor.setThreadFactory(r -> new Thread(r, threadNamePrefix + new AtomicInteger(1).getAndIncrement()));
        executor.setTaskDecorator(new MdcTaskDecorator());
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(60);
        executor.initialize();
        return executor;
    }
}

2.3 MDC task decorator

public class MdcTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        Map<String, String> parent = MDC.getCopyOfContextMap();
        return () -> {
            Map<String, String> child = MDC.getCopyOfContextMap();
            try {
                if (parent != null) {
                    MDC.setContextMap(parent);
                } else {
                    MDC.clear();
                }
                runnable.run();
            } finally {
                if (child != null) {
                    MDC.setContextMap(child);
                } else {
                    MDC.clear();
                }
            }
        };
    }
}

2.4 Asynchronous aggregation service

The service calls three mock price APIs (Taobao, JD, Pinduoduo) in parallel using the custom executor.

@Service
public class PriceAggregateService {
    private final Executor packTaskExecutor;
    public PriceAggregateService(@Qualifier("packTaskExecutor") Executor packTaskExecutor) {
        this.packTaskExecutor = packTaskExecutor;
    }
    public CompletableFuture<String> getTaobaoPrice(String productId) {
        return CompletableFuture.supplyAsync(() -> {
            log.info("[淘宝] 查询商品价格,productId:{}", productId);
            Thread.sleep(500);
            return "淘宝:¥5999";
        }, packTaskExecutor);
    }
    // similar methods for JD and Pinduoduo omitted for brevity
}

2.5 Controller

@RestController
@RequestMapping("/api/price")
public class PriceAggregateController {
    private final PriceAggregateService priceAggregateService;
    public PriceAggregateController(PriceAggregateService priceAggregateService) {
        this.priceAggregateService = priceAggregateService;
    }
    @GetMapping("/{productId}")
    public Map<String, Object> getPrice(@PathVariable String productId) {
        log.info("接收商品比价请求,productId:{}", productId);
        CompletableFuture<String> taobao = priceAggregateService.getTaobaoPrice(productId);
        CompletableFuture<String> jd = priceAggregateService.getJdPrice(productId);
        CompletableFuture<String> pdd = priceAggregateService.getPddPrice(productId);
        CompletableFuture.allOf(taobao, jd, pdd).join();
        Map<String, Object> result = new HashMap<>();
        try {
            result.put("taobao", taobao.get());
            result.put("jd", jd.get());
            result.put("pdd", pdd.get());
        } finally {
            log.info("商品比价完成,productId:{}", productId);
        }
        return result;
    }
}

2.6 Logback configuration

Adding %X{traceId:-N/A} to the pattern makes the traceId appear in every log line.

<configuration scan="false" scanPeriod="15 seconds" debug="false">
  <property name="COMMON_PATTERN" value="%green(%d{23:mm:ss}) [%yellow(%thread)][%red(traceId=%X{traceId:-N/A})] %highlight(%-5level) %logger{36} - %msg%n"/>
  <appender name="TRACEX" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>${COMMON_PATTERN}</pattern>
      <charset>UTF-8</charset>
    </encoder>
  </appender>
  <springProfile name="dev | default">
    <root level="INFO">
      <appender-ref ref="TRACEX"/>
    </root>
  </springProfile>
</configuration>

2.7 Feign interceptor for trace propagation

public class FeignTraceInterceptor implements RequestInterceptor {
    public static final String TRACE_ID = "traceId";
    public static final String X_TRACE_ID = "X-Trace-Id";
    @Override
    public void apply(RequestTemplate template) {
        String traceId = MDC.get(TRACE_ID);
        if (traceId != null && !traceId.isEmpty()) {
            template.header(X_TRACE_ID, traceId);
        }
    }
}

@EnableFeignClients(defaultConfiguration = FeignDefaultConfiguration.class)
public class FeignConfig {}

@Configuration
public class FeignDefaultConfiguration {
    @Bean
    public FeignTraceInterceptor feignTraceInterceptor() {
        return new FeignTraceInterceptor();
    }
}

2.8 Global RestClient and RestTemplate interceptors

@Component
public class CustomRestClient implements RestClientCustomizer {
    public static final String TRACE_ID = "traceId";
    public static final String X_TRACE_ID = "X-Trace-Id";
    @Override
    public void customize(Builder builder) {
        builder.requestInterceptor((request, body, execution) -> {
            String traceId = MDC.get(TRACE_ID);
            request.getHeaders().add(X_TRACE_ID, traceId);
            return execution.execute(request, body);
        });
    }
}

@Component
public class CustomRestTemplate implements RestTemplateRequestCustomizer<ClientHttpRequest> {
    public static final String TRACE_ID = "traceId";
    public static final String X_TRACE_ID = "X-Trace-Id";
    @Override
    public void customize(ClientHttpRequest request) {
        String traceId = MDC.get(TRACE_ID);
        request.getHeaders().add(X_TRACE_ID, traceId);
    }
}

3. Summary

Before submitting a task, the filter captures the parent MDC (e.g., traceId=abc-123).

When the task runs in a child thread, the decorator restores the parent MDC, preserving traceability.

After execution, the original child MDC is restored to avoid memory leaks.

Console screenshots (shown in the original article) demonstrate that each log line now contains the same traceId across all asynchronous calls, enabling end‑to‑end request tracing in a microservice environment.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

microservicesloggingspring-bootthread-poolAsynctraceidmdc
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.