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.
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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
