在使用虚拟线程(Executor)时,MDC与安全上下文的传播会失败
我正在把一个Spring Boot 3.x应用迁移到使用Java虚拟线程。我已经把标准异步执行器替换为虚拟线程执行器:
@Bean
public Executor taskExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
当一个请求到来时,过滤器会成功填充 SecurityContextHolder 和SLF4J的 MDC(带着一个 traceId)。然而,当我使用 @Async 或 CompletableFuture.supplyAsync(..., taskExecutor) 将任务下放时,子虚拟线程会同时丢失MDC上下文和认证对象。
我知道对于标准ThreadPool,可以使用 TaskDecorator,但由于虚拟线程不应该被池化,注入一个标准装饰器似乎是一种反模式。我也查看过 micrometer-context-propagation 库,但我不确定如何在Spring生成的所有虚拟线程上全局接入,而不必为每个 Runnable 手动包裹。
在Spring Boot 3中,自动将 ThreadLocal 值(MDC、SecurityContext)传播到虚拟线程的规范、生产就绪的方式是什么?无需为每次异步调用编写样板的上下文包装代码?
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@SpringBootApplication
public class VirtualThreadContextApp {
public static void main(String[] args) {
SpringApplication.run(VirtualThreadContextApp.class, args);
}
// 1. Define the Virtual Thread Executor (Java 21+)
@Bean
public ExecutorService virtualThreadExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
@RestController
class ContextTestController {
private static final Logger log = LoggerFactory.getLogger(ContextTestController.class);
private final ExecutorService executor;
ContextTestController(ExecutorService virtualThreadExecutor) {
this.executor = virtualThreadExecutor;
}
@GetMapping("/test-context")
public String testContextLoss() throws Exception {
// 2. Simulate a Web Filter setting MDC and SecurityContext
MDC.put("traceId", "req-12345");
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("demo-user", "password")
);
log.info("Main Thread - traceId: {}, user: {}",
MDC.get("traceId"),
SecurityContextHolder.getContext().getAuthentication().getName());
// 3. Offload work to a Virtual Thread
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
// BUG: Context is lost here! MDC is null, SecurityContext is empty.
String childTraceId = MDC.get("traceId");
String childUser = SecurityContextHolder.getContext().getAuthentication() != null ?
SecurityContextHolder.getContext().getAuthentication().getName() : "NULL";
log.info("Virtual Thread - traceId: {}, user: {}", childTraceId, childUser);
}, executor);
future.get(); // Block to wait for completion
// Cleanup
MDC.clear();
SecurityContextHolder.clearContext();
return "Check console logs to see context loss.";
}
}
解决方案
原生MDC与 Spring Security的解决方案是
@Bean
public ExecutorService virtualThreadExecutor() {
return Executors.newThreadPerTaskExecutor(r -> {
final Map<String, String> map = MDC.getCopyOfContextMap();
final SecurityContext context = SecurityContextHolder.getContext();
return Thread.ofVirtual().unstarted(() -> {
MDC.setContextMap(map);
SecurityContextHolder.setContext(context);
try {
r.run();
} finally {
MDC.clear();
SecurityContextHolder.clearContext();
}
});
});
}
这当然没有基于Observation/Micrometer的方案那样优雅、强大。
在Spring Boot 3中,自动将ThreadLocal值(MDC、SecurityContext)传播到虚拟线程的规范、生产就绪的方式是什么?
这是一个很好的问题。
首先,上下文传播,以及我们最近讨论的 [Graceful Shutdown],与池化是正交的(独立的),即便虚拟线程不建议进行池化,上下文传播的问题仍然是一样的:在调用一个 Runnable 任务时,必须检索它的状态,然后把它设置/复制到执行该任务的线程上,虚拟线程还是平台线程,均无所谓。因此,你所问的传播到虚拟线程的规范做法,基本上和传统的平台线程相同。
另一方面,正如从 [虚拟线程指南] 可以推断的,虚拟线程的原生传播方式,是使用 ScopedValues而不是 ThreadLocals。然而,像MDC和 Spring Security这样的框架并不非常热衷于采用这一思路。这个话题在Stack Overflow的讨论中有专门的条目,特别是 [Spring Security Virtual Threads and ThreadLocal](Spring Security)以及 [Logback: availability of MDCs in forks created inside a StructuredTaskScope](MDC);你也可以查看我为这两种情况整理的自制解决方案。