面试题:Spring request 作用域 Bean 在异步线程中丢失的根因与原型注入

这道题在面试里出现频率不低,但很多人只能答出「因为 ThreadLocal 拿不到 request」,再往下问就卡住了:ThreadLocal 到底存在哪、Spring 是怎么把当前请求绑定到线程上的、为什么加个 @Async 就报 Scope 'request' is not active for the current thread、以及为什么「注入一个

这道题在面试里出现频率不低,但很多人只能答出「因为 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 里取的。具体链路如下:

  1. DispatcherServlet 处理请求前,FrameworkServlet.processRequest 调用 initContextHolders,最终把 ServletRequestAttributes 放入 RequestContextHolder 的 requestAttributesHolder 中。
  2. RequestContextHolder 内部维护两个静态字段:requestAttributesHolder(NamedThreadLocal,名为 RequestAttributes)和 inheritableRequestAttributesHolder(NamedInheritableThreadLocal)。默认用的是第一个,即不可继承的 ThreadLocal。
  3. 请求处理完毕后,resetContextHolders 会 remove() 掉对应值,防止线程池复用导致的内存泄漏和串请求。
  4. 创建 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 实现来强行跨线程,这类方案维护成本高且容易在版本升级时踩坑。