在 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 并注入 ThreadLocaldoFinally清理 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。