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

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

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


一、为什么需要 ThreadLocal

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

text 复制代码
Filter → Interceptor → Controller → Service → Component → Util

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

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

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

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


二、核心组件总览

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

组件 层级 职责
CachedBodyFilter Servlet Filter 缓存请求体(最高优先级),使后续可重复读取 body
ApiLogInterceptor Spring Interceptor 创建/清理 ThreadLocal,写业务日志
ApiLogContext 上下文持有者 ThreadLocal<ApiLogContext> 的静态封装
ApiLogAspect AOP 切面 补充方法名/描述到上下文,记录方法级耗时
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>,并对外暴露静态方法:

java 复制代码
@Data
public 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;
    }
}

设计要点:

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

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

4.1 Filter 层:缓存请求体

java 复制代码
@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 执行之前运行,负责初始化上下文:

java 复制代码
@Override
public 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 切面拦截这些方法:

java 复制代码
@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 写入上下文:

java 复制代码
@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,无需参数传递:

java 复制代码
// Service 层(有 rawFeature 参数,直接用局部变量)
@Service
@Slf4j
public 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
@Slf4j
public 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 读取上下文:

java 复制代码
@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

java 复制代码
@Override
public 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() 都会执行。


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

java 复制代码
@Configuration
public 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:1106
2026-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 通过闭包引用:

java 复制代码
// 修复前
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 同理:

java 复制代码
// 修复前
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

java 复制代码
// PreloadMenuServiceImpl.java
final 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,任务执行后自动清理:

java 复制代码
@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 之上实现线程池场景下的透明传递:

java 复制代码
private static final TransmittableThreadLocal<ApiLogContext> CONTEXT = new TransmittableThreadLocal<>();

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

7.4 本项目修复清单

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

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

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

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

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

机制 来源 范围 传播性
MDC traceId Log4j2 %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 和主线程一样需要清理。

相关推荐
小罗水1 小时前
第8章 文档解析与文本切片
数据库·spring·spring cloud·微服务
用户40966601317512 小时前
Lombok 你用对了吗?@Data 之外的 6 个隐藏神器
java·后端·代码规范
梅头脑2 小时前
交易跨10个库、日均千万订单——选错一次分布式事务方案,加班三个月重写
java·分布式
久久学姐2 小时前
基础转码学 AI:Java+Python 双语言入门,3 个月可落地实战项目
java·python·ai·转码·实战项目
花生了什么事o2 小时前
synchronized 与 ReentrantLock:Java 锁机制原理与实现对比
java·开发语言
长不胖的路人甲3 小时前
Serial 串行、Parallel 并行、CMS 并发收集器
java·开发语言·jvm
IT_Octopus3 小时前
Spring Boot 线程池关闭:destroyMethod 的作用与最佳实践
java·spring boot·后端
亦暖筑序4 小时前
AgentScope-Java 入门:完善 Vue 前端、发布 GitHub,并规划下一步
java·前端·vue.js
wddptwd284 小时前
android studio 报错怎么处理 java.lang.NullPointerException
android·java·android studio