这道题在面试里出现频率不低,但很多人只能答出「因为 ThreadLocal 拿不到 request」,再往下问就卡住了:ThreadLocal 到底存在哪、Spring 是怎么把当前请求绑定到线程上的、为什么加个 @Async 就报 Scope 'request' is not active for the current thread、以及为什么「注入一个原型 Bean 包一层」有时候能救、有时候救不了。本文把这几个环节拆开讲清楚,并给出三种可落地的修复方案,附真实可运行的代码。
现象复现:一个最小可运行的报错场景
先看一段能稳定复现问题的代码。一个 request 作用域的 Bean,被一个单例 Service 注入,然后在 @Async 方法里被调用。
@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContextHolderBean {
private String traceId;
public String getTraceId() {
return traceId;
}
public void setTraceId(String traceId) {
this.traceId = traceId;
}
}
@Service
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
@Autowired
private RequestContextHolderBean requestBean;
@Async
public void asyncHandle(String orderNo) {
// 这里会抛异常
log.info("orderNo={}, traceId={}", orderNo, requestBean.getTraceId());
}
}
启动后调用 orderService.asyncHandle("A001"),控制台抛出:
org.springframework.beans.factory.BeanCreationException: Error creating bean with name
'scopedTarget.requestContextHolderBean': Scope 'request' is not active for the current thread;
consider defining a scoped proxy for this bean if you intend to refer to it from a singleton;
nested exception is java.lang.IllegalStateException: No thread-bound request found:
Are you referring to request attributes outside of an actual web request, or processing a request
outside of the originally receiving thread? If you are actually operating within a web request and
still receive this message, your code is probably running outside of DispatcherServlet: In this
case, use RequestContextListener or RequestContextFilter to expose the current request.
注意这个报错的措辞:「Scope ‘request’ is not active for the current thread」。它已经点明了根因方向——作用域是绑定在「线程」上的,而不是绑定在「请求」对象上。这是理解整道题的关键。
根因剖析:request 作用域到底依赖什么
ThreadLocal 才是真正的作用域载体
很多人的直觉是:HTTP 请求对象 HttpServletRequest 已经创建了,为什么拿不到?因为 Spring 的 request 作用域并不是从某个全局 Map 里按 requestId 查出来的,它是从 当前线程的 ThreadLocal 里取的。具体链路如下:
DispatcherServlet处理请求前,FrameworkServlet.processRequest调用initContextHolders,最终把ServletRequestAttributes放入RequestContextHolder的requestAttributesHolder中。RequestContextHolder内部维护两个静态字段:requestAttributesHolder(NamedThreadLocal,名为RequestAttributes)和inheritableRequestAttributesHolder(NamedInheritableThreadLocal)。默认用的是第一个,即不可继承的 ThreadLocal。- 请求处理完毕后,
resetContextHolders会remove()掉对应值,防止线程池复用导致的内存泄漏和串请求。 - 创建
request作用域 Bean 时,RequestScope.get()调用RequestContextHolder.currentRequestAttributes(),从 ThreadLocal 取属性;取不到就抛IllegalStateException: No thread-bound request found。
所以问题的本质不是「请求没了」,而是执行代码的那个线程,其 ThreadLocal 里没有绑定 request 属性。异步线程是线程池中的另一条线程,它的 ThreadLocal 是空的,Spring 自然无从创建 Bean。
为什么 ScopedProxyMode 也救不了
有人以为加上 proxyMode = ScopedProxyMode.TARGET_CLASS 就万事大吉。代理确实解决了「单例里注入 request Bean 会报 BeanCreationException(在启动阶段)」的问题——它让注入点拿到的是一个 CGLIB 代理,真正的方法调用被转发到 getObject() 时才去容器里按作用域取真实对象。但代理只是延迟了取 Bean 的时机,取的时候依然要读 ThreadLocal,线程不对照样抛上面那个异常。
关键结论:代理解决的是「注入时机」问题,不解决「线程上下文传递」问题。这两件事经常被混为一谈,也是面试时区分候选人是否真懂的考点。
@Async 额外埋的坑:aop:proxy-target-class 与执行器
还有一个常被忽略的点:@Async 方法如果没有配置 TaskExecutor,Spring Boot 2.1 之后默认使用 SimpleAsyncTaskExecutor(每次新建线程,无池化),2.1 之前用 SimpleAsyncTaskExecutor,而如果容器里存在唯一的 TaskExecutor 则会用它。无论哪种,新线程的 ThreadLocal 都是空的。若你用了自定义线程池并开启了 InheritableThreadLocal,也只在线程创建的那一刻继承一次,线程复用后第二次任务就错了——这是更隐蔽的 bug。
三种修复方案与各自边界
方案一:显式传递上下文(推荐,最可控)
不要在异步线程里访问 request 作用域 Bean,而是在提交异步任务前把需要的数据取出来,作为方法参数传进去。这是最稳的做法,因为参数传递天然跨线程。
@Service
public class OrderService {
@Autowired
private RequestContextHolderBean requestBean;
@Autowired
private TaskExecutor taskExecutor;
public void handle(String orderNo) {
String traceId = requestBean.getTraceId(); // 在 Web 线程中取
taskExecutor.execute(() -> doHandle(orderNo, traceId)); // 值拷贝进闭包
}
private void doHandle(String orderNo, String traceId) {
System.out.printf("orderNo=%s, traceId=%s%n", orderNo, traceId);
}
}
注意闭包捕获的是 String 这类不可变值,安全。如果捕获的是 HttpServletRequest 引用,虽然对象还能读,但请求结束后可能已被容器回收或 recycle,行为未定义,不要这么做。
方案二:手动桥接 RequestAttributes
如果异步逻辑里确实要创建 request 作用域 Bean,可以在提交任务前记录当前 RequestAttributes,在异步线程里重新绑定、结束后清理。Spring 自带 RequestContextHolder,但没有开箱可用的跨线程传递装饰器,需要自己写:
public class RequestContextTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
return () -> {
try {
if (attributes != null) {
RequestContextHolder.setRequestAttributes(attributes, true);
}
runnable.run();
} finally {
RequestContextHolder.resetRequestAttributes();
}
};
}
}
然后挂到线程池上:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(256);
executor.setThreadNamePrefix("biz-async-");
executor.setTaskDecorator(new RequestContextTaskDecorator());
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
这里有个关键陷阱:setRequestAttributes(attributes, true) 的第二个参数 inheritable 若为 true,会把属性写入 inheritableRequestAttributesHolder。如果你用的是池化线程,属性会在任务结束后被 resetRequestAttributes() 清掉,没问题;但如果 finally 忘了 reset,或异常路径绕过 finally,属性会残留在线程上,下一次任务就可能读到上一个请求的数据,造成跨用户数据串号。这属于安全事故级别的问题。
另外,请求处理结束后 ServletRequestAttributes.requestCompleted() 可能已被调用,此时属性对象处于半失效状态,只读基本字段通常还行,但别依赖它访问流、Session 等资源。
方案三:原型注入 + 对象工厂(面试官常追问的那条)
「原型注入」在题面里出现,指的是通过 ObjectFactory、ObjectProvider 或 @Lookup 在需要时按需获取 Bean,而不是在单例初始化阶段一次性注入。它常被和 scoped proxy 并列讨论,但要澄清一个常见误解:
原型注入解决的是「每次获取新实例」的问题,它不会把 ThreadLocal 带到新线程。如果你注入的原型 Bean 内部依赖了 request 作用域,在异步线程中调用它,依然会抛
Scope 'request' is not active。只有在原型 Bean 内部接收了从 Web 线程传过来的数据(构造参数或 setter),它才能在异步线程里正常工作。
写法上,ObjectProvider 比 ObjectFactory 更推荐,因为它是 Spring 4.3 引入的扩展,支持 getIfAvailable()、getIfUnique() 等安全获取:
@Component
public class TaskPayloadHolder {
private final String traceId;
private final String userId;
public TaskPayloadHolder(String traceId, String userId) {
this.traceId = traceId;
this.userId = userId;
}
public String getTraceId() { return traceId; }
public String getUserId() { return userId; }
}
@Service
public class ReportService {
@Autowired
private ObjectProvider<TaskPayloadHolder> payloadProvider;
@Autowired
private RequestContextHolderBean requestBean;
public void submit() {
// Web 线程:读 request 作用域数据
String traceId = requestBean.getTraceId();
// 异步线程:手工构造原型实例,数据来自参数,不来自 ThreadLocal
taskExecutor.execute(() -> {
TaskPayloadHolder payload = new TaskPayloadHolder(traceId, "u1001");
System.out.println(payload.getTraceId());
});
}
}
如果你坚持要让原型 Bean 由容器创建(例如它还要注入其他依赖),可以用 provider.getObject(...) 配合工厂方法传参,但注意:Spring 对原型 Bean 不做销毁回调管理,若原型 Bean 持有 @PreDestroy 资源,必须调用方自己负责释放,否则会泄漏。这是面试里有区分度的细节。
修好之后的验证与常见变体
修复后建议用如下断言验证,避免「看起来好了」:
String traceId = requestBean.getTraceId();
assert traceId != null;
CompletableFuture<String> future = CompletableFuture.supplyAsync(
() -> TaskPayloadHolderFactory.of(traceId), taskExecutor);
assertEquals(traceId, future.get(3, TimeUnit.SECONDS));
几个常见变体与注意点:
- WebFlux 场景:
request作用域在响应式编程里同样不可靠,应当改用ServerWebExchange或 Reactor 的Context,不要指望 ThreadLocal。 - 定时任务 / MQ 消费:这些入口根本没有 Web 请求,
RequestContextHolder永远为空。若代码里混用了 request 作用域 Bean,会在这些入口爆发;建议把 request 作用域 Bean 限制在 Controller 层使用。 - 线程池大小与拒绝策略:
CallerRunsPolicy会让任务回落到调用者线程执行,此时 ThreadLocal 反而存在,容易造成「本地测不出来、线上偶发失败」;AbortPolicy则直接抛RejectedExecutionException,排查思路完全不同。 - MDC 日志:
traceId常放在 MDC 里,MDC 底层也是InheritableThreadLocal(Logback 1.4 前为InheritableThreadLocal),同样有线程复用失效问题,建议与 RequestAttributes 一起用TaskDecorator传递。 - Spring 版本差异:Spring 6 / Boot 3 里
RequestContextHolder的实现基本未变,但 Jakarta 命名空间下异常类包名变为jakarta.servlet,贴报错时注意别张冠李戴。
小结
把这道题压缩成一句话:request 作用域的存储介质是线程绑定的 ThreadLocal,异步线程与 Web 线程不是同一线程,所以取不到;ScopedProxy 只负责延迟获取,ObjectFactory/原型注入只负责每次新实例,二者都不跨线程传递上下文。真正跨线程的手段只有两种:值拷贝传递,或在提交任务时桥接 RequestAttributes 并在 finally 里清理。答题时把这四层(作用域实现 → 代理机制 → 异步执行模型 → 修复边界)依次说清,就是完整答案。
FAQ
1. 为什么加了 @Scope(proxyMode = TARGET_CLASS) 启动不报错了,运行还报?
因为启动阶段单例注入的是代理对象,不触发真实 Bean 创建;运行阶段代理转发到 RequestScope.get(),此时才读 ThreadLocal。报错时机变了,根因没变。
2. 用 InheritableThreadLocal 能不能解决?
只在「异步线程由当前线程新建」时有效,线程池复用线程后失效,且容易读到脏数据。线上不推荐。Spring 的 inheritableRequestAttributesHolder 需要显式 setRequestAttributes(attrs, true) 才会用到,并非默认行为。
3. 为什么 finally 里的 resetRequestAttributes 不能省?
线程池复用线程,属性残留会导致下一个任务读到上一个请求的数据,可能出现用户 A 的 traceId 出现在用户 B 的日志里,属于数据泄露。
4. ObjectProvider 拿到的原型 Bean 会不会被 Spring 销毁?
不会。destroy() 只对单例作用域生效,原型 Bean 的生命周期由调用方管理,持有 IO、连接等资源时必须自行关闭。
5. 项目里大量代码依赖 request Bean,改造成本高怎么办?
优先做「入口收敛」:把 request 作用域 Bean 的使用限制在 Controller 及同线程的 Service 调用链内,在进入异步边界前把数据转成 DTO;再配合 TaskDecorator 兜底。不建议全局改用 RequestContextFilter 或自定义 Scope 实现来强行跨线程,这类方案维护成本高且容易在版本升级时踩坑。