ThreadLocal + 异步线程导致用户数据串号:一次跨请求数据泄漏的完整复盘

老炮踩坑录 · 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 → 线程池化了 → 串数据
  • 有人加了自定义 TaskExecutor Bean → 线程池化了 → 串数据
  • 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老炮踩坑录」,不错过每一篇真实案例,少踩坑。

相关推荐
前端冒菜师1 小时前
我为什么做了 Iris,又为什么停下了它
后端·ai编程
子一!!1 小时前
集成Spring家族的Spring论坛实战==一阶段
java·后端·spring
Ticnix1 小时前
你的 Agent 聊到第 20 轮就"失忆"?你管理的是历史,高手管理的是上下文
后端·python·agent
不合格的程序员1 小时前
Agent Memory架构设计与实现
后端·ai编程
用户93816912553601 小时前
分页查询 Out of sort memory 问题
后端
xyLJ1 小时前
为什么 Spring Boot 自动配置了 Redis,还要自己写 RedisTemplate?
后端
xixiaoyunya2 小时前
Spring Boot 3 统一响应与全局异常处理实战
java·spring boot·后端
JavaGuide2 小时前
DeepSeek Harness 官方桌面端终于有了!
前端·后端
SFLYQ2 小时前
隔离内网下 AI Agent 工程实战
后端·agent·ai编程