在Web后端开发里,“当前请求是谁”这个问题,几乎每个项目都会遇到。刚工作那会儿我做权限模块,每次在Service层要拿当前登录用户ID,都是Controller从Token里解析出来,再一层层往下传。方法少还好,方法一多,每个方法都挂个userId参数,代码丑得没法看,还容易漏传。后来看到一个老项目用了ThreadLocal处理这个问题,我才反应过来:把用户信息放在当前线程的上下文里,才是正解。Spring Boot项目里用ThreadLocal存放用户信息,算得上Java Web开发中的经典操作了,既能解耦参数传递,又能保证线程隔离,原理和使用都有不少细节值得掰开揉碎讲清楚。
这篇文章适合刚接触ThreadLocal的初中级开发者,也适合那些已经在用、但偶尔被“线程池取值不对”“内存泄漏”这类问题困扰的同行。我会从核心原理讲起,然后给出一个能直接抄进项目里的Spring Boot完整实现,最后把我在实际项目中踩过的坑和排查思路都整理出来,希望能帮你少走弯路。
1. 为什么选ThreadLocal存放用户信息
1.1 先理解ThreadLocal到底做了什么
ThreadLocal的官方定义是“提供线程局部变量”。这句话听起来抽象,我用一个生活化的类比解释:假设公司前台有个快递架,每个工位对应一个专属小格子。快递员把包裹放进对应工位的格子里,哪个员工来取,只能取到自己格子里的那份,别人格子里的东西你碰不到也看不到。ThreadLocal就是这个快递架,每个线程就是不同的工位格子,你往ThreadLocal里放数据,相当于放进当前线程专属的格子里。
从源码层面看,ThreadLocal的核心机制是:每个Thread对象内部维护了一个ThreadLocalMap,这个Map的key是ThreadLocal实例的弱引用,value是你存入的对象。当你想在代码里拿数据时,通过当前的Thread对象找到它的ThreadLocalMap,再用当前的ThreadLocal实例作为key去查value。因为每个线程都有自己的ThreadLocalMap,所以天然做到了线程隔离,不同线程之间存的数据互不干扰。
我在最初学习的时候有个误区,以为ThreadLocal是解决并发下数据混乱问题的。其实准确来说,ThreadLocal解决的是“变量在不同线程间的隔离”问题,而不是“多个线程同时访问同一个变量时的竞争”问题。这两者的本质区别在于:如果你定义了一个static的HashMap,多个线程往里写数据,那需要加锁或者用ConcurrentHashMap;但如果是每个线程在自己的ThreadLocal里读写,根本不存在共享,也就谈不上竞争。
1.2 用户信息传递这个场景的特殊性
在Spring Boot的Web应用中,一次HTTP请求从进入DispatcherServlet到最后响应返回,整个过程都由Tomcat的一个工作线程从头到尾执行。也就是说,请求来了,Tomcat线程池里分配一个线程X来处理;Controller、Service、Mapper这些层全在这个线程X上执行,Request处理完,线程X归还给Tomcat线程池。
这个“单线程贯穿请求全程”的特性,正好是ThreadLocal最理想的使用场景。我们可以把当前登录用户的信息放到ThreadLocal里,在后续的任何层级、任何地方,只要还在这个线程上执行,就能随时取出来用。不用再层层传递参数,也不需要在每个方法签名上加一个多余的用户参数。
和传统的传参方式相比,ThreadLocal的优势非常明显:
- 去除冗余参数:所有业务方法不需要额外挂userId或User对象参数,代码更干净。
- 任意层级访问:工具类、AOP切面、自定义注解解析、MyBatis拦截器等位置都可以直接获取登录用户。
- 自动线程隔离:高并发下,A请求的线程和B请求的线程各存各的,互不覆盖。
还有一个容易被忽略的价值:它让“当前用户”这个概念变成了和“当前时间”“当前环境”类似的上下文信息,不再散落在代码各处。对于大型项目来说,这种基础能力的统一封装比每个业务模块自己维护一套用户传递机制,维护成本要低得多。
1.3 为什么不建议用ServletRequest attribute或参数传递
有人说,Spring MVC本来就可以通过RequestAttributes拿当前请求的信息,为什么还要用ThreadLocal?
理论上,你可以用RequestContextHolder.currentRequestAttributes()拿到当前的HttpServletRequest,然后从attribute里取用户信息。但问题在于:这个方案严重依赖Servlet API,在单元测试、MQ消费、定时任务这类非Web环境下会直接抛异常。而且用Request对象存取数据,本质上还是在和底层容器耦合。
参数传递的问题更直接:如果Controller的每个方法都加一个“User currentUser”参数,接口签名会非常臃肿;如果因为偷懒漏传了某个参数,还会导致后续做用户数据隔离时出现越权漏洞。ThreadLocal的方式不改变方法签名,用的时候取一下,用完删掉,规则统一了,安全性反而更有保证。
2. Spring Boot中存放用户信息的完整实现
2.1 整体设计思路
在Spring Boot里用ThreadLocal存放用户信息,业界比较成熟的方案是“三个组件”配合使用,职责清晰、环环相扣:
第一个是用户上下文持有器(UserContext),内部封装ThreadLocal的读写操作,对外提供set、get、remove等静态方法。持有器把所有细节藏起来,业务方拿用户信息时只需一行代码。
第二个是用户信息解析器(UserInterceptor),实现HandlerInterceptor接口,在preHandle里从请求Token中解析出当前用户,放入UserContext;在afterCompletion里调用UserContext.clear(),确保线程使用完后清空数据,防止复用线程时数据串号。
第三个是Web配置类(WebMvcConfig),负责把拦截器注册到Spring MVC的拦截器链中,并配置需要拦截或放行的URL路径。
这样设计的好处是:业务代码只依赖UserContext这个门面,不依赖具体实现;如果以后要把ThreadLocal换成别的方案,只改UserContext的内部代码即可,业务层完全无感知。
2.2 定义UserContext工具类
这个类就是整个方案对外暴露的唯一入口。第一次写这个类的朋友最容易犯的错是:定义一个成员变量ThreadLocal,然后在Service里new一个UserContext实例来调方法。这种做法是错的。因为Service实例大多是多线程共享的,如果ThreadLocal是Service的成员变量,它虽然内部按线程做了隔离,但语义上还是怪怪的。
标准做法是:UserContext只提供静态方法,ThreadLocal直接以private static final的形式定义在类内部。
public class UserContext { private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<>(); public static void set(LoginUser loginUser) { HOLDER.set(loginUser); } public static LoginUser get() { return HOLDER.get(); } public static Long getUserId() { LoginUser loginUser = HOLDER.get(); return loginUser != null ? loginUser.getUserId() : null; } public static String getUsername() { LoginUser loginUser = HOLDER.get(); return loginUser != null ? loginUser.getUsername() : null; } public static void clear() { HOLDER.remove(); } }LoginUser就是一个普通的POJO,包含userId、username、nickname、roles等业务需要的字段。有人图省事直接往ThreadLocal里塞HttpSession或HttpServletRequest,我建议别这样,一是让上下文和具体容器实现纠缠在一起,二是对象太重,没必要。
关于命名,我看到有项目叫UserThreadLocal、CurrentUserHolder、UserContext的,都行。我习惯用UserContext,因为它的语义更接近“当前用户的上下文环境”,而且方便后续扩展——以后如果想在里面增加请求ID、租户ID,只改这个类就行。
2.3 拦截器:入口解析,出口清场
拦截器是这个方案的执行引擎。它的工作分两步:
preHandle阶段:请求进入Controller之前,从Authorization头或Cookie里拿到Token,解析Token得到用户信息,调用UserContext.set()放入ThreadLocal。这个阶段的代码要尽量“轻”,不要把复杂的业务逻辑写在里面,以防其他请求进入时阻塞Tomcat工作线程。
afterCompletion阶段:请求处理完成、视图渲染完毕之后,调用UserContext.clear()。这一步是重中之重,使用ThreadLocal后必须清理。如果不清理,Tomcat的工作线程在处理完A请求后会被归还到线程池,线程对象没有销毁,ThreadLocalMap还挂在线程上。下一个B请求如果碰巧复用了这个线程,在preHandle里没解析出新的用户信息(比如B请求的Token过期了),get()时就会拿到A请求残留的用户数据,造成数据串号。更严重的是,线程池里的线程生命周期很长,如果每次请求都往ThreadLocal里塞对象而不清理,这些对象会一直被线程引用着,GC永远回收不了,最终引发内存泄漏。
public class UserInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { token = token.substring(7); } LoginUser loginUser = parseToken(token); if (loginUser != null) { UserContext.set(loginUser); } return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserContext.clear(); } private LoginUser parseToken(String token) { // 这里调用JWT解析或Redis查询,返回LoginUser对象 // 解析失败返回null即可,不一定在这里抛异常 return null; } }这里有一个细节:如果你用了Spring Security + JWT,通常自己写一个OncePerRequestFilter,那么只需要在Filter里解析完Token后调用UserContext.set(),然后在finally块里清理即可,不一定要用HandlerInterceptor。这两种方式没有绝对的优劣,取决于项目的权限框架。用拦截器的好处是能精确控制哪些路径需要解析用户、哪些不需要;用Filter的好处是生效时机更早,连静态资源也能覆盖到。我这里给出的拦截器是Spring MVC项目最常见的做法。
2.4 注册拦截器并配置放行规则
光定义拦截器没用,还得把它注册进MVC配置里。
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Resource private UserInterceptor userInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(userInterceptor) .addPathPatterns("/**") .excludePathPatterns( "/api/auth/login", "/api/auth/register", "/doc.html", "/webjars/**", "/favicon.ico" ); } }配置里有几个心得:
- addPathPatterns("/**")是拦截所有路径,然后通过excludePathPatterns精确放行白名单。白名单包括登录注册接口、Swagger文档、静态资源等不需要用户身份的路径。
- 如果在Spring Security体系中,这里配置的是MVC层面拦截器,Security的过滤链是更外层的拦截,职责不同,别搞混了。Security负责认证授权,这里负责把认证结果搬运到ThreadLocal。
- excludePathPatterns的路径匹配规则是Ant通配符,/api/auth/可以匹配/api/auth下的多级子路径,注意这个和*的区别。
完成上面三步,一个最小可用的ThreadLocal传用户信息方案就跑通了。Controller层、Service层的代码直接调用UserContext.getUserId()即可,方法签名一个多余的参数都不用加。
2.5 在业务代码中使用UserContext
写一个简单的获取“我的信息”接口来验证效果:
@RestController @RequestMapping("/api/user") public class UserController { @Resource private UserService userService; @GetMapping("/me") public Result<LoginUser> me() { Long userId = UserContext.getUserId(); // 这里直接通过userId查询用户信息,不用Controller接收userId参数 return Result.success(userService.getUserById(userId)); } }Service层如果还需要用户ID做权限校验,也可以随时取:
@Service public class OrderService { public List<Order> listMyOrders() { Long userId = UserContext.getUserId(); return orderMapper.selectByUserId(userId); } }看起来是不是干净了很多?这就是ThreadLocal方案的日常体验。业务代码不需要感知用户信息是怎么传进来的,反正只要请求是从登录状态发起的,UserContext里就一定拿得到数据。
3. ThreadLocal的底层机制与高频踩坑点
3.1 ThreadLocalMap的弱引用设计
很多人背过“ThreadLocal内存泄漏是因为Entry的key是弱引用”,但只知其然不知其所以然。我尽量把这个机制讲清楚。
先看数据结构:Thread类内部有一个ThreadLocal.ThreadLocalMap类型的成员变量threadLocals。ThreadLocalMap的每个Entry是Entry extends WeakReference<ThreadLocal<?>>,也就是说Entry的key是一个对ThreadLocal实例的弱引用。弱引用的特点是:当JVM进行GC时,如果某个对象只被弱引用指向,没有任何强引用指向它,那么这个对象就会被回收掉。
放到这个场景里:ThreadLocal实例通常被定义为static final,所以它一直存在强引用,不会被回收。但如果你把ThreadLocal定义成普通的成员变量(甚至局部变量创建的匿名ThreadLocal),那么当这个对象不再被业务代码持有时,JVM GC时就会把ThreadLocal实例回收掉,此时ThreadLocalMap里对应的Entry就变成了key为null的“脏Entry”。key都没了,value自然取不出来了,但value还被这个Entry强引用着,如果线程一直存活(比如Tomcat工作线程常驻线程池),那value就永远无法被GC回收,这就是内存泄漏的根源。
所以结论有两个层面:
- 如果你的ThreadLocal是static final的,key存在强引用,不会出现key为null的情况,但value依然需要在请求结束时主动remove。
- 如果某处代码临时创建了ThreadLocal(比如工具方法里直接new了一个),用完还不手动remove,那这个线程的ThreadLocalMap里就会残留脏Entry,长期积累就会泄漏。
这也是我反复强调“set之后务必remove”的原因——不是强迫症,而是生产环境里真实的抖动和OOM警报教会我的。
3.2 线程池场景下的数据串号
ThreadLocal的线程隔离在线程池环境下会遇到一个陷阱。Tomcat的工作线程本身就是一个线程池,线程在线程池中是复用的。假设请求A处理到一半,当前线程X的ThreadLocal里存了用户A的信息;然后请求A代码里向业务线程池提交了一个异步任务Task1,Task1被线程池分配到线程Y执行。如果Task1内部尝试通过UserContext.getUserId()拿用户信息,拿到的是什么?
答案是:大概率是null,甚至可能是上一个复用线程Y的请求残留下来的用户B的信息。为什么?因为ThreadLocal是根据当前执行线程存取的,Task1在Y线程上执行,X线程里的用户A信息对Y来说是不可见的。Y线程被用了多少次、之前执行过哪些任务、有没有清理过ThreadLocal,全是未知数,所以你可能会取到很奇怪的数据。
这个问题在面试中经常被问到,真实项目中也不少见。常用的对策有三种:
- 在提交异步任务之前,把需要传递的用户信息作为参数显式传给任务。简单但不优雅。
- 使用InheritableThreadLocal替代ThreadLocal,可以让新建的子线程自动继承父线程的副本值。但注意,线程池复用线程时,子线程创建一次,继承只发生在第一次创建时,后续复用不会更新。
- 使用阿里巴巴开源的TransmittableThreadLocal(TTL),它在InheritableThreadLocal基础上增强了“任务提交时对线程池进行值传递”的能力,是目前异步场景最标准的解法。
我个人的建议是:如果项目里异步任务比较多,直接引入TTL依赖,让UserContext内部的ThreadLocal替换为TTL的子类,业务代码无需感知任何差异。如果异步场景很少,就老老实实采用参数传递,不要上来就上中间件。
<dependency> <groupId>com.alibaba</groupId> <artifactId>transmittable-thread-local</artifactId> <version>2.14.5</version> </dependency>使用TTL时,UserContext内部的持有器改成:
private static final TransmittableThreadLocal<LoginUser> HOLDER = new TransmittableThreadLocal<>();然后在线程池提交任务时使用TtlRunnable.get(task)包装一下,或者配合TtlExecutors.getTtlExecutorService()包装线程池。这里只是提个方向,具体用法建议参考官方文档。
3.3 父子线程与异步调用的传递细节
这里再展开说一下InheritableThreadLocal和TransmittableThreadLocal的区别,避免新手选错。
InheritableThreadLocal是JDK自带的类,继承自ThreadLocal。当主线程new了一个子线程时,子线程构造过程中会把父线程的ThreadLocalMap中的可继承值复制到自己的ThreadLocalMap中。它的局限非常明显:
- 只在创建子线程的那一刻拷贝一次。如果父线程在处理过程中多次修改了ThreadLocal中的值,子线程拿到的还是创建时那个旧值。
- 对线程池基本无效。线程池里的线程是预先创建好的,不是请求来了才创建的,所以继承动作根本不是发生在业务请求的那个父线程上的。
TransmittableThreadLocal则专门解决了线程池场景下的值传递问题。它的实现思路是:在线程池任务提交时,捕获当前线程的所有TTL值快照,在任务真正执行前回放到执行线程上,任务执行完后恢复原值。这样每次任务提交都能拿到最新的父线程数据,线程池复用时也不会串号。
如果你的项目里用了@Async注解、消息队列消费、定时任务、链路追踪等,强烈建议用TTL方案。这不是过度设计,而是实际分布式系统里排查“用户信息突然消失”时会救命的方案。
4. 基于Spring Boot的具体集成与扩展
4.1 与Spring MVC拦截器链的协作细节
把UserInterceptor放进MVC拦截器链路时,要注意执行顺序。如果你在WebMvcConfig里还注册了其他拦截器(比如日志拦截器、跨域拦截器、接口耗时统计拦截器),它们的执行顺序会影响UserContext里有没有数据。
HandlerInterceptor的执行顺序是:按照注册顺序执行所有拦截器的preHandle,然后进入Controller执行,执行完后按照注册顺序的逆序执行所有拦截器的postHandle和afterCompletion。这意味着:
- 如果日志拦截器注册在UserInterceptor之前,日志拦截器在preHandle里打日志时,UserContext还没被set,取不到用户信息。
- 如果跨域拦截器注册在UserInterceptor之后,并且提前return false了,那么其他拦截器的afterCompletion可能不会执行,UserContext可能得不到清理。这一点要特别注意,最好在UserInterceptor自己的afterCompletion里做好清理,不要依赖其他拦截器一定会执行。
关于CB跨域配置,这个和ThreadLocal本身没有直接关系,但如果你项目中配置了CorsFilter,注意它的优先级高于MVC拦截器,请求先经过CorsFilter再进入拦截器,不要被“跨域时预检请求OPTIONS也会被拦截器处理”这类问题恶心到。
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LogInterceptor()).order(1); registry.addInterceptor(userInterceptor).order(2); registry.addInterceptor(new RateLimitInterceptor()).order(3); }Spring Boot支持用order()控制拦截器执行顺序,数字越小越先执行。建议把UserInterceptor放在比较靠后的位置,保证进入业务前提早set;清理放在afterCompletion里,确保最后执行清理。
4.2 配合Spring Security的常见姿势
如果你的项目用了Spring Security,配置方式略有不同。常见架构是用JWT做无状态认证,然后在Security的过滤链中加入一个自定义OncePerRequestFilter:
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { String token = resolveToken(request); if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) { LoginUser loginUser = parseToken(token); if (loginUser != null) { UserContext.set(loginUser); } } filterChain.doFilter(request, response); } finally { UserContext.clear(); } } }注意我特意用了一个try-finally结构:无论后面的Filter或Controller有没有抛异常,finally块一定会执行UserContext.clear(),这是比afterCompletion更保险的写法。因为Filter的scope比MVC拦截器更外层,它能保证ThreadLocal一定被清理。
Spring Security版本从5.x升级到6.x后,配置方式有些变化(比如WebSecurityConfigurerAdapter废弃,改为SecurityFilterChain Bean),但Filter的写法基本不受影响。
4.3 从ThreadLocal到MDC:日志链路中的用户信息
ThreadLocal存用户信息这件事,还能延伸到一个非常实用的场景:日志链路追踪。如果每个线程的日志都能自动带上当前操作用户ID,排查线上问题时,一条用户操作的全链路日志就非常直观。
SLF4J自带MDC机制,本质也是基于ThreadLocal实现的。你可以在拦截器里把userId塞进MDC:
MDC.put("userId", String.valueOf(UserContext.getUserId()));然后在logback配置文件的pattern里加上%X{userId},比如:
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} userId:%X{userId} - %msg%n</pattern>这样所有日志打出来都会带上当前用户的ID。清理的时候除了UserContext.clear(),别忘了MDC.remove("userId")。MDC不清理的后果和ThreadLocal不清理是一样的,线程池日志全串号。
我见过不少项目把用户信息、请求ID、链路追踪ID统一放在一个ThreadLocal持有的TraceContext对象中,然后配合MDC输出日志,两边共用同一个上下文数据源,效果非常理想。
4.4 防止ThreadLocal滥用的一点建议
说回设计原则,ThreadLocal虽然好用,但不能乱用。几类不推荐塞进ThreadLocal的场景各位应该要有数:
- 大数据对象:比如把整个数据库查询结果集合放到ThreadLocal里,内存开销大且不易回收,容易触发频繁GC。
- 数据库连接、网络连接等有资源生命周期管理的对象:这种对象应该由连接池管理,而不是ThreadLocal。
- 有状态且会被多个线程共享修改的服务类实例:如果对象内部有可变状态,不同线程同时访问还会出现可见性问题,ThreadLocal解决不了这个。
ThreadLocal适合存放的是“线程私有、跨方法传递、生命周期不超过线程工作周期”的数据。用户信息、请求ID、事务上下文、租户信息,这类轻量级上下文数据是最佳候选。
5. 常见问题与排查技巧实录
5.1 问题速查表
我把实际开发和线上排查中高频遇到的问题整理成一个速查表,方便大家对照处理。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| Controller里能拿到用户,Service里取到null | 异步线程导致上下文丢失;或者Service层通过@Async异步执行 | 使用TTL,或显式传参 |
| 用户A的请求里取到了用户B的信息 | ThreadLocal未清理,线程池复用了残留数据 | 检查afterCompletion或finally块是否执行remove,排查是否有拦截器提前return false |
| 设置完ThreadLocal后,日志里打印出旧userId | MDC未清理,或者同一过滤器链中先执行了其他过滤器塞入了MDC | 在每个过滤器的finally中统一清理MDC和UserContext |
| 线程池里执行任务时UserContext里有旧任务的用户信息 | 提交任务时没有清理或者任务结束后没有remove | 使用TtlRunnable包装,或任务开头set,任务finally里remove |
| 应用内存持续增长,且有大量ThreadLocalMap对象 | 长期未调用remove,或者创建了大量临时ThreadLocal | 对代码进行搜索,找到所有ThreadLocal的set点,确保对应try-finally中remove |
| 我明明set了,但get还是null | 可能set和get不在同一个线程;也可能set后线程被切换;也可能是跨JVM场景 | 打印当前线程名称,确认执行线程是否一致,排查异步、代理、熔断器导致的线程切换 |
5.2 排查“取不到用户”的定位思路
如果你遇到UserContext.get()返回null,不要慌。我的排查顺序一般是:
- 先确认请求是否真的经过了拦截器。用debugger在preHandle里打一个断点,看token是否解析成功,set是否执行。不经过拦截器的路径(比如404、静态资源、被其他过滤器提前return false)都不会set。
- 再确认读数据的线程和执行拦截器的线程是不是同一个。给UserContext的get方法临时加一行日志,打印当前线程名,再对比拦截器里set时的线程名。不一样就是线程切换了,去看异步配置。
- 最后检查有没有全局异常处理器直接捕获异常后重新提交线程池做补偿任务的情况。这种代码会“偷走”线程上下文,排查起来最隐蔽,要多搜搜。
5.3 面试中怎么把ThreadLocal讲清楚
ThreadLocal是Java面试高频题,如果你正好在准备跳槽,我建议按这个逻辑去讲:
先讲作用和适用场景:线程隔离、上下文变量传递。再讲原理:Thread类里有ThreadLocalMap,key是ThreadLocal弱引用,value是对象。然后讲内存泄漏问题:为什么弱引用还会泄漏?因为value被强引用,线程存活时value无法回收,所以必须remove。最后结合Spring Boot项目讲应用:拦截器set、afterCompletion清、异步场景的传递问题。
如果能讲清楚“为什么JDK设计成弱引用”这个问题,面试官会认为你是真的有深度。我的理解是:弱引用设计是为了尽量降低“ThreadLocal实例无法被回收”的风险,因为ThreadLocal如果是局部变量(非static),一旦业务代码执行完,线程还活着的话,强引用可以让ThreadLocal实例继续被引用,而弱引用下,ThreadLocal实例可以被GC回收,从而让Entry失效,虽然value还是泄漏,但至少key一侧不会再膨胀。这个设计谈不上完美,所以官方也建议每次用完手动remove。
5.4 推荐一份稳健的代码模板
最后分享一个我一直在用的“稳健版”UserContext模板。它在拦截器入口负责设置,在finally里负责清理,并且提供判断是否登录的方法,适合直接嵌进项目:
public class UserContext { private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<>(); private UserContext() { } public static void set(LoginUser user) { HOLDER.set(user); } public static LoginUser get() { return HOLDER.get(); } public static boolean isLoggedIn() { return HOLDER.get() != null; } public static Long getUserId() { LoginUser user = HOLDER.get(); return user == null ? null : user.getUserId(); } public static void clear() { HOLDER.remove(); } }对应拦截器的标准写法:
@Component public class UserContextInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { LoginUser user = resolveUserFromRequest(request); if (user != null) { UserContext.set(user); } return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } private LoginUser resolveUserFromRequest(HttpServletRequest request) { // 伪代码:解析token -> 查用户信息 -> 返回LoginUser return null; } }这套组合经过我多个项目的验证,放在通用权限框架里能稳定工作,配合日志MDC、权限注解、多租户过滤链路都没问题。
如果你现在正好在做Spring Boot项目,要处理“当前登录用户”这类上下文传递的需求,我强烈建议你在项目早期就建立这个统一上下文机制。别等到Controller、Service、Feign、MQ满天飞了再改造,那时候提出“所有方法都要改签名”,光是说服同事就会耗掉大量精力。而如果一开始就把这个基础设施搭好,不管项目后面怎么膨胀,用户信息的传递路径始终是清晰且可控的。