最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
Spring Boot中ThreadLocal在一次HTTP请求上下文的完整生命周期
时间:2026-08-07 09:54:03 编辑:袖梨 来源:一聚教程网
Spring Boot中ThreadLocal在一次HTTP请求上下文的完整生命周期并不只看表面做法,关键还要理解相关条件、限制和后续影响。
从源码视角拆解
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 个关键组件参与请求上下文的管理:
| 组件 | 层级 | 职责 |
|---|---|---|
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>,并对外暴露静态方法:
@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; }}设计要点:
ThreadLocal是private static final,全局唯一,每个线程持有独立的副本getOrCreate()实现懒加载:第一次调用时创建实例并绑定到线程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 | 方法签名已有 rawFeature 或 sessionId 参数 | 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 等)。如果不用 finally,remove() 不会执行,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)
在主线程提前捕获 sessionId 为 final 局部变量,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,在 finally 中 remove:
// 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.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:
<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 | 深层调用链在异步入口 setSessionId,finally 中 remove |
| 兜底处理 | GlobalExceptionHandler 中 ctx == null 时创建兜底上下文 |
非 /api/** 安全降级 | getSessionId() 返回 null 而非抛异常,日志显示 sessionId:null |
十一、常见陷阱
陷阱一:忘记remove()导致数据串号
线程池复用线程,残留的 ThreadLocal 会被下一个请求读到。try-finally 是最低保障。
陷阱二:异步线程拿不到上下文
ThreadLocal 不跨线程传播。CompletableFuture / @Async / executor.submit() / parallelStream() 中的代码读到的都是 null。需要在异步前捕获 sessionId 为 final 变量,或使用 TaskDecorator / TransmittableThreadLocal。
陷阱三:InheritableThreadLocal对线程池无效
InheritableThreadLocal 只在线程创建时继承父线程的值。线程池复用已有线程,不会触发继承。很多人踩过这个坑。
陷阱四:afterCompletion不保证执行
Spring 的 afterCompletion 在 preHandle 返回 true 后保证执行。但如果 preHandle 本身抛异常,afterCompletion 不会调用。因此 preHandle 中不要做可能抛异常的重逻辑,或者额外用 Filter + try-finally 兜底。
陷阱五:getOrCreate()的副作用
getOrCreate() 在 ThreadLocal 为空时会创建新实例并 set。如果在非请求线程(如定时任务、异步线程)中误调 getOrCreate(),会创建一个空上下文且无人清理。应优先使用 get() + 判空。
陷阱六:parallelStream隐蔽的跨线程
parallelStream() 默认使用 ForkJoinPool.commonPool(),开发者很容易忽略这也是跨线程。TaskDecorator 无法覆盖 ForkJoinPool,必须手动闭包捕获。
陷阱七:子线程set后忘记remove
在子线程中调用 ApiLogContext.setSessionId(sessionId) 后,如果不在 finally 中 remove(),线程池复用该子线程时同样会数据串号。子线程的 ThreadLocal 和主线程一样需要清理。
