ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ThreadLocal + @Async 导致用户数据串号,排查与解决方案(附源码)

ThreadLocal + @Async 导致用户数据串号,排查与解决方案(附源码) 老炮踩坑录 · F05 · 翻车现场系列基于「企业融合评估平台」真实源码复盘一个静态 ThreadLocal 引发的跨请求数据泄漏关键词ThreadLocal 串数据 · Async 线程池 · Session 泄漏 · remove 没调欢迎阅读个人主页知守观专栏传送门老炮踩坑录专栏当前内容ThreadLocal引子离职几个月后有一天晚上前同事突然给我发微信“老哥又出大事了”他说客服群里今天炸了锅——有个用户登录进去看到的居然是另一家公司的企业信息。我的第一反应是缓存没清干净。第二反应是不可能啊Session 是按用户隔离的每个请求拿自己的 Session怎么会串打开代码看到这一行publicstaticfinalThreadLocalMapString,ObjectthreadLocalnewThreadLocal();一个static的 ThreadLocal存的是用户的 Session 数据。再往下看——全项目没有一处调用过threadLocal.remove()。一个都没有。那一刻我就知道问题出在哪了。案发现场一段看起来没问题的代码这个项目的登录流程有个特殊设计外部系统的登录回调是异步处理的。异步线程里需要用到当前请求的 HTTP Session——但问题来了异步线程跑在另一个线程上RequestContextHolder拿不到当前请求的HttpServletRequest也就拿不到 Session。怎么办开发者想了一个办法用 ThreadLocal 把 Session 搬过去。来看完整代码// AsyncService.javaSlf4jServicepublicclassAsyncService{// 1. 静态 ThreadLocal存用户 SessionpublicstaticfinalThreadLocalMapString,ObjectthreadLocalnewThreadLocal();// 2.异步登录方法AsyncpublicvoidextLoginInfo(JSONObjectuserInfo,JSONObjectcompanyUpdateFrom,StringcompanyId,MapString,Objectdata){threadLocal.set(data);// 3. 把 Session 塞进 ThreadLocalloginService.extLoginInfo(userInfo,companyUpdateFrom,companyId);}}然后在需要 Session 的地方这样取// SessionCacheUtils.java / LoginServiceImpl.java 等 5个类文件MapString,ObjectstringObjectMap1AsyncService.threadLocal.get();// 4.从 ThreadLocal 取if(stringObjectMap1!nullstringObjectMap1.containsKey(session)){session(HttpSession)stringObjectMap1.get(session);}else{// 兜底从 RequestContextHolder 取HttpServletRequestrequest((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest();sessionrequest.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 一模一样的声明staticfinalThreadLocalMapString,ObjectthreadLocalnewThreadLocalMapString,Object();publicstaticvoidmain(String[]args)throwsException{// 模拟“万一被池化了”的场景核心1、最大1、无界队列线程必复用// 项目实际用的 SimpleAsyncTaskExecutor 不池化但只要有人改了配置就会变成这样// 模拟 Boot 2.1 的 applicationTaskExecutor池化核心 1线程必复用ThreadPoolExecutorpoolnewThreadPoolExecutor(1,1,0,TimeUnit.SECONDS,newLinkedBlockingQueue());// 企业A的异步登录set 完不清理AsyncService.java:47 原样MapString,ObjectdataAnewHashMap();dataA.put(session,fakeSession(企业A));pool.execute(()-{threadLocal.set(dataA);System.out.println(企业A异步登录处理完毕);});pool.execute(()-{// ApiServiceImpl.sendRegisterCompany 里的读取逻辑原样MapString,ObjectctxthreadLocal.get();if(ctx!nullctx.containsKey(session)){MapString,Objectsession(MapString,Object)ctx.get(session);System.out.println(企业B的后台任务拿到session归属session.get(owner));}});pool.shutdown();}staticMapString,ObjectfakeSession(Stringowner){MapString,ObjectsessionnewHashMap();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 背后是线程复用这个项目的启动类加了EnableAsyncEnableAsyncEnableSchedulingpublicclassEnterpriseFrameworkApplication{publicstaticvoidmain(String[]args){SpringApplication.run(EnterpriseFrameworkApplication.class,args);}}没有配置自定义线程池。Spring Boot 2.1.0 默认用的是SimpleAsyncTaskExecutor——不限制线程数每个任务创建一个新线程不复用。看起来没问题别急。第一SimpleAsyncTaskExecutor在高并发下会创建大量线程本身就是一个隐患。第二也是更重要的——这个设计是脆弱的。什么叫脆弱就是现在碰巧没出事但任何一个改动都会让它出事有人在application.yml里加了一行spring.task.execution.pool.core-size8→ 线程池化了 → 串数据有人加了自定义TaskExecutorBean → 线程池化了 → 串数据Spring Boot 升级后默认行为变了 → 线程池化了 → 串数据你依赖的是碰巧没有线程池而不是代码本身是安全的。这不叫设计叫赌运气。缺陷三static ThreadLocal 存请求级数据publicstaticfinalThreadLocalMapString,ObjectthreadLocalnewThreadLocal();static final——这个 ThreadLocal 是类级别的所有实例共享同一个。它存的是什么是MapString, Object里面装着当前请求的 HTTP Session。Session 是请求级的数据ThreadLocal 是线程级的存储。把请求级的数据放在线程级的容器里本身就需要极其小心的管理它的生命周期。而这个项目里set 的时候不管线程是不是复用的get 的时候不检查数据是不是当前请求的用完之后不 remove三步全错。这个 bug 为什么难排查ThreadLocal 串数据有一个特点不可预测、不可复现。单线程测试不会串。因为 set 和 get 在同一个线程里。低并发可能不串。因为线程还没来得及复用。高并发一定串。但高并发时的错误日志也是乱的你很难把用户 A 的数据出现在用户 B 的上下文里和ThreadLocal 没清联系起来。更坑的是代码里有一个兜底逻辑if(stringObjectMap1!nullstringObjectMap1.containsKey(session)){session(HttpSession)stringObjectMap1.get(session);// ThreadLocal 有就用}else{sessionrequest.getSession();// 没有就从 Request 取}如果 ThreadLocal 里没有数据代码会正常从RequestContextHolder取 Session——一切正常。但如果 ThreadLocal 里有上一个请求遗留的数据——代码会优先使用那份脏数据而且不会报任何错。它不是崩溃是安静地用错数据。这是最难的 bug 类型——没有异常、没有堆栈、没有错误日志只有数据不对。正确写法三条铁律铁律一ThreadLocal 必须 remove放在 finally 里// 错误set了不removeAsyncpublicvoiddoSomething(MapString,Objectdata){threadLocal.set(data);businessService.process();// 结束了threadLocal 里的数据还在}// 正确finally 里 removeAsyncpublicvoiddoSomething(MapString,Objectdata){threadLocal.set(data);try{businessService.process();}finally{threadLocal.remove();// 不管成功失败一定清理}}finally不是可选的——它是 ThreadLocal 使用的标配。铁律二不要用 ThreadLocal 跨线程传递请求上下文ThreadLocal 的设计初衷是线程隔离——让每个线程有自己的独立副本。它不是用来跨线程传数据的。如果你需要在异步线程里拿到请求上下文正确的做法是// 方案一参数传递不用 ThreadLocalAsyncpublicvoidextLoginInfo(JSONObjectuserInfo,StringcompanyId,HttpSessionsession){// Session 作为参数直接传进来不依赖 ThreadLocalloginService.extLoginInfo(userInfo,companyId,session);}// 方案二使用 TaskDecoratorSpring 4.3publicclassSessionTaskDecoratorimplementsTaskDecorator{OverridepublicRunnabledecorate(Runnablerunnable){RequestAttributesattributesRequestContextHolder.getRequestAttributes();MapString,ObjectdataextractContext();return()-{try{AsyncService.threadLocal.set(data);runnable.run();}finally{AsyncService.threadLocal.remove();}};}}方案一把上下文当参数传清晰、安全、可追踪。方案二用 Spring 的TaskDecorator统一处理避免在每个Async方法里重复 set/remove。铁律三Async 必须配自定义线程池// 默认 SimpleAsyncTaskExecutor线程数不可控EnableAsync// 自定义线程池 TaskDecorator 自动传递上下文ConfigurationEnableAsyncpublicclassAsyncConfigimplementsAsyncConfigurer{OverridepublicExecutorgetAsyncExecutor(){ThreadPoolTaskExecutorexecutornewThreadPoolTaskExecutor();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 不成对 必出 bugstatic ThreadLocal 存请求级数据搜static.*ThreadLocal存 Session、存用户信息、存请求参数 高风险Async 没有自定义线程池搜EnableAsync看有没有配套的AsyncConfigurer没配 线程数不可控上下文传递不可靠老炮点评这个 bug 的本质是ThreadLocal 用错了用 ThreadLocal 来解决一个它不该解决的问题。异步线程拿不到请求上下文这是一个真实的问题。但 ThreadLocal 不是答案——它是看起来能用的锤子。真正的答案是把上下文当参数传。简单、直接、可追踪、不会串。但当参数传意味着要改方法签名要一层一层往下传要改很多代码。而 ThreadLocal 只需要一个static变量——短期省的事长期全变成了 bug。这就是技术债的典型特征用错误的方式解决正确的问题省了今天的代码量欠了明天的排查时间。下期预告《硬编码 paperid0/1/2/3产品说加第 5 个模型时我慌了》switch 写死四种诊断模型策略模式 10 分钟的事硬是拖了三年。等产品经理说我们要加第 5 个的时候我才发现改一个 paperid 要动 7 个文件。下期讲这个硬编码之债是怎么滚起来的以及怎么用策略模式 10 分钟解决。如果本文对你有帮助欢迎 点赞 ⭐ 收藏 关注 留言你的每一次互动都是我继续更新的动力我们下一篇见我是老炮18 年 Java 老兵仍在一线。关注「Java老炮踩坑录」不错过每一篇真实案例少踩坑。
返回列表