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;
}
}
设计要点:
ThreadLocal是private 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 |
方法签名已有 rawFeature 或 sessionId 参数 |
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 等)。如果不用 finally,remove() 不会执行,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)
在主线程提前捕获 sessionId 为 final 局部变量,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,在 finally 中 remove:
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 | 深层调用链在异步入口 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 和主线程一样需要清理。