☰
ThreadLocal + @Async 导致用户数据串号,排查与解决方案(附源码)
2026/9/30 16:47:46 网站建设 项目流程

老炮踩坑录 · F05 · 翻车现场系列

基于「企业融合评估平台」真实源码,复盘一个静态 ThreadLocal 引发的跨请求数据泄漏

关键词:ThreadLocal 串数据 · @Async 线程池 · Session 泄漏 · remove 没调

👋欢迎阅读

🏠个人主页:知守观
📘专栏传送门:老炮踩坑录专栏
💻当前内容:ThreadLocal

引子

离职几个月后,有一天晚上,前同事突然给我发微信:“老哥,又出大事了!”

他说客服群里今天炸了锅——有个用户登录进去,看到的居然是另一家公司的企业信息。

我的第一反应是:缓存没清干净。

第二反应是:不可能啊,Session 是按用户隔离的,每个请求拿自己的 Session,怎么会串?

打开代码,看到这一行:

publicstaticfinalThreadLocal<Map<String,Object>>threadLocal=newThreadLocal<>();

一个static的 ThreadLocal,存的是用户的 Session 数据。

再往下看——全项目没有一处调用过threadLocal.remove()。

一个都没有。

那一刻我就知道问题出在哪了。

案发现场:一段"看起来没问题"的代码

这个项目的登录流程有个特殊设计:外部系统的登录回调是异步处理的。

异步线程里需要用到当前请求的 HTTP Session——但问题来了:异步线程跑在另一个线程上,RequestContextHolder拿不到当前请求的HttpServletRequest,也就拿不到 Session。

怎么办?开发者想了一个办法:用 ThreadLocal 把 Session "搬"过去。

来看完整代码:

// AsyncService.java@Slf4j@ServicepublicclassAsyncService{// 1. 静态 ThreadLocal,存用户 SessionpublicstaticfinalThreadLocal<Map<String,Object>>threadLocal=newThreadLocal<>();// 2.异步登录方法@AsyncpublicvoidextLoginInfo(JSONObjectuserInfo,JSONObjectcompanyUpdateFrom,StringcompanyId,Map<String,Object>data){threadLocal.set(data);// 3. 把 Session 塞进 ThreadLocalloginService.extLoginInfo(userInfo,companyUpdateFrom,companyId);}}

然后在需要 Session 的地方,这样取:

// SessionCacheUtils.java / LoginServiceImpl.java 等 5个类文件Map<String,Object>stringObjectMap1=AsyncService.threadLocal.get();// 4.从 ThreadLocal 取if(stringObjectMap1!=null&&stringObjectMap1.containsKey("session")){session=(HttpSession)stringObjectMap1.get("session");}else{// 兜底:从 RequestContextHolder 取HttpServletRequestrequest=((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest();session=request.getSession();}

全项目有5 个文件在用同样的方式取 Session——全都是AsyncService.threadLocal.get()。

问题出在哪?

如果没看出,我先把执行过程画成一张图:

时间线 ──────────────────────────────────────────────────► 线程池-线程1: 请求A → threadLocal.set(用户A的Session) → 业务处理... → 方法结束 → 没有 remove(),用户A的Session还在线程1的ThreadLocal里 线程池-线程1(被复用): 请求B → 业务代码调用 threadLocal.get() → 拿到了用户A的Session ← 数据串了!

用户 B 拿到了用户 A 的 Session。

然后再写个复现测试:

空口无凭的说会串,不如跑一遍。照着真实代码的骨架,三十行代码必现:

publicclassCrossoverProof{// 和 AsyncService.java:29 一模一样的声明staticfinalThreadLocal<Map<String,Object>>threadLocal=newThreadLocal<Map<String,Object>>();publicstaticvoidmain(String[]args)throwsException{// 模拟“万一被池化了”的场景:核心1、最大1、无界队列,线程必复用// (项目实际用的 SimpleAsyncTaskExecutor 不池化,但只要有人改了配置,就会变成这样)// 模拟 Boot 2.1 的 applicationTaskExecutor:池化,核心 1,线程必复用ThreadPoolExecutorpool=newThreadPoolExecutor(1,1,0,TimeUnit.SECONDS,newLinkedBlockingQueue<>());// 企业A的异步登录:set 完不清理(AsyncService.java:47 原样)Map<String,Object>dataA=newHashMap<>();dataA.put("session",fakeSession("企业A"));pool.execute(()->{threadLocal.set(dataA);System.out.println("企业A:异步登录处理完毕");});pool.execute(()->{// ApiServiceImpl.sendRegisterCompany 里的读取逻辑,原样Map<String,Object>ctx=threadLocal.get();if(ctx!=null&&ctx.containsKey("session")){Map<String,Object>session=(Map<String,Object>)ctx.get("session");System.out.println("企业B的后台任务,拿到session归属:"+session.get("owner"));}});pool.shutdown();}staticMap<String,Object>fakeSession(Stringowner){Map<String,Object>session=newHashMap<>();session.put("owner",owner);returnsession;}}

输出:

企业A:异步登录处理完毕 企业B的后台任务,拿到session归属:企业A

单线程池保证两个任务在同一条线程上排队,企业 B 的任务读到的 session 属于企业 A。放到 8 线程的 applicationTaskExecutor 上,只

是从必现变成概率性——高峰期两个企业的异步任务凑上同一条线程,就都拿着 A 的 token 和 enterpriseid 去调远程接口了。企业 A 的报告出现在 B 的列表里,B 的附件写进 A 的目录——具体串成什么样,取决于撞上的是五处读取里的哪一处。轻则显示错误的企业信息,重则越权操作——用 A 的身份提交了 B 的数据。

为什么"串了"不是偶然,是必然

这个 bug 有三个致命的设计缺陷,每一个都足以引爆问题。

缺陷一:set 了,但从来没 remove

我搜遍了整个项目:

grep -r "threadLocal.remove" src/ → 0 matches

零。一次remove()都没有。

ThreadLocal 的设计原则很简单:谁 set,谁 remove。用完不删,数据就赖在线程上,等下一个线程来"继承"。

如果线程是"一次性"的(用完就销毁),问题不大——数据跟着线程一起死了。

但如果线程是复用的(线程池),问题就来了——上一个请求留下的数据,会被下一个请求读到。

缺陷二:@Async 背后是线程复用

这个项目的启动类加了@EnableAsync:

@EnableAsync@EnableSchedulingpublicclassEnterpriseFrameworkApplication{publicstaticvoidmain(String[]args){SpringApplication.run(EnterpriseFrameworkApplication.class,args);}}

没有配置自定义线程池。Spring Boot 2.1.0 默认用的是SimpleAsyncTaskExecutor——不限制线程数,每个任务创建一个新线程,不复用。

看起来没问题?别急。

  • 第一,SimpleAsyncTaskExecutor在高并发下会创建大量线程,本身就是一个隐患。
  • 第二,也是更重要的——这个设计是脆弱的。

什么叫脆弱?就是"现在碰巧没出事,但任何一个改动都会让它出事":

  • 有人在application.yml里加了一行spring.task.execution.pool.core-size=8→ 线程池化了 → 串数据
  • 有人加了自定义TaskExecutorBean → 线程池化了 → 串数据
  • Spring Boot 升级后默认行为变了 → 线程池化了 → 串数据

你依赖的是"碰巧没有线程池",而不是"代码本身是安全的"。这不叫设计,叫赌运气。

缺陷三:static ThreadLocal 存请求级数据

publicstaticfinalThreadLocal<Map<String,Object>>threadLocal=newThreadLocal<>();

static final——这个 ThreadLocal 是类级别的,所有实例共享同一个。

它存的是什么?是Map<String, Object>,里面装着当前请求的 HTTP Session。

Session 是请求级的数据,ThreadLocal 是线程级的存储。把请求级的数据放在线程级的容器里,本身就需要极其小心的管理它的生命周期。

而这个项目里:

  • set 的时候不管线程是不是复用的
  • get 的时候不检查数据是不是当前请求的
  • 用完之后不 remove

三步全错。

这个 bug 为什么难排查

ThreadLocal 串数据有一个特点:不可预测、不可复现。

  • 单线程测试?不会串。因为 set 和 get 在同一个线程里。
  • 低并发?可能不串。因为线程还没来得及复用。
  • 高并发?一定串。但高并发时的错误日志也是乱的,你很难把"用户 A 的数据出现在用户 B 的上下文里"和"ThreadLocal 没清"联系起来。

更坑的是,代码里有一个"兜底逻辑":

if(stringObjectMap1!=null&&stringObjectMap1.containsKey("session")){session=(HttpSession)stringObjectMap1.get("session");// ThreadLocal 有就用}else{session=request.getSession();// 没有就从 Request 取}

如果 ThreadLocal 里没有数据,代码会正常从RequestContextHolder取 Session——一切正常。

但如果 ThreadLocal 里有上一个请求遗留的数据——代码会优先使用那份脏数据,而且不会报任何错。

它不是崩溃,是"安静地用错数据"。这是最难的 bug 类型——没有异常、没有堆栈、没有错误日志,只有"数据不对"。

正确写法:三条铁律

铁律一:ThreadLocal 必须 remove,放在 finally 里

// 错误:set了不remove@AsyncpublicvoiddoSomething(Map<String,Object>data){threadLocal.set(data);businessService.process();// 结束了,threadLocal 里的数据还在}// 正确:finally 里 remove@AsyncpublicvoiddoSomething(Map<String,Object>data){threadLocal.set(data);try{businessService.process();}finally{threadLocal.remove();// 不管成功失败,一定清理}}

finally不是可选的——它是 ThreadLocal 使用的标配。

铁律二:不要用 ThreadLocal 跨线程传递请求上下文

ThreadLocal 的设计初衷是线程隔离——让每个线程有自己的独立副本。它不是用来跨线程传数据的。

如果你需要在异步线程里拿到请求上下文,正确的做法是:

// 方案一:参数传递,不用 ThreadLocal@AsyncpublicvoidextLoginInfo(JSONObjectuserInfo,StringcompanyId,HttpSessionsession){// Session 作为参数直接传进来,不依赖 ThreadLocalloginService.extLoginInfo(userInfo,companyId,session);}// 方案二:使用 TaskDecorator(Spring 4.3+)publicclassSessionTaskDecoratorimplementsTaskDecorator{@OverridepublicRunnabledecorate(Runnablerunnable){RequestAttributesattributes=RequestContextHolder.getRequestAttributes();Map<String,Object>data=extractContext();return()->{try{AsyncService.threadLocal.set(data);runnable.run();}finally{AsyncService.threadLocal.remove();}};}}
  • 方案一把上下文当参数传,清晰、安全、可追踪。
  • 方案二用 Spring 的TaskDecorator统一处理,避免在每个@Async方法里重复 set/remove。

铁律三:@Async 必须配自定义线程池

// 默认 SimpleAsyncTaskExecutor:线程数不可控@EnableAsync// 自定义线程池 + TaskDecorator 自动传递上下文@Configuration@EnableAsyncpublicclassAsyncConfigimplementsAsyncConfigurer{@OverridepublicExecutorgetAsyncExecutor(){ThreadPoolTaskExecutorexecutor=newThreadPoolTaskExecutor();executor.setCorePoolSize(4);executor.setMaxPoolSize(8);executor.setQueueCapacity(100);executor.setThreadNamePrefix("async-");executor.setTaskDecorator(newSessionTaskDecorator());// 自动传递上下文executor.initialize();returnexecutor;}}

不配线程池,@Async就是"盲飞"——你不知道线程怎么创建的,不知道并发上限是多少,不知道 ThreadLocal 会不会串。

自查清单

在你的项目里搜三个东西:

检查项怎么搜危险信号
ThreadLocal.set 没有对应的 remove搜threadLocal.set,检查同一方法内是否有finally { remove() }set 和 remove 不成对 = 必出 bug
static ThreadLocal 存请求级数据搜static.*ThreadLocal存 Session、存用户信息、存请求参数 = 高风险
@Async 没有自定义线程池搜@EnableAsync,看有没有配套的AsyncConfigurer没配 = 线程数不可控,上下文传递不可靠

老炮点评

这个 bug 的本质是"ThreadLocal 用错了",用 ThreadLocal 来解决一个它不该解决的问题。

异步线程拿不到请求上下文,这是一个真实的问题。但 ThreadLocal 不是答案——它是"看起来能用的锤子"。

真正的答案是:把上下文当参数传。简单、直接、可追踪、不会串。

但"当参数传"意味着要改方法签名,要一层一层往下传,要改很多代码。而 ThreadLocal 只需要一个static变量——短期省的事,长期全变成了 bug。

这就是技术债的典型特征:用错误的方式解决正确的问题,省了今天的代码量,欠了明天的排查时间。


下期预告:《硬编码 paperid==0/1/2/3,产品说加第 5 个模型时我慌了》

switch 写死四种诊断模型,策略模式 10 分钟的事,硬是拖了三年。等产品经理说"我们要加第 5 个"的时候,我才发现改一个 paperid 要动 7 个文件。

下期讲这个"硬编码之债"是怎么滚起来的,以及怎么用策略模式 10 分钟解决。

如果本文对你有帮助,欢迎:

👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言

你的每一次互动都是我继续更新的动力,我们下一篇见!🚀

我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询