本文摘要:线上接口偶发 500,按 traceId 查不到对应异常堆栈。根因是 MDC 未跨线程传递,用 TaskDecorator 在任务前后恢复即可。
一、问题与结论
现象 :POST /order 每天约 0.3% 请求返回 500,网关日志里有 traceId,把 trace-a1b2 拿到应用日志里检索,只能看到一行 请求开始,没有任何 ERROR 行------错误比错误本身更难对付,因为你不知道它是 NPE、超时还是数据竞争。
环境 :Spring Boot 3.2.5、JDK 17、Logback、@Async 由 ThreadPoolTaskExecutor 执行。
本文要验证的结论 :500 的真实堆栈发生在异步线程内,日志检索不到有两层原因------MDC 的 traceId 与 RequestContextHolder 都是 ThreadLocal,没有跨线程传到 async-* 线程;同时 CompletableFuture 被 get() 包装成 ExecutionException(或 void 方法把异常交出去后只留下一条无 traceId 的记录),真正根因始终没被按 traceId 收录。Spring Boot 3.2 不会自动为 @Async 配置上下文传播,即使 spring-boot-starter-actuator 已在依赖树中。
二、排查与选择依据
排查路径只有三步,关键是先把线程名 打进日志(%thread),否则无法区分两条日志来自哪个线程。
- 网关确认 500 的 traceId,应用日志
grep 'trace-a1b2' app.log,只命中请求线程两行。 - 改 pattern 加
%thread后重启复现,异步线程的MDC为空。 - 把
future.get()的堆栈打全,看到NullPointerException指向attrs.getRequest(),attrs为 null。
text
2024-06-15 10:00:00.001 INFO [http-nio-8080-exec-3] [trace-a1b2] 请求开始, orderId=order123
2024-06-15 10:00:00.005 INFO [async-1] [] 异步校验开始, orderId=order123
2024-06-15 10:00:00.010 ERROR [http-nio-8080-exec-3] [trace-a1b2] 订单处理失败, orderId=order123
java.util.concurrent.ExecutionException: java.lang.NullPointerException
at OrderController.createOrder(OrderController.java:24)
... Caused by: java.lang.NullPointerException (AsyncService.validate)
第二行的 [] 是空 traceId:按 traceId 全文检索时它根本不存在,这就是"500 无日志"的直接来源。MySQL 慢查询归因、Redis 大 Key 卡顿的排障同理依赖这条链路,异步线程日志一旦断链,EXPLAIN 结果和 SLOW LOG 都无法关联到具体请求。
替代方案与取舍
| 方案 | 选择条件 | 代价 | 边界(不该用的场景) |
|---|---|---|---|
本文方案:TaskDecorator 捕获/恢复 MDC 与 RequestAttributes |
单体 MVC、异步点集中、只依赖少数几个上下文 | 需自维护装饰器,每新增一个 ThreadLocal 都要登记 |
同时依赖 SecurityContextHolder、自定义上下文且调用链分散时,逐个登记的维护成本超过收益,改用显式传参 |
显式传参:traceId 作为方法参数 |
异步方法职责单一,不读取请求对象 | 方法签名变长,调用点分散 | 需要访问 HttpServletRequest 本体的逻辑无法传参,不该硬套 |
Micrometer context-propagation / Tracing 注入 |
已引入 actuator 或分布式追踪体系 | 需注册 ThreadLocalAccessor,覆盖范围要逐项确认 |
小服务为修一个 500 引入追踪体系,成本不划算 |
微型服务、异步方法只做一件事时,表格第二行更省事;项目已经跑着 OpenTelemetry 就直接用第三行,不必再手写装饰器。
三、关键原理
上下文为什么丢 :MDC 底层是 ThreadLocal<Map>,RequestContextHolder 底层是 ThreadLocal<RequestAttributes>。任务提交到线程池后,执行线程是池里复用的旧线程,它的 ThreadLocal 只有两种状态------上个任务清理过则为空(上下文丢失),没清理过则残留上一个请求的 traceId(脏数据,比为空更危险,会把两个请求的日志串在一起)。

为什么 InheritableThreadLocal 救不了 :它只在子线程被创建那一刻从父线程复制值,线程池复用的线程早就创建完了,继承逻辑不再触发。
异常为什么看不见 :CompletableFuture 类型的 @Async,异常封装在 future 里,调用方不 get() 就没人读;void 类型的异常交给 AsyncUncaughtExceptionHandler,默认实现只输出一条产生于异步线程、traceId 为空的记录------按 traceId 检索等同于没有。装饰器负责传播上下文,异常兜底负责留痕,缺任何一环问题依旧。
四、可运行示例
环境与输入 :JDK 17、Spring Boot 3.2.5,依赖 spring-boot-starter-web 与 spring-boot-starter-actuator;输入为 orderId=order123。
复现代码(问题版):
java
@PostMapping("/order")
public String createOrder(@RequestParam String orderId) {
MDC.put("traceId", "trace-" + orderId);
log.info("请求开始, orderId={}", orderId);
try {
return asyncService.validate(orderId).get();
} catch (Exception e) {
log.error("订单处理失败, orderId={}", orderId, e);
return "ORDER_FAILED";
} finally {
MDC.clear();
}
}
@Async
public CompletableFuture<String> validate(String orderId) {
log.info("异步校验开始, orderId={}", orderId); // traceId 为空
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
String ua = attrs.getRequest().getHeader("User-Agent"); // attrs == null → NPE
return CompletableFuture.completedFuture("validated:" + orderId);
}
修复代码:
java
@Configuration
public class AsyncConfig implements AsyncConfigurer {
private static final Logger log = LoggerFactory.getLogger(AsyncConfig.class);
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(4);
ex.setMaxPoolSize(8);
ex.setThreadNamePrefix("async-");
ex.setTaskDecorator(new ContextAwareTaskDecorator());
ex.initialize();
return ex;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (t, m, p) -> log.error("异步方法异常: {}.{}, 参数={}",
m.getDeclaringClass().getSimpleName(), m.getName(), Arrays.toString(p), t);
}
}
class ContextAwareTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable task) {
Map<String, String> mdc = MDC.getCopyOfContextMap(); // 提交线程快照
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
return () -> {
try {
if (mdc != null) MDC.setContextMap(mdc);
if (attrs != null) RequestContextHolder.setRequestAttributes(attrs);
task.run();
} finally {
MDC.clear(); // 防止线程复用残留
RequestContextHolder.resetRequestAttributes();
}
};
}
}
操作步骤:
bash
mvn -q spring-boot:run
curl -X POST 'http://localhost:8080/order?orderId=order123'
grep 'async-1' logs/app.log | grep 'trace-a1b2'
预期输出 :grep 命中异步线程那行 异步校验开始,且行内 traceId 为 trace-a1b2,不再出现 []。
实际输出(生产复现日志,本文写作环境未重新运行验证,接入服务后以你的日志为准):
text
INFO [async-1] [trace-a1b2] 异步校验开始, orderId=order123
常见失败与修复 :装饰器只写 MDC.setContextMap(mdc) 而没有 finally 清理,下一个复用 async-1 的请求会打上上一个请求的 traceId,日志串味。修复方式就是上面代码里的 finally { MDC.clear(); RequestContextHolder.resetRequestAttributes(); },缺任一行都会残留。
五、验证结果与边界
验证方法(连续执行两次,确保线程复用):
bash
for i in 1 2; do curl -X POST "http://localhost:8080/order?orderId=order$i"; done
grep -E 'async-1.*异步校验' logs/app.log
预期输出 :两次请求的日志都出现在 async-1 上,traceId 分别为 trace-order1、trace-order2,互不串味,且 RequestContextHolder 不再触发 NPE,接口分别返回 validated:order1 与 validated:order2 而非 ORDER_FAILED。若第二次仍显示第一次的 traceId,说明 finally 清理缺失。
边界与未验证项 :Micrometer context-propagation 的默认 ThreadLocalAccessor 是否已覆盖 MDC 与 RequestContextHolder,本文未逐项实测,接入前需自行确认,未确认时按第一节的装饰器方案兜底。装饰器只在 @Async 走 getAsyncExecutor() 返回的执行器时生效,若项目里存在多个 ThreadPoolTaskExecutor bean 或 @Async(executor = "...") 指定了其他执行器,需为对应执行器单独设置 TaskDecorator。
何时不该用本方案 :异步方法需要读取 SecurityContextHolder、租户上下文等多个 ThreadLocal,且散落在十几个服务里,逐个登记会持续漏项------此时要么改显式传参,要么引入 tracing 框架统一传播;纯异步批处理任务本来就没有请求上下文,不需要装饰器,直接给日志打自己的批次 ID 即可。
思考
- 对只按 traceId 检索的日志体系,装饰器传播和显式传参哪个更容易做静态审计、避免漏传?
- 异步方法同时依赖
SecurityContextHolder时,是继续登记 accessor,还是把鉴权结果作为参数传入更稳妥?