如何让Spring Bean同时具备Request和Schedule两种作用域?
我有一个用于请求期间缓存的UsersHandledInRequestCache Bean,配置如下:
@Configuration public class CacheConfiguration { @Bean @RequestScope public UsersHandledInRequestCache usersHandledInRequestCache() { return new UsersHandledInRequestCache(); } public static class UsersHandledInRequestCache { private final Set<Integer> cachedUsersIds = new HashSet<>(); public void cache(Integer userId) { this.cachedUsersIds.add(userId); } public boolean isCached(Integer userId) { return this.cachedUsersIds.contains(userId); } } }
对应的UserService会注入这个缓存Bean,用来避免重复处理同一个用户ID:
@Service public class UserService { @Resource(name = "usersHandledInRequestCache") private CacheConfiguration.UsersHandledInRequestCache usersHandledInRequestCache; public void doSomething(Integer userId) { if (!usersHandledInRequestCache.isCached(userId)) { // 执行业务逻辑 usersHandledInRequestCache.cache(userId); } } }
当Web Controller调用这个服务时完全正常,每个HTTP请求都会创建独立的缓存实例,不会出现重复处理的问题:
@RestController @RequiredArgsConstructor @RequestMapping("/users") public class UserController { private final UserService userService; @PostMapping public void doSomething(@RequestBody Collection<Integer> usersIds) { usersIds.forEach(userService::doSomething); } }
但我还有一个定时任务,同样会调用UserService的方法:
@Component @RequiredArgsConstructor public class UserJob { private final UserService userService; @Scheduled(cron = "0 0 7 * * *") public void doSomething() { userService.getSomeUsersIds.forEach(userService::doSomething); } }
定时任务运行时直接抛出异常,原因很明显——定时任务没有HTTP请求上下文,@RequestScope的Bean无法初始化:
Error creating bean with name 'scopedTarget.usersHandledInRequestCache': 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.
我需要让这个缓存Bean同时支持请求上下文和定时任务上下文,该怎么实现?
方法一:自定义混合作用域(推荐)
自己实现一个Scope,让它在有请求上下文时复用request作用域的逻辑,没有时用线程局部存储(对应定时任务的单次执行线程)。
1. 实现自定义Scope类
import org.springframework.beans.factory.ObjectFactory; import org.springframework.context.support.SimpleThreadScope; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; public class RequestOrScheduleScope extends SimpleThreadScope { @Override public Object get(String name, ObjectFactory<?> objectFactory) { // 先检查是否存在HTTP请求上下文 RequestAttributes requestAttrs = RequestContextHolder.getRequestAttributes(); if (requestAttrs != null) { // 请求上下文下,直接用request作用域存储Bean Object bean = requestAttrs.getAttribute(name, RequestAttributes.SCOPE_REQUEST); if (bean == null) { bean = objectFactory.getObject(); requestAttrs.setAttribute(name, bean, RequestAttributes.SCOPE_REQUEST); } return bean; } else { // 无请求上下文(比如定时任务),用线程局部存储 return super.get(name, objectFactory); } } @Override public void remove(String name) { RequestAttributes requestAttrs = RequestContextHolder.getRequestAttributes(); if (requestAttrs != null) { requestAttrs.removeAttribute(name, RequestAttributes.SCOPE_REQUEST); } else { super.remove(name); } } }
2. 注册自定义作用域并修改Bean配置
import org.springframework.beans.factory.config.CustomScopeConfigurer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Scope; import org.springframework.context.annotation.ScopedProxyMode; @Configuration public class CacheConfiguration { // 注册自定义的混合作用域 @Bean public static CustomScopeConfigurer customScopeConfigurer() { CustomScopeConfigurer configurer = new CustomScopeConfigurer(); configurer.addScope("requestOrSchedule", new RequestOrScheduleScope()); return configurer; } // 把Bean的作用域改成自定义的requestOrSchedule,同时开启代理 @Bean @Scope(value = "requestOrSchedule", proxyMode = ScopedProxyMode.TARGET_CLASS) public UsersHandledInRequestCache usersHandledInRequestCache() { return new UsersHandledInRequestCache(); } public static class UsersHandledInRequestCache { private final Set<Integer> cachedUsersIds = new HashSet<>(); public void cache(Integer userId) { this.cachedUsersIds.add(userId); } public boolean isCached(Integer userId) { return this.cachedUsersIds.contains(userId); } // 新增清理方法,用于定时任务线程复用后清理缓存 public void clear() { this.cachedUsersIds.clear(); } } }
3. 定时任务中手动清理缓存
因为定时任务线程通常会被线程池复用,所以在任务执行完成后必须清理缓存,避免下一次任务拿到残留数据:
@Component @RequiredArgsConstructor public class UserJob { private final UserService userService; private final UsersHandledInRequestCache usersHandledInRequestCache; @Scheduled(cron = "0 0 7 * * *") public void doSomething() { try { userService.getSomeUsersIds.forEach(userService::doSomething); } finally { // 不管任务成功失败,都清理缓存 usersHandledInRequestCache.clear(); } } }
方法二:直接用ThreadLocal实现(简单粗暴)
如果不想自定义Scope,可以直接用ThreadLocal来存储缓存,天然支持线程隔离,不管是Web请求线程还是定时任务线程都能适配。
修改缓存Bean的实现
@Configuration public class CacheConfiguration { @Bean // 这里用单例即可,因为缓存存在ThreadLocal里 public UsersHandledInRequestCache usersHandledInRequestCache() { return new UsersHandledInRequestCache(); } public static class UsersHandledInRequestCache { // 用ThreadLocal维护每个线程独立的缓存集合 private final ThreadLocal<Set<Integer>> cachedUsersIds = ThreadLocal.withInitial(HashSet::new); public void cache(Integer userId) { cachedUsersIds.get().add(userId); } public boolean isCached(Integer userId) { return cachedUsersIds.get().contains(userId); } // 清理当前线程的缓存 public void clear() { cachedUsersIds.remove(); } } }
补充清理逻辑
- Web请求场景:需要在请求结束后清理ThreadLocal,避免Tomcat线程池复用导致缓存残留。可以用拦截器实现:
import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @Component public class CacheCleanInterceptor implements HandlerInterceptor { private final UsersHandledInRequestCache cache; public CacheCleanInterceptor(UsersHandledInRequestCache cache) { this.cache = cache; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 请求完成后清理缓存 cache.clear(); } }
注册拦截器:
import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebConfig implements WebMvcConfigurer { private final CacheCleanInterceptor cacheCleanInterceptor; public WebConfig(CacheCleanInterceptor cacheCleanInterceptor) { this.cacheCleanInterceptor = cacheCleanInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(cacheCleanInterceptor); } }
- 定时任务场景:同样在finally块中清理缓存,和方法一的定时任务代码一致。
方法三:手动模拟请求上下文(不推荐)
这种方式属于hack,强行在定时任务中创建一个空的请求上下文,让@RequestScope的Bean能初始化:
import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.MockHttpServletRequest; @Component @RequiredArgsConstructor public class UserJob { private final UserService userService; @Scheduled(cron = "0 0 7 * * *") public void doSomething() { // 手动创建一个空的请求上下文 RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(new MockHttpServletRequest())); try { userService.getSomeUsersIds.forEach(userService::doSomething); } finally { // 用完必须销毁上下文 RequestContextHolder.resetRequestAttributes(); } } }
这种方法虽然能解决问题,但不符合语义——定时任务本来就和HTTP请求无关,不建议在生产环境使用。
内容的提问来源于stack exchange,提问作者Zhenyria

