老炮踩坑录 · F05 · 翻车现场系列
基于「企业融合评估平台」真实源码,复盘一个静态 ThreadLocal 引发的跨请求数据泄漏
关键词:ThreadLocal 串数据 · @Async 线程池 · Session 泄漏 · remove 没调
👋 欢迎阅读

🏠个人主页: 知守观
📘我的专栏: 老炮踩坑录
💻当前内容:ThreadLocal
引子
离职几个月后,有一天晚上,前同事突然给我发微信:"老哥,又出大事了!"
他说客服群 里今天炸了锅------有个用户登录进去,看到的居然是另一家公司的企业信息。
我的第一反应是:缓存没清干净。
第二反应是:不可能啊,Session 是按用户隔离的,每个请求拿自己的 Session,怎么会串?
打开代码,看到这一行:
java
public static final ThreadLocal<Map<String, Object>> threadLocal = new ThreadLocal<>();
一个 static 的 ThreadLocal,存的是用户的 Session 数据。
再往下看------全项目没有一处调用过 threadLocal.remove()。
一个都没有。
那一刻我就知道问题出在哪了。
案发现场:一段"看起来没问题"的代码
这个项目的登录流程有个特殊设计:外部系统的登录回调是异步处理的。
异步线程里需要用到当前请求的 HTTP Session------但问题来了:异步线程跑在另一个线程上,RequestContextHolder 拿不到当前请求的 HttpServletRequest,也就拿不到 Session。
怎么办?开发者想了一个办法:用 ThreadLocal 把 Session "搬"过去。
来看完整代码:
java
// AsyncService.java
@Slf4j
@Service
public class AsyncService {
// 1. 静态 ThreadLocal,存用户 Session
public static final ThreadLocal<Map<String, Object>> threadLocal = new ThreadLocal<>();
// 2.异步登录方法
@Async
public void extLoginInfo(JSONObject userInfo, JSONObject companyUpdateFrom,
String companyId, Map<String, Object> data) {
threadLocal.set(data); // 3. 把 Session 塞进 ThreadLocal
loginService.extLoginInfo(userInfo, companyUpdateFrom, companyId);
}
}
然后在需要 Session 的地方,这样取:
java
// 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 取
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.getRequestAttributes()).getRequest();
session = request.getSession();
}
全项目有 5 个文件 在用同样的方式取 Session------全都是 AsyncService.threadLocal.get()。
问题出在哪?
如果没看出,我先把执行过程画成一张图:
css
时间线 ──────────────────────────────────────────────────►
线程池-线程1:
请求A → threadLocal.set(用户A的Session)
→ 业务处理...
→ 方法结束
→ 没有 remove(),用户A的Session还在线程1的ThreadLocal里
线程池-线程1(被复用):
请求B → 业务代码调用 threadLocal.get()
→ 拿到了用户A的Session ← 数据串了!
用户 B 拿到了用户 A 的 Session。
然后再写个复现测试:
空口无凭的说会串,不如跑一遍。照着真实代码的骨架,三十行代码必现:
java
public class CrossoverProof {
// 和 AsyncService.java:29 一模一样的声明
static final ThreadLocal<Map<String, Object>> threadLocal = new ThreadLocal<Map<String, Object>>();
public static void main(String[] args) throws Exception {
// 模拟"万一被池化了"的场景:核心1、最大1、无界队列,线程必复用
// (项目实际用的 SimpleAsyncTaskExecutor 不池化,但只要有人改了配置,就会变成这样)
// 模拟 Boot 2.1 的 applicationTaskExecutor:池化,核心 1,线程必复用
ThreadPoolExecutor pool = new ThreadPoolExecutor(
1, 1, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
// 企业A的异步登录:set 完不清理(AsyncService.java:47 原样)
Map<String, Object> dataA = new HashMap<>();
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();
}
static Map<String, Object> fakeSession(String owner) {
Map<String, Object> session = new HashMap<>();
session.put("owner", owner);
return session;
}
}
输出:
text
企业A:异步登录处理完毕
企业B的后台任务,拿到session归属:企业A
单线程池保证两个任务在同一条线程上排队,企业 B 的任务读到的 session 属于企业 A。放到 8 线程的 applicationTaskExecutor 上,只
是从必现变成概率性------高峰期两个企业的异步任务凑上同一条线程,就都拿着 A 的 token 和 enterpriseid 去调远程接口了。企业 A 的报告出现在 B 的列表里,B 的附件写进 A 的目录------具体串成什么样,取决于撞上的是五处读取里的哪一处。轻则显示错误的企业信息,重则越权操作------用 A 的身份提交了 B 的数据。
为什么"串了"不是偶然,是必然
这个 bug 有三个致命的设计缺陷,每一个都足以引爆问题。
缺陷一:set 了,但从来没 remove
我搜遍了整个项目:
perl
grep -r "threadLocal.remove" src/
→ 0 matches
零。 一次 remove() 都没有。
ThreadLocal 的设计原则很简单:谁 set,谁 remove。 用完不删,数据就赖在线程上,等下一个线程来"继承"。
如果线程是"一次性"的(用完就销毁),问题不大------数据跟着线程一起死了。
但如果线程是复用的(线程池),问题就来了------上一个请求留下的数据,会被下一个请求读到。
缺陷二:@Async 背后是线程复用
这个项目的启动类加了 @EnableAsync:
java
@EnableAsync
@EnableScheduling
public class JSenterpriseFrameApplication {
public static void main(String[] args) {
SpringApplication.run(JSenterpriseFrameApplication.class, args);
}
}
没有配置自定义线程池。Spring Boot 2.1.0 默认用的是 SimpleAsyncTaskExecutor------不限制线程数,每个任务创建一个新线程,不复用。
看起来没问题?别急。
- 第一,
SimpleAsyncTaskExecutor在高并发下会创建大量线程,本身就是一个隐患。 - 第二,也是更重要的------这个设计是脆弱的。
什么叫脆弱?就是"现在碰巧没出事,但任何一个改动都会让它出事":
- 有人在
application.yml里加了一行spring.task.execution.pool.core-size=8→ 线程池化了 → 串数据 - 有人加了自定义
TaskExecutorBean → 线程池化了 → 串数据 - Spring Boot 升级后默认行为变了 → 线程池化了 → 串数据
你依赖的是"碰巧没有线程池",而不是"代码本身是安全的"。 这不叫设计,叫赌运气。
缺陷三:static ThreadLocal 存请求级数据
java
public static final ThreadLocal<Map<String, Object>> threadLocal = new ThreadLocal<>();
static final------这个 ThreadLocal 是类级别的,所有实例共享同一个。
它存的是什么?是 Map<String, Object>,里面装着当前请求的 HTTP Session。
Session 是请求级的数据,ThreadLocal 是线程级的存储。 把请求级的数据放在线程级的容器里,本身就需要极其小心的管理它的生命周期。
而这个项目里:
- set 的时候不管线程是不是复用的
- get 的时候不检查数据是不是当前请求的
- 用完之后不 remove
三步全错。
这个 bug 为什么难排查
ThreadLocal 串数据有一个特点:不可预测、不可复现。
- 单线程测试?不会串。因为 set 和 get 在同一个线程里。
- 低并发?可能不串。因为线程还没来得及复用。
- 高并发?一定串。但高并发时的错误日志也是乱的,你很难把"用户 A 的数据出现在用户 B 的上下文里"和"ThreadLocal 没清"联系起来。
更坑的是,代码里有一个"兜底逻辑":
java
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 里
java
// 错误:set了不remove
@Async
public void doSomething(Map<String, Object> data) {
threadLocal.set(data);
businessService.process();
// 结束了,threadLocal 里的数据还在
}
// 正确:finally 里 remove
@Async
public void doSomething(Map<String, Object> data) {
threadLocal.set(data);
try {
businessService.process();
} finally {
threadLocal.remove(); // 不管成功失败,一定清理
}
}
finally 不是可选的------它是 ThreadLocal 使用的标配。
铁律二:不要用 ThreadLocal 跨线程传递请求上下文
ThreadLocal 的设计初衷是线程隔离 ------让每个线程有自己的独立副本。它不是用来跨线程传数据的。
如果你需要在异步线程里拿到请求上下文,正确的做法是:
java
// 方案一:参数传递,不用 ThreadLocal
@Async
public void extLoginInfo(JSONObject userInfo, String companyId, HttpSession session) {
// Session 作为参数直接传进来,不依赖 ThreadLocal
loginService.extLoginInfo(userInfo, companyId, session);
}
// 方案二:使用 TaskDecorator(Spring 4.3+)
public class SessionTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
RequestAttributes attributes = 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 必须配自定义线程池
java
// 默认 SimpleAsyncTaskExecutor:线程数不可控
@EnableAsync
// 自定义线程池 + TaskDecorator 自动传递上下文
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.setTaskDecorator(new SessionTaskDecorator()); // 自动传递上下文
executor.initialize();
return executor;
}
}
不配线程池,@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老炮踩坑录」,不错过每一篇真实案例,少踩坑。