用 ThreadLocal 存用户信息,结果拿到了别人的数据——这个坑比你想的更常见
有同学在生产环境遇到过这样一个诡异现象某个接口偶发性地返回了错误的用户数据日志里的用户 ID 和请求里的根本对不上。反复排查权限逻辑、SQL 过滤条件都没问题。最后定位下来问题出在一个用了好几年、看起来无害的工具类上——ThreadLocal。这类问题之所以难排查是因为它在低并发环境下几乎不会复现偏偏在流量上来之后才开始出现而且每次现象都稍有不同。ThreadLocal 在 Spring Boot 里的使用方式用 ThreadLocal 在请求链路里传递用户信息是一个很常见的做法代码通常长这样public class UserContext { private static final ThreadLocalLong USER_ID new ThreadLocal(); public static void set(Long userId) { USER_ID.set(userId); } public static Long get() { return USER_ID.get(); } public static void clear() { USER_ID.remove(); } }在登录拦截器里set在业务层get请求结束后clear。逻辑上没毛病单线程环境里跑得很好。问题在于 Spring Boot 底层用的是 Tomcat 线程池线程不是用完就销毁的而是跑完一个请求后放回池子等下一个。ThreadLocal 和线程绑定线程没有销毁上一个请求设置的值就还在那里。如果下一个请求进来的时候拦截器正确执行了set一切没问题。但如果哪个请求因为某种原因跳过了拦截器——比如走了另一条过滤链或者某个异步分支里没有正确设置——拿到的就是上一个请求留下的用户 ID。用户 A 的线程被用户 B 的请求复用B 就拿到了 A 的数据。另一个场景跨线程提交任务还有一类更隐蔽的情况发生在业务代码里用了线程池的时候Service public class ReportService { Autowired private ThreadPoolExecutor executor; public void generateReport() { Long userId UserContext.get(); // 主线程拿到了用户 ID executor.submit(() - { // 子线程是另一个线程ThreadLocal 不会自动传递 Long uid UserContext.get(); // 这里拿到的是 null或者是这个线程上次留下的值 // 后续逻辑拿到错误的用户 ID }); } }ThreadLocal 变量在登录拦截器里设置的用户信息只在 Spring 容器底层线程即处理请求的 Tomcat 线程上有效。一旦业务代码把任务提交给自定义线程池子线程中获取到的就不是真正的用户信息。这个问题不会抛异常只会安静地让后续逻辑拿到一个 null 或者脏值出问题的地方可能离submit调用很远排查成本很高。内存泄漏的原理除了数据串号ThreadLocal 在线程池场景里还有另一个风险内存泄漏。理解这个问题需要先搞清楚一条引用链每个Thread对象内部维护一个ThreadLocalMap存储的 Entry 结构是WeakReferenceThreadLocal, Object——key 是弱引用value 是强引用。当你把ThreadLocal变量置为 null 或者方法执行结束后ThreadLocal对象本身会在下次 GC 时被回收对应 Entry 的 key 就变成了 null。但 value 还被 Entry 强引用着而 Entry 又被ThreadLocalMap强引用ThreadLocalMap又跟着Thread存活。只要线程还在运行Entry 中 key 为 null 的 value 对象就不会被回收这就是内存泄漏的根源。在使用线程池的场景下核心线程不会被销毁这部分内存会一直积累。ThreadLocal 内部确实做了一定的被动清理在调用get()、set()、remove()时会顺带清理 key 为 null 的 Entry。但这是被动触发的如果你的代码从此之后再也不碰这个 ThreadLocal 实例清理就不会发生。正确的收尾姿势解决方案本身不复杂核心原则只有一条用完必须remove()而且要保证在任何情况下都能执行到。最稳妥的方式是在过滤器或拦截器里用 try-finally 包住Component public class UserContextFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { try { // 解析 Token设置用户 ID Long userId parseToken(request); UserContext.set(userId); chain.doFilter(request, response); } finally { // 无论请求是否正常结束都清理 UserContext.clear(); } } }finally块确保即使请求处理过程中抛了异常清理逻辑也一定会执行。跨线程传递的处理如果业务确实需要把当前用户信息传递给子线程不能靠 ThreadLocal 自动传需要显式传递executor.submit(() - { UserContext.set(userId); // 显式把主线程的值传进来 try { // 业务逻辑 } finally { UserContext.clear(); // 子线程同样要清理 } });如果用CompletableFuture的场景很多也可以考虑阿里开源的TransmittableThreadLocalTTL它能在线程池场景下自动完成父子线程之间的值传递不需要每次手动处理。但 TTL 需要额外引入依赖也需要对线程池做一定改造适合上下文透传需求比较密集的项目统一引入。一个判断标准ThreadLocal 本身没有问题它的设计目标就是线程隔离在单次请求、单线程链路里工作得很好。出问题的地方是两个边界线程被复用时没有清理旧数据以及线程切换时数据没有跟着传过去。在 Spring Boot 项目里这两个边界的交汇点就是 Tomcat 线程池 自定义业务线程池覆盖了绝大多数出问题的场景。把这两个边界守住——请求结束必须remove()跨线程必须显式传值或用 TTL——ThreadLocal 就是一个可靠的工具而不是定时炸弹。

相关新闻