Spring Boot中ThreadLocal在一次HTTP请求上下文的完整生命周期

作者:袖梨 2026-08-07

Spring Boot中ThreadLocal在一次HTTP请求上下文的完整生命周期并不只看表面做法,关键还要理解相关条件、限制和后续影响。

从源码视角拆解 ThreadLocal 在一次 HTTP 请求中的完整生命周期:创建、填充、传播、消费、清理,以及跨线程失效的经典陷阱与修复方案。

Spring Boot中ThreadLocal在一次HTTP请求上下文的完整生命周期

一、为什么需要 ThreadLocal

在一个典型的 Spring Boot 推荐服务中,一次请求会穿过:

Filter → Interceptor → Controller → Service → Component → Util

每一层都需要打印日志,而日志里几乎都要带上 sessionId 做全链路追踪。如果把 sessionId 作为方法参数逐层传递:

// 反面教材:参数污染public RespData recommend(RawFeature rawFeature, String sessionId) {    menuService.getMenu(rawFeature, sessionId);    scoreService.score(rawFeature, sessionId);    rankService.rank(rawFeature, sessionId);    // ...}

sessionId 与业务逻辑无关,却出现在每个方法签名里,代码侵入性极强。

ThreadLocal 的核心思想是:把上下文绑定到线程,而非穿透方法签名。 同一个线程内任何代码都能通过静态方法取到当前请求的上下文,方法签名保持干净。

二、核心组件总览

本项目中有 6 个关键组件参与请求上下文的管理:

组件层级职责
CachedBodyFilterServlet Filter缓存请求体(最高优先级),使后续可重复读取 body
ApiLogInterceptorSpring Interceptor创建/清理 ThreadLocal,写业务日志
ApiLogContext上下文持有者ThreadLocal<ApiLogContext> 的静态封装
ApiLogAspectAOP 切面补充方法名/描述到上下文,记录方法级耗时
GlobalExceptionHandler全局异常处理异常时从上下文读取信息写错误日志
WebMvcConfig配置类注册拦截器,限定拦截路径 /api/**

它们之间的协作关系:

HTTP 请求  │  ▼┌─────────────────────────────────────────────────────────────┐│  CachedBodyFilter (HIGHEST_PRECEDENCE)                      ││  缓存 requestBody → 包装成 CachedBodyHttpServletRequest     ││  不碰 ThreadLocal                                           │└──────────────────────┬──────────────────────────────────────┘                       ▼┌─────────────────────────────────────────────────────────────┐│  ApiLogInterceptor.preHandle()                              ││  ① getOrCreate() → 创建 ApiLogContext 写入 ThreadLocal      ││  ② 填充 traceId / sessionId / httpMethod / uri / startTime  │└──────────────────────┬──────────────────────────────────────┘                       ▼┌─────────────────────────────────────────────────────────────┐│  ApiLogAspect (@Around)                                     ││  补充 methodName / methodDesc 到上下文                       │└──────────────────────┬──────────────────────────────────────┘                       ▼┌─────────────────────────────────────────────────────────────┐│  Controller                                                 ││  ApiLogContext.setSessionId(rawFeatures.getSessionId())     ││  调用 Service 层                                             │└──────────────────────┬──────────────────────────────────────┘                       ▼┌─────────────────────────────────────────────────────────────┐│  Service / Component / Util                                 ││  ApiLogContext.getSessionId() → 用于 log 日志                ││  ApiLogContext.get() → 读取其他上下文字段                    ││                                                             ││  ⚠ CompletableFuture.supplyAsync / parallelStream           ││  → 子线程: ApiLogContext.getSessionId() 返回 null            ││  → 需要手动捕获 sessionId(见第七节)                        │└──────────────────────┬──────────────────────────────────────┘                       ▼┌─────────────────────────────────────────────────────────────┐│  GlobalExceptionHandler (异常时)                             ││  从 ThreadLocal 读取上下文 → 写错误日志                       │└──────────────────────┬──────────────────────────────────────┘                       ▼┌─────────────────────────────────────────────────────────────┐│  ApiLogInterceptor.afterCompletion()                        ││  ① 补充 endTime / 异常信息                                   ││  ② 序列化 ApiLogDTO → 写 business.log                        ││  ③ finally { ApiLogContext.remove() } ← 清理 ThreadLocal     │└─────────────────────────────────────────────────────────────┘  │  ▼HTTP 响应返回,线程归还 Tomcat 线程池

三、ApiLogContext:上下文持有者

ApiLogContext 是整个机制的核心。它内部持有一个 ThreadLocal<ApiLogContext>,并对外暴露静态方法:

@Datapublic class ApiLogContext {    // ---- 上下文字段 ----    private String traceId;    private String sessionId;    private String httpMethod;    private String uri;    private long startTime;    private String requestBody;    private String responseBody;    private String status;    private String errorClass;    private String errorMsg;    private String methodName;    private String methodDesc;    private long endTime;    // ---- ThreadLocal ----    private static final ThreadLocal<ApiLogContext> CONTEXT = new ThreadLocal<>();    // 设置整个上下文对象    public static void set(ApiLogContext context) {        CONTEXT.set(context);    }    // 获取当前线程的上下文    public static ApiLogContext get() {        return CONTEXT.get();    }    // 清理当前线程的上下文    public static void remove() {        CONTEXT.remove();    }    // 获取或创建上下文(懒加载模式)    public static ApiLogContext getOrCreate() {        ApiLogContext ctx = CONTEXT.get();        if (ctx == null) {            ctx = new ApiLogContext();            CONTEXT.set(ctx);        }        return ctx;    }    // 便捷方法:只读 sessionId    public static String getSessionId() {        ApiLogContext ctx = CONTEXT.get();        return ctx != null ? ctx.sessionId : null;    }    // 便捷方法:只写 sessionId    public static void setSessionId(String sessionId) {        ApiLogContext ctx = getOrCreate();        ctx.sessionId = sessionId;    }}

设计要点:

  1. ThreadLocalprivate static final,全局唯一,每个线程持有独立的副本
  2. getOrCreate() 实现懒加载:第一次调用时创建实例并绑定到线程
  3. getSessionId() / setSessionId() 是面向业务层的便捷方法,避免每次都 get() 再判空

四、完整生命周期:一次 HTTP 请求的旅程

4.1 Filter 层:缓存请求体

@Component@Order(Ordered.HIGHEST_PRECEDENCE)  // 最高优先级,确保最先执行public class CachedBodyFilter implements Filter {    @Override    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)            throws IOException, ServletException {        if (request instanceof HttpServletRequest httpRequest) {            String contentType = httpRequest.getContentType();            if (contentType != null && contentType.contains("application/json")) {                // 将 InputStream 读取一次缓存到 byte[]                CachedBodyHttpServletRequest cachedRequest = new CachedBodyHttpServletRequest(httpRequest);                chain.doFilter(cachedRequest, response);                return;            }        }        chain.doFilter(request, response);    }}

为什么要这一步? Servlet 的 InputStream 只能读一次。Interceptor 的 preHandle 需要读取 body 提取 sessionId,Controller 的 @RequestBody 也需要反序列化 body。CachedBodyHttpServletRequest 把 body 缓存到 byte[],之后可以反复读取。

注意: Filter 层不碰 ThreadLocal,只负责请求体缓存。

4.2 Interceptor 层:创建上下文

preHandle 在 Controller 执行之前运行,负责初始化上下文:

@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {    // ① 创建上下文,绑定到当前线程    ApiLogContext ctx = ApiLogContext.getOrCreate();    // ② 填充请求元信息    ctx.setTraceId(UUID.randomUUID().toString());    ctx.setHttpMethod(request.getMethod());    ctx.setUri(request.getRequestURI());    ctx.setStartTime(System.currentTimeMillis());    ctx.setStatus("SUCCESS");    // ③ 从缓存的请求体中提取 sessionId    String requestBody = "";    if (request instanceof CachedBodyHttpServletRequest cachedRequest) {        requestBody = cachedRequest.getCachedBody();    }    ctx.setRequestBody(requestBody);    ctx.setResponseBody("");    ctx.setErrorClass(null);    ctx.setErrorMsg(null);    return true;}

preHandle 阶段暂未解析 sessionId(请求体已缓存但未反序列化),sessionId 在 Controller 层通过 rawFeatures.getSessionId() 设置。AOP 切面在 Controller 方法执行前后都能通过 ApiLogContext.getSessionId() 获取到值。

4.3 AOP 层:补充方法信息

@ApiLog 注解标注在 Controller 方法上,ApiLogAspect@Around 切面拦截这些方法:

@Around("@annotation(apiLog)")public Object around(ProceedingJoinPoint joinPoint, ApiLog apiLog) throws Throwable {    MethodSignature signature = (MethodSignature) joinPoint.getSignature();    String methodName = signature.getMethod().getName();    String className = signature.getDeclaringType().getSimpleName();    // 将方法信息写入上下文    ApiLogContext ctx = ApiLogContext.get();    if (ctx != null) {        ctx.setMethodName(methodName);        ctx.setMethodDesc(desc);    }    try {        result = joinPoint.proceed();        log.debug("sessionId:{}, [{}] {} - {} executed successfully, cost: {}ms",                ApiLogContext.getSessionId(), className, methodName, desc, ...);        return result;    } catch (Throwable e) {        log.debug("sessionId:{}, [{}] {} - {} execution failed, cost: {}ms, error: {}",                ApiLogContext.getSessionId(), className, methodName, desc, ..., e.getMessage());        throw e;    }}

4.4 Controller 层:业务入口

Controller 方法执行时,通过 @RequestBody 反序列化拿到 rawFeatures,将 sessionId 写入上下文:

@PostMapping("/v1/recommend/OR/kfcp/qianwen")public PredictRespVO<RespData> recommendORKFCPQianWen(@RequestBody PredictReqVO predictReqVO) {    final RawFeature rawFeatures = predictReqVO.getRawFeatures();    ApiLogContext.setSessionId(rawFeatures.getSessionId());    return doORRecommend(predictReqVO, rawFeatures, "Pre-order", "preorder");}

4.5 Service / Component / Util 层:消费上下文

这是 ThreadLocal 价值最大的地方。任何深层代码都能直接获取 sessionId,无需参数传递:

// Service 层(有 rawFeature 参数,直接用局部变量)@Service@Slf4jpublic class IntentORServiceImpl implements IntentORService {    public RespData recommend(PredictReqVO predictReqVO, RawFeature rawFeature, ...) {        String sessionId = rawFeature.getSessionId();        log.info("sessionId:{}, 推荐完成, 推荐proposal数量: {}", sessionId, proposalList.size());        // ...    }}// Util 层(没有 rawFeature 参数,用 ApiLogContext.getSessionId())@Component@Slf4jpublic class RedisUtils {    public Object get(String key) {        Object result = redisTemplate.opsForValue().get(key);        if (result == null) {            log.debug("sessionId:{}, data is null", ApiLogContext.getSessionId());        }        return result;    }}

两种获取方式:

方式适用场景示例
局部变量 sessionId方法签名已有 rawFeaturesessionId 参数log.info("sessionId:{}, ...", sessionId, ...)
ApiLogContext.getSessionId()方法中没有 rawFeature(如 Util 工具类)log.info("sessionId:{}, ...", ApiLogContext.getSessionId(), ...)

4.6 异常处理层

当 Controller 抛出异常时,GlobalExceptionHandler 拦截并从 ThreadLocal 读取上下文:

@ExceptionHandler(Exception.class)public PredictRespVO<?> handleException(Exception e) {    log.error("sessionId:{}, {}", ApiLogContext.getSessionId(), e.getMessage(), e);    ApiLogContext ctx = ApiLogContext.get();    if (ctx == null) {        // 极少情况:上下文不存在,创建兜底上下文        ctx = new ApiLogContext();        ctx.setTraceId("N/A");        ctx.setStatus("FAILED");    } else {        ctx.setStatus("FAILED");    }    ctx.setEndTime(System.currentTimeMillis());    ctx.setErrorClass(e.getClass().getName());    ctx.setErrorMsg(e.getMessage());    ApiLogDTO dto = ApiLogDTO.fromContext(ctx);    log.error("sessionId:{}, [EXCEPTION] {}", ApiLogContext.getSessionId(), JSON.toJSONString(dto));    return PredictRespVO.error();}

4.7 Interceptor 层:清理上下文(最关键的一步)

afterCompletion 在 Controller 执行完成后(无论成功或异常)运行,负责清理 ThreadLocal

@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response,                            Object handler, Exception ex) {    try {        ApiLogContext ctx = ApiLogContext.get();        if (ctx == null) {            return;        }        ctx.setEndTime(System.currentTimeMillis());        if (ex != null) {            ctx.setStatus("FAILED");            ctx.setErrorClass(ex.getClass().getName());            ctx.setErrorMsg(truncateMessage(ex.getMessage()));        }        // 序列化上下文为 DTO,写入 business.log        ApiLogDTO dto = ApiLogDTO.fromContext(ctx);        String logJson = JSON.toJSONString(dto);        if ("FAILED".equals(dto.getStatus())) {            BUSINESS_LOG.error(logJson);        } else {            BUSINESS_LOG.info(logJson);        }    } finally {        // 无论如何都要清理,防止 ThreadLocal 泄漏        ApiLogContext.remove();    }}

为什么用 try-finally

JSON.toJSONString(dto) 可能抛出异常(如循环引用、OOM 等)。如果不用 finallyremove() 不会执行,ThreadLocal 中的对象会一直驻留在线程上。当 Tomcat 线程池复用这个线程处理下一个请求时,getOrCreate() 会拿到上一次残留的 context,导致数据串号——这是线上事故级别的 bug。

finally 块确保无论业务逻辑成功还是抛异常,remove() 都会执行。

五、拦截器注册与路径配置

@Configurationpublic class WebMvcConfig implements WebMvcConfigurer {    @Autowired    private ApiLogInterceptor apiLogInterceptor;    @Override    public void addInterceptors(InterceptorRegistry registry) {        registry.addInterceptor(apiLogInterceptor)                .addPathPatterns("/api/**")           // 只拦截 /api/** 路径                .excludePathPatterns("/health", "/ready");  // 排除健康检查    }}

注意:/api/ 路径的请求不会经过 preHandle / afterCompletion,也就不会创建和清理 ThreadLocal。如果这些路径的代码调用了 ApiLogContext.getSessionId(),会返回 null——这是安全的,只是日志中 sessionId 显示为 null

六、Tomcat 线程池与 ThreadLocal 的关系

Spring Boot 内嵌 Tomcat 使用线程池处理请求,默认核心线程数 10,最大 200。线程处理完一个请求后不会销毁,而是归还线程池等待复用。

请求A ──→ 线程-1 ──→ preHandle(创建context) ──→ ... ──→ afterCompletion(remove) ──→ 归还                                                                               │请求B ──→ 线程-1(复用) ◄──────────────────────────────────────────────────────┘          └─ 此时 ThreadLocal 已被 remove(),线程干净

如果不 remove() 会怎样?

请求A ──→ 线程-1 ──→ preHandle(创建context, sessionId="AAA") ──→ 异常,afterCompletion 未执行                                                                               │请求B ──→ 线程-1(复用) ◄──────────────────────────────────────────────────────┘          └─ getOrCreate() 拿到残留 context,sessionId 还是 "AAA" → 数据串号!

七、跨线程传播问题:ThreadLocal 的天然边界

ThreadLocal 绑定的是当前线程。当业务代码通过 CompletableFuture.supplyAsync()executor.submit()parallelStream() 提交异步任务时,子线程是全新的线程,不会继承父线程的 ThreadLocal

7.1 问题复现

线上日志中出现大量 sessionId:null

2026-07-29 10:23:25.431 [Modify-Async-Thread-2] INFO  DataToolMethod - sessionId:null, post online menu time-consuming:11062026-07-29 10:23:25.504 [Modify-Async-Thread-2] INFO  DataToolMethod - sessionId:null, online menu processing time-consuming:73

线程名 Modify-Async-Thread-2 说明代码运行在 modifyTaskExecutor 线程池中,ThreadLocal 没有传播过来。

7.2 传播链路图

本项目涉及三种跨线程场景:

Tomcat 线程 (ThreadLocal ✅ 有值)    │    ├── 场景1: CompletableFuture.supplyAsync(task, executor)    │   → 业务线程池 (Add-Async-Thread-X / Modify-Async-Thread-X)    │   → ApiLogContext.getSessionId() ❌ 返回 null    │    ├── 场景2: parallelStream().forEach(...)    │   → ForkJoinPool.commonPool()    │   → ApiLogContext.getSessionId() ❌ 返回 null    │    └── 场景3: executor.submit(task)        → 单线程池 / recommendBackTaskExecutor        → ApiLogContext.getSessionId() ❌ 返回 null

7.3 修复方案

方案一:闭包捕获(适用于 supplyAsync / submit / parallelStream)

在主线程提前捕获 sessionIdfinal 局部变量,lambda 通过闭包引用:

// 修复前public CompletableFuture<Map<String, Menu>> getOnlineMenuData(String business, ...) {    return CompletableFuture.supplyAsync(() -> {        // 子线程:ApiLogContext.getSessionId() 返回 null ❌        log.info("sessionId:{}, ...", ApiLogContext.getSessionId(), ...);    }, modifyTaskExecutor);}// 修复后public CompletableFuture<Map<String, Menu>> getOnlineMenuData(String business, ...) {    final String sessionId = ApiLogContext.getSessionId();  // 主线程捕获 ✅    return CompletableFuture.supplyAsync(() -> {        log.info("sessionId:{}, ...", sessionId, ...);  // 闭包引用 ✅    }, modifyTaskExecutor);}

parallelStream 同理:

// 修复前public static Map<String, Menu> mergingMenuData(...) {    offlineMenuData.entrySet().parallelStream().forEach((menu_) -> {        // ForkJoinPool 线程:ApiLogContext.getSessionId() 返回 null ❌        log.warn("sessionId:{}, ...", ApiLogContext.getSessionId(), ...);    });}// 修复后public static Map<String, Menu> mergingMenuData(...) {    final String sessionId = ApiLogContext.getSessionId();  // 主线程捕获 ✅    offlineMenuData.entrySet().parallelStream().forEach((menu_) -> {        log.warn("sessionId:{}, ...", sessionId, ...);  // 闭包引用 ✅    });}

优点: 简单直接,无额外框架依赖。

缺点: 需要开发者手动捕获,容易遗漏。

方案二:set + finally remove(适用于深层调用链)

当异步方法内部有大量 ApiLogContext.getSessionId() 调用,且方法本身不持有 rawFeature 参数时,在异步入口处 setSessionId,在 finallyremove

// PreloadMenuServiceImpl.javafinal String sessionId = rawFeature.getSessionId();final CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {    try {        ApiLogContext.setSessionId(sessionId);   // 子线程设置 ✅        dataToolMethod.preloadMenuData(...);     // 内部所有 getSessionId() 都能拿到    } finally {        ApiLogContext.remove();                  // 子线程清理 ✅    }}, modifyTaskExecutor);

关键:finally 中的 remove() 不可省略——线程池复用线程时残留的 ThreadLocal 同样会导致数据串号。

方案三:TaskDecorator(统一方案,推荐)

给线程池配置 TaskDecorator,在任务提交时自动复制 ThreadLocal,任务执行后自动清理:

@Bean(name = "addTaskExecutor")public ThreadPoolTaskExecutor addTaskExecutor() {    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();    // ... 线程池参数 ...    executor.setTaskDecorator(runnable -> {        // 主线程捕获上下文        String sessionId = ApiLogContext.getSessionId();        return () -> {            try {                // 子线程设置上下文                ApiLogContext.setSessionId(sessionId);                runnable.run();            } finally {                // 子线程清理                ApiLogContext.remove();            }        };    });    executor.initialize();    return executor;}

优点: 一次性配置,所有使用该线程池的异步任务自动传播,开发者无需关心。

缺点:parallelStream 走的是 ForkJoinPool.commonPool()TaskDecorator 无法覆盖,仍需闭包捕获。

方案四:TransmittableThreadLocal(阿里 TTL 框架)

使用阿里开源的 transmittable-thread-local,在 ThreadLocal 之上实现线程池场景下的透明传递:

private static final TransmittableThreadLocal<ApiLogContext> CONTEXT = new TransmittableThreadLocal<>();

配合 TtlExecutors.getTtlExecutor(executor) 包装线程池,即可在所有异步任务中透明传递。这是最彻底的方案,但引入了第三方依赖。

7.4 本项目修复清单

文件跨线程场景修复方式
DataToolMethod.javasupplyAsync × 2 + parallelStream × 3闭包捕获
ScoreToolMethod.javasupplyAsync × 3闭包捕获
IntentServiceImpl.javaexecutor.submit + supplyAsync × 6闭包捕获
HotSaleMenuGroupingServiceImpl.javasupplyAsync × 1闭包捕获
PreloadMenuServiceImpl.javarunAsync × 1set + finally remove
ObjectLogicMethodLocal.javaparallelStream × 1闭包捕获

八、日志输出:从 ThreadLocal 到日志文件

本项目的日志框架是 SLF4J + Log4j2(LMAX Disruptor 异步),log4j2.xml 中配置了 MDC traceId

<property name="LOG_PATTERN"    value='{"traceId":"%mdc{traceId}","timestamp":"%d{ISO8601}","level":"%level","thread":"%t","msg":%m}%n'/>

项目中有两套 trace 机制并存:

机制来源范围传播性
MDC traceIdLog4j2 %mdc{traceId}日志框架层面跨线程不传播(同 ThreadLocal)
ApiLogContext.sessionId业务自定义 ThreadLocal业务代码层面跨线程不传播(已修复)

两者独立运作:traceId 用于日志格式化层面的链路追踪,sessionId 用于业务日志中的会话追踪。本项目中 sessionId 是通过 log.info("sessionId:{}, ...") 手动写入日志消息体的,而非通过 MDC。

九、完整生命周期总结

┌──────────────────────────────────────────────────────────────────────────┐│                            一次 HTTP 请求                                 ││                                                                          ││  Tomcat 线程池 ──→ 分配 Thread-1                                         ││                                                                          ││  1. CachedBodyFilter                                                     ││     │  缓存 requestBody 到 CachedBodyHttpServletRequest                  ││     │  ThreadLocal: 未触碰                                               ││     ▼                                                                    ││  2. ApiLogInterceptor.preHandle()                                        ││     │  getOrCreate() → 创建 ApiLogContext                                ││     │  ThreadLocal.set(ctx)  ←── 绑定到 Thread-1                         ││     │  填充: traceId, httpMethod, uri, startTime, body                   ││     ▼                                                                    ││  3. ApiLogAspect @Around                                                 ││     │  补充: methodName, methodDesc                                      ││     │  log.debug("sessionId:{}, ...", ApiLogContext.getSessionId())      ││     ▼                                                                    ││  4. Controller (@RequestBody 反序列化)                                    ││     │  ApiLogContext.setSessionId(rawFeatures.getSessionId())            ││     │  调用 Service 层                                                    ││     ▼                                                                    ││  5. Service → Component → Util (同线程)                                   ││     │  ApiLogContext.getSessionId() → 用于所有 log 输出                   ││     │  ApiLogContext.get() → 读取上下文字段                               ││     │                                                                    ││     │  ⚠ 跨线程场景(已修复):                                            ││     │  ├─ supplyAsync → final String sessionId 捕获 → 闭包引用           ││     │  ├─ parallelStream → final String sessionId 捕获 → 闭包引用        ││     │  └─ runAsync → setSessionId + finally remove                       ││     ▼                                                                    ││  6. GlobalExceptionHandler (仅异常时)                                     ││     │  ApiLogContext.get() → 读取上下文写错误日志                          ││     ▼                                                                    ││  7. ApiLogInterceptor.afterCompletion()                                  ││     │  try {                                                             ││     │      补充 endTime / 异常信息                                         ││     │      ApiLogDTO.fromContext(ctx) → JSON → business.log              ││     │  } finally {                                                       ││     │      ApiLogContext.remove()  ←── 清理 Thread-1 的 ThreadLocal      ││     │  }                                                                 ││                                                                          ││  Thread-1 归还 Tomcat 线程池(ThreadLocal 已清空,干净复用)                │└──────────────────────────────────────────────────────────────────────────┘

十、最佳实践清单

实践说明
try-finally 清理afterCompletion 中用 finally 保证 remove() 执行
只拦截需要的路径addPathPatterns("/api/**") 避免健康检查等无谓创建上下文
便捷静态方法getSessionId() / setSessionId() 降低使用成本
请求体缓存CachedBodyFilter 使 body 可重复读取,Interceptor 和 Controller 各取所需
跨线程闭包捕获异步任务前 final String sessionId = ApiLogContext.getSessionId(),lambda 内引用
跨线程 set + remove深层调用链在异步入口 setSessionIdfinallyremove
兜底处理GlobalExceptionHandlerctx == null 时创建兜底上下文
/api/** 安全降级getSessionId() 返回 null 而非抛异常,日志显示 sessionId:null

十一、常见陷阱

陷阱一:忘记remove()导致数据串号

线程池复用线程,残留的 ThreadLocal 会被下一个请求读到。try-finally 是最低保障。

陷阱二:异步线程拿不到上下文

ThreadLocal 不跨线程传播。CompletableFuture / @Async / executor.submit() / parallelStream() 中的代码读到的都是 null。需要在异步前捕获 sessionIdfinal 变量,或使用 TaskDecorator / TransmittableThreadLocal

陷阱三:InheritableThreadLocal对线程池无效

InheritableThreadLocal 只在线程创建时继承父线程的值。线程池复用已有线程,不会触发继承。很多人踩过这个坑。

陷阱四:afterCompletion不保证执行

Spring 的 afterCompletionpreHandle 返回 true 后保证执行。但如果 preHandle 本身抛异常,afterCompletion 不会调用。因此 preHandle 中不要做可能抛异常的重逻辑,或者额外用 Filter + try-finally 兜底。

陷阱五:getOrCreate()的副作用

getOrCreate()ThreadLocal 为空时会创建新实例并 set。如果在非请求线程(如定时任务、异步线程)中误调 getOrCreate(),会创建一个空上下文且无人清理。应优先使用 get() + 判空。

陷阱六:parallelStream隐蔽的跨线程

parallelStream() 默认使用 ForkJoinPool.commonPool(),开发者很容易忽略这也是跨线程。TaskDecorator 无法覆盖 ForkJoinPool,必须手动闭包捕获。

陷阱七:子线程set后忘记remove

在子线程中调用 ApiLogContext.setSessionId(sessionId) 后,如果不在 finallyremove(),线程池复用该子线程时同样会数据串号。子线程的 ThreadLocal 和主线程一样需要清理。

相关文章

精彩推荐