兼容 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 分钟前
Spring MVC 核心原理全解析
java·spring·mvc
Ulyanov23 分钟前
《PySide6 GUI开发指南:QML核心与实践》 第二篇:QML语法精要——构建声明式UI的基础
java·开发语言·javascript·python·ui·gui·雷达电子对抗系统仿真
码界筑梦坊26 分钟前
357-基于Java的大型商场应急预案管理系统
java·开发语言·毕业设计·知识分享
云烟成雨TD39 分钟前
Spring AI Alibaba 1.x 系列【31】集成 Studio 模块实现可视化 Agent 调试
java·人工智能·spring
014-code1 小时前
Spring Data JPA 实战指南
java·spring
安小牛1 小时前
Android 开发汉字转带声调的拼音
android·java·学习·android studio
聚美智数1 小时前
企业实际控制人查询-公司实控人查询
android·java·javascript
zb200641201 小时前
SpringBoot详解
java·spring boot·后端
AI人工智能+电脑小能手2 小时前
【大白话说Java面试题】【Java基础篇】第7题:HashMap的get流程是什么
java·后端·面试·哈希算法·散列表·hash-index·hash
我头发多我先学2 小时前
C++ 模板全解:从泛型编程初阶到特化、分离编译进阶
java·开发语言·c++