兼容 ThreadLocal 的用户上下文透传方案:WebFlux 项目改造实践

在 Spring WebFlux 的响应式编程模型中,很多开发者习惯使用 ThreadLocal 存储当前登录用户,但会遇到一个奇怪问题:
Controller 里 ThreadLocal 是 null,日志里第一次还有值,第二次突然没了。

这并不是代码写错,而是 Reactor 与传统 Servlet 模型的根本差异

本文将介绍:

  • 为什么 WebFlux 下 ThreadLocal 会失效
  • Reactor Context 的基本原理
  • 一份可直接使用的 UserFilter(兼容 ThreadLocal)
  • 几种方案的对比与常见坑

一、为什么 WebFlux 中 ThreadLocal 会失效

WebFlux 基于 Reactor,是 响应式 + 非阻塞模型,而不是 Servlet 那种「一个请求一个线程」的阻塞模型。

✏️ 什么时候会发生线程切换

  • 请求进入 Controller 之前,I/O 线程处理 HTTP 报文
  • 调用下游数据库、Redis、HTTP 时,Reactor 会释放当前线程,等待响应回来后切换到另一个线程继续执行
  • Reactor 的线程池(reactor-http-epoll-*)会轮询调度,无法保证「同一请求始终用同一线程」

在 Servlet 同步模型里:

复制代码
请求 ---> Controller ---> Service (全程同一线程)

在 WebFlux 响应式模型里:

复制代码
请求 (epoll-2) -> Controller (epoll-5) -> await DB -> 继续 (epoll-3)

⚠️ 而 ThreadLocal 是跟线程绑定的,线程一换,值就没了。


二、Reactor Context 的作用

Reactor 提供了 Context,它是一个和线程无关的上下文容器:

  • 相当于一个 Map<String, Object>
  • 绑定在 Reactor 执行链上,而不是线程上
  • 可以在 .contextWrite() 写入,在 .deferContextual() 读取

是跨线程透传信息的唯一正确方式。


三、兼容 ThreadLocal 的 UserFilter 示例(可直接使用)

如果你系统中已有大量使用 ThreadLocal.get() 读取用户信息的代码,可以用下面这种方案:
在 UserFilter 里把用户信息放进 Context,并在 Controller 前注入回 ThreadLocal。

java 复制代码
@Slf4j
public class UserFilter implements WebFilter {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        String token = exchange.getRequest().getHeaders().getFirst("Authorization");
        if (StringUtils.isEmpty(token)) {
            return unauthorized(exchange);
        }

        // 假设这里解析 token 得到用户信息
        Map<String, Object> userInfo = Map.of(
                "USER_ID", 123456L,
                "USERNAME", "demo_user"
        );

        return Mono.deferContextual(ctxView -> {
                    // ✅ 订阅时执行,在 Controller 之前
                    Map<String, Object> ctxMap = ctxView.getOrDefault("USER_INFO", Map.of());
                    ctxMap.forEach(ThreadUtil::put); // 注入 ThreadLocal
                    return chain.filter(exchange);
                })
                .contextWrite(ctx -> ctx.put("USER_INFO", userInfo))
                .doFinally(signalType -> {
                    // ✅ 请求结束清理 ThreadLocal
                    userInfo.keySet().forEach(k -> ThreadUtil.put(k, null));
                });
    }

    private Mono<Void> unauthorized(ServerWebExchange exchange) {
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        return exchange.getResponse().setComplete();
    }
}

🔑 核心点:

  • contextWrite 写入用户信息(Reactor Context)
  • deferContextual 在 Controller 运行前读取 Context 并注入 ThreadLocal
  • doFinally 清理 ThreadLocal 防止内存泄漏

这样 Controller、Service 里用 ThreadUtil.get("USER_ID") 依然可用。


四、几种方案的对比

方案 能跨线程 Controller 能否拿到 备注
ThreadLocal + doFirst 太早执行,且未绑定 Context
ThreadLocal + doOnEach 执行太晚,Controller 已跑完
ThreadLocal + deferContextual 兼容老系统,推荐
Context 方案 推荐新系统,需重构 Controller

❌ 典型错误示例(ThreadLocal 丢失)

java 复制代码
return chain.filter(exchange).doFirst(() -> {
    ThreadUtil.put("USER_ID", 123456L);
});
  • 这里虽然看似在最前面执行,但还没有 Context ,也不能保证后续在同一线程
  • Controller 中 ThreadUtil.get("USER_ID") 通常会得到 null

五、推荐做法总结

  • 新系统 :全链路使用 Context,Controller 通过 Mono.deferContextual() 直接读取,不使用 ThreadLocal
  • 老系统 :使用上面的 UserFilter 模板,在 Controller 前把 Context 信息注入到 ThreadLocal,请求结束后清理

六、纯 Context 读取示例(更推荐)

如果不需要兼容 ThreadLocal,可以直接:

java 复制代码
@GetMapping("/me")
public Mono<String> me() {
    return Mono.deferContextual(ctx -> {
        Long uid = (Long) ctx.<Map>get("USER_INFO").get("USER_ID");
        return Mono.just("Hello " + uid);
    });
}

这样就完全避免了线程耦合,是 WebFlux 环境下最优雅、最安全的做法。


七、总结

  • WebFlux 中由于线程频繁切换,不能用 ThreadLocal 透传用户信息
  • Reactor Context 是官方设计的跨线程上下文机制
  • .contextWrite() 写入,.deferContextual() 读取
  • 老系统可用「Context → ThreadLocal 注入」兼容方案,逐步迁移

📌 一句话记忆

想要在 WebFlux 中跨线程传递用户信息,请使用 Reactor Context,而不是 ThreadLocal。

相关推荐
土司大王4 分钟前
LeetCode 17 电话号码的字母组合:Java 回溯模板、多叉决策树与复杂度分析
java·leetcode·决策树
行百里er13 分钟前
Spring Insight 里上报 Span 为什么用 JDK HttpClient 而不是 RestTemplate
spring boot·spring cloud·监控
CoderYanger17 分钟前
前端基础——JavaScript(WebAPI)代码案例
java·开发语言·前端·javascript·css·前端框架·html5
Nuanyt27 分钟前
JUC常见核心知识梳理02 AQS ReentrantLock CAS 原子类 线程池
java
CodeStats31 分钟前
【Java数据类型】Java底层数据类型原理(上):基本类型、转换与封装核心
java·数据类型·string·封装·转换
陈皮波比茶36 分钟前
SpringCloudAlibaba
java·spring cloud·微服务
重生之小比特38 分钟前
【Java SE】程序逻辑控制
java·开发语言·python
find1star39 分钟前
LeetCode 25:K 个一组翻转链表
java·数据结构·算法·leetcode·链表·职场和发展·动态规划
染的人1 小时前
Java 10x15cm面单PDF转换A4格式PDF
java·开发语言·pdf
泡海椒1 小时前
JQuick-java (JQuick-ASM)性能优化原理:字节码生成与缓存机制深度解析
java·缓存·性能优化