05-ThreadLocal与请求上下文

O泡李华 6

请求上下文为什么会串线?ThreadLocal、MDC 与异步传播

上一个用户的信息出现在下一条日志里,或者任务提交成功但异步日志没有关联号,常常来自对线程生命周期的误解。请求会结束,线程却可能继续复用;跨线程之后,上下文也不会因为变量名相同就自然存在。

1. 请求生命周期和线程生命周期不相等

理解数据到底挂在哪个线程上,才能解释复用污染。

ThreadLocal 到底保存在哪里?

ThreadLocal 是访问线程局部值的入口。典型用法是:

static final ThreadLocal<String> CURRENT_USER = new ThreadLocal<>();

同一个 ThreadLocal,在不同线程中可以对应不同的值。线程 A set 的值,不会通过线程 B 的普通 get 自动读出。ThreadLocal API

在 OpenJDK 17 的实现中,相关映射保存在 Thread 持有的 ThreadLocalMap 里,可以理解为:

线程 A → ThreadLocalMap → CURRENT_USER 对应 Alice
线程 B → ThreadLocalMap → CURRENT_USER 对应 Bob

因此它不是把一个共享变量变成线程安全变量,而是让每个线程有各自的绑定值。

线程复用为什么会破坏请求隔离?

线程池通常不会在每个任务结束后销毁工作线程。任务 A 和 B 可以先后在同一个线程中运行:

工作线程 1:任务 A 设置用户 → A 结束 → 任务 B 开始 → B 读取用户

如果 A 没有 remove,B 看到的可能仍是 A 的值。

这不是两个线程互相读到了数据,而是同一个线程上的旧数据没有在逻辑任务边界清理。线程局部与请求局部,边界不同。

登录用户、租户编号、Trace ID 等上下文都可能受到影响。

完整示例:稳定复现残留,再验证清理

使用单线程执行器,保证任务顺序复用同一个工作线程。将程序复制到 Java 17 的 Main.java 运行。

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class Main {
    static final ThreadLocal<String> CURRENT_USER = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newSingleThreadExecutor();
        try {
            String first = executor.submit(() -> {
                CURRENT_USER.set("user-A");
                return CURRENT_USER.get(); // 故意遗漏清理
            }).get(5, TimeUnit.SECONDS);
            System.out.println("task A = " + first);

            String residual = executor.submit(() -> CURRENT_USER.get())
                    .get(5, TimeUnit.SECONDS);
            System.out.println("next task without set = " + residual);

            String second = executor.submit(() -> {
                try {
                    CURRENT_USER.set("user-B");
                    return CURRENT_USER.get();
                } finally {
                    CURRENT_USER.remove();
                }
            }).get(5, TimeUnit.SECONDS);
            System.out.println("task B = " + second);

            String afterCleanup = executor.submit(() -> CURRENT_USER.get())
                    .get(5, TimeUnit.SECONDS);
            System.out.println("next task after remove = " + afterCleanup);
        } finally {
            executor.shutdown();
            if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
                executor.shutdownNow();
                throw new IllegalStateException("shutdown timeout");
            }
        }
    }
}

运行输出:

task A = user-A
next task without set = user-A
task B = user-B
next task after remove = null

Future.get 保证当前任务完成后再开始检查。这个示例复现了上下文残留,并没有通过堆分析证明某个应用发生了内存泄漏。

弱引用为什么仍然可能保留内存?

OpenJDK 17 的 ThreadLocalMap 条目对 ThreadLocal 键使用弱引用,但条目中的 value 不是因此就变成弱引用。

如果某个 ThreadLocal 不再被其他强引用持有,垃圾回收可能清掉键;线程上的映射条目仍可能暂时保留 value。

存活线程 → ThreadLocalMap → 条目 → value 对象
                              ↓
                           键可能已失效

实现会在部分操作中清理失效条目,但不是键被回收后所有 value 都立即同步释放。线程长期存活、相关清理没有发生时,可能产生不必要的内存保留。OpenJDK 17u ThreadLocal 源码

如果 ThreadLocal 是静态字段,键本身还被强引用持有,也不能因此忽略清理。value 仍可能被线程长期持有,并带来业务残留。

弱引用只影响特定引用链,不能替代明确的生命周期管理。

2. 清理与传播是两种操作

结束时释放上下文,提交时捕获上下文,执行后恢复之前状态。

在哪一个线程里清理?

remove 清理当前线程对应的绑定。主线程执行 remove,不会自动清理所有工作线程中的值。

因此 set、使用和清理,应在拥有这份上下文的执行边界内组织:

try {
    context.set(value);
    handleRequest();
} finally {
    context.remove();
}

这里 context、value 和 handleRequest 表示实际请求上下文与处理逻辑。finally 能覆盖正常返回和异常传播的路径,避免只在“成功”分支清理。

如果是嵌套作用域,同一线程内原本已有上下文,简单 remove 可能破坏外层状态。应定义作用域规则,例如保存旧值,在退出时恢复;顶层请求结束仍需要清理。恢复 null 与恢复“没有绑定”的区别,也要依据实际初始化策略处理。

跨线程为什么不能直接读到上下文?

请求线程设置了用户,把任务提交到另一线程后,另一线程普通 get 不会自动获得这份值。

需要传递时,可以显式构造必要的上下文快照,交给任务,或使用有明确传播与清理规则的任务包装器。不要盲目复制所有数据,更不要把可变请求对象长期留在队列中。

InheritableThreadLocal 与线程创建时的继承有关,不等于线程池每次提交任务时都复制当前上下文。线程池工作线程可能早已创建,所以它不能直接解决请求到任务的传播问题。

异步链、响应式执行等可能跨多个线程,更需要对应框架的上下文机制或显式参数,不能把执行线程固定当成请求生命周期。

ThreadLocal 不会让引用的对象自动安全

即使每个线程有自己的绑定,如果绑定值指向同一个共享可变对象,该对象仍可能被并发访问。

线程 A 的局部值 ─┐
                ├──→ 同一个可变对象
线程 B 的局部值 ─┘

此外,请求身份应来自可信的认证结果,业务授权不能只靠“线程里有一个用户编号”。ThreadLocal 是携带上下文的工具,不是权限验证机制。

3. 日志关联还要区分 Request ID 与 Trace

复制 MDC 不等于建立分布式 Span,其他执行模型也需要自己的传播机制。

Request ID、Trace ID 与 Span ID

Request ID 可由应用定义,用于关联一次请求。Trace ID 表示一条分布式追踪链,Span ID 表示链中的一个操作。一个 Trace 中可以包含多个 Span。

Spring Boot 可通过 Micrometer Tracing 等组件接入追踪并把相关信息加入日志上下文。具体依赖、传播格式和采样配置应按应用版本确认。Boot 追踪说明。

不要生成一个自定义请求编号,然后把它标成标准 Trace ID。可以同时保留两者,但名称和用途清楚。

请求入口怎样处理外部标识?

外部提交的关联号属于不可信输入。限制长度与字符集合,防止换行注入日志;不合格则生成服务器标识。关联号不能用于身份认证或访问控制。

请求结束时清理 MDC,线程可能被下一个请求复用。存在嵌套上下文时恢复之前的值,而不是简单清掉整个上下文。

入口日志记录路径模板、方法、状态和耗时,避免把含敏感参数的完整 URL 直接写入。业务日志记录对象编号与结果分类,不原样记录身份证、令牌和模型上下文。

线程池为什么会丢失 MDC?

MDC 通常与当前线程相关,异步任务执行在另一个线程,不能假设它自然得到请求线程的值。线程池线程会复用,残留值还可能污染后续任务。

下面是 Spring Framework 6.2 与 SLF4J 的 TaskDecorator 示例,可放在已有 Boot 项目。它只复制 MDC,不实现分布式 Span 传播:

import org.slf4j.MDC;
import org.springframework.core.task.TaskDecorator;
import java.util.Map;

public final class MdcTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        Map<String, String> captured = MDC.getCopyOfContextMap();
        return () -> {
            Map<String, String> previous = MDC.getCopyOfContextMap();
            try {
                replace(captured);
                runnable.run();
            } finally {
                replace(previous);
            }
        };
    }

    private static void replace(Map<String, String> context) {
        if (context == null) MDC.clear();
        else MDC.setContextMap(context);
    }
}

在应用实际使用的 ThreadPoolTaskExecutor 上注册它。以下是配置方法片段,需要 @Configuration 类和相应 import:

@Bean
public ThreadPoolTaskExecutor businessExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(4);
    executor.setMaxPoolSize(8);
    executor.setQueueCapacity(100);
    executor.setThreadNamePrefix("business-");
    executor.setTaskDecorator(new MdcTaskDecorator());
    return executor;
}

Spring 管理 Bean 生命周期并初始化执行器。线程数量只是示例配置,实际根据任务与负载确定。使用 @Async 时指定该执行器;其他线程池不会因为存在这个 Bean 就自动应用装饰器。TaskDecorator API。

异步、Reactor 与远程传播分别处理

CompletableFuture 默认执行器、调度任务、消息消费和 Reactor 链都有自己的上下文传递方式。不要把一段 TaskDecorator 宣称为覆盖全部场景。

Reactor 执行可能切换线程,需要相应 Context 与观测集成。远程 HTTP 则需要在出站请求中注入标准追踪上下文,并由下游提取。优先采用框架支持的观测组件,避免手工随意拼接 trace 请求头。

异步任务晚于 HTTP 响应结束时,仍可以通过任务编号与追踪关系关联,但要明确任务是后台持续执行,不把它的结束时间误算为请求返回时间。

脱敏与低基数如何兼顾排查?

日志保留排查必需事实:操作、对象标识、结果、错误分类、调用耗时和关联号。敏感字段遮蔽或使用内部标识映射,访问日志权限与保留周期受到控制。

监控指标不要将用户 ID、会话 ID 和请求 ID 作为标签,否则大量不同值会形成高基数。它们更适合进入日志或 Trace 的受控字段。

模型 Token、工具耗时和检索命中数可以形成指标,但原始问题与回答默认不应全量记录。确需采样排查时,明确脱敏、权限和保留规则。

4. 设计判断与适用边界

把上下文视为一次业务操作的附加状态,明确谁创建、谁读取、谁清理。入口使用 try/finally;异步任务保存必要快照,不携带可变的大对象或数据库会话。后台任务从持久化记录恢复身份范围,不能依赖已经结束的 HTTP 请求。

关联号便于查日志,不能成为权限凭证。外部关联号限制格式和长度,身份由认证模块确定。线程池、Reactor、消息消费者和远程 HTTP 分别验证传播;只修复一种异步机制,不能宣称整条调用链已经覆盖。

5. 问答

问:ThreadLocal 是每个请求独立的吗?

不是。它按线程提供绑定,线程复用时需要应用维护任务或请求边界。

问:为什么键是弱引用,还要 remove?

value 仍可能通过线程映射被持有;静态键也可能一直存活。及时清理还能避免业务上下文残留。

问:日志有 UUID 就算分布式追踪吗?

答:它能关联日志,但不自动具有 Span 关系和标准跨服务传播。

问:为什么 finally 中恢复 previous?

答:避免污染线程,同时尊重任务执行前已有的上下文。

参考资料