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

4 阅读9分钟

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

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

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

👋 欢迎阅读

f05fm.jpg

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

引子

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

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

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

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

打开代码,看到这一行:

public static final ThreadLocal<Map<String, Object>> threadLocal = new ThreadLocal<>();

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

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

一个都没有。

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

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

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

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

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

来看完整代码:

// 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 的地方,这样取:

// 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()。

问题出在哪?

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

时间线 ──────────────────────────────────────────────────►

线程池-线程1:
  请求A → threadLocal.set(用户A的Session)
         → 业务处理...
         → 方法结束
         → 没有 remove(),用户A的Session还在线程1的ThreadLocal里

线程池-线程1(被复用):
  请求B → 业务代码调用 threadLocal.get()
       → 拿到了用户A的Session  ← 数据串了!

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

然后再写个复现测试:

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

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;
    }
}

输出:

企业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
@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 存请求级数据

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 没清"联系起来。

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

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
@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 的设计初衷是线程隔离——让每个线程有自己的独立副本。它不是用来跨线程传数据的。

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

// 方案一:参数传递,不用 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 必须配自定义线程池

// 默认 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老炮踩坑录」,不错过每一篇真实案例,少踩坑。