Spring Boot 3.2 偶发500无日志:线程池复用导致请求上下文丢失

本文摘要:线上接口偶发 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),否则无法区分两条日志来自哪个线程。

  1. 网关确认 500 的 traceId,应用日志 grep 'trace-a1b2' app.log,只命中请求线程两行。
  2. 改 pattern 加 %thread 后重启复现,异步线程的 MDC 为空。
  3. 把 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 即可。

思考

  1. 对只按 traceId 检索的日志体系,装饰器传播和显式传参哪个更容易做静态审计、避免漏传?
  2. 异步方法同时依赖 SecurityContextHolder 时,是继续登记 accessor,还是把鉴权结果作为参数传入更稳妥?

参考资料

相关推荐
杨凯凡3 小时前
Spring Boot 的 @Transactional 为什么会失效?从代理、自调用到异常回滚的排查实战
java·spring boot·spring·事务·后端开发
孤狼GPT3 小时前
ChatGPT、Codex数据库实战:MyBatis-Plus第一页查询正常,翻到第10000页为什么越来越慢?
mysql·chatgpt·mybatis-plus·codex·深分页
1314lay_10073 小时前
ABAP 程序性能优化总结
经验分享·笔记·性能优化
Decay__3 小时前
17_MySQL 监控体系构建 —— mysqld_exporter + Prometheus + Grafana 全栈实战
运维·数据库·sql·mysql·架构
Franciz小测测4 小时前
MySQL 增量备份实践:文件都在,为什么仍可能恢复失败?
数据库·mysql·binlog
IT规划师4 小时前
Redis 数据类型全解 - ZSet:两个编码,七种玩法(五)
redis·.net
实名上网宋凯宣12 小时前
Django
后端·python·django
我不会起名字32213 小时前
Redis 从 0 到 1(十七):订单 30 分钟不支付自动关闭——ZSet 延迟队列 Go 实现
redis·go·延迟队列·zset·订单