先讲一件我经历过的线上事故。某天下午运维转了个工单过来,说用户在 App 里看到了别人的收货地址。第一反应是查缓存,查 Redis Key、Cookie、网关日志,都没发现明显异常。最后排查到日志里有一条规律:这个问题只在某个偶发接口出现,而且复现时线程名完全一致。那一刻我心里咯噔一下——ThreadLocal 没清理,Tomcat 工作线程把上一位用户的 userId 残留在线程变量里,下一位用户复用同一条线程时,就读到了别人的上下文。这就是俗称的“串号”。
先给结论:会,而且在多线程和分布式场景里,ThreadLocal 不清理导致的串号从来不是小概率事件,而是一场概率性数据事故,排查过程极其恶心。但这道面试题真正的深度,不在于你背会了“要用 try/finally + remove”,而在于面试官还会顺着这条线往下追问分布式环境里的上下文传递、线程池污染、全局互斥等问题。这篇就把这条线彻底讲透。
1. 先正面回答:ThreadLocal 不清理,串号是怎样发生的
1.1 一个能单测复现串号的 Demo
不要觉得串号只存在于“高并发压测”中,它用一段很小的代码就能复现。核心就一句话:ThreadLocal 变量绑定的是“当前线程”,而线程池里的线程是复用的。
java复制public class ThreadLocalSerialNumberDemo {
static final ThreadLocal<String> CURRENT_USER = new ThreadLocal<>();
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.submit(() -> {
CURRENT_USER.set("用户A-1001");
System.out.println("第一次请求,处理:" + CURRENT_USER.get());
// 业务执行完了,但没有清理 CURRENT_USER
});
Thread.sleep(100);
executor.submit(() -> {
// 第二次请求中,代码里并没有 set 用户B
System.out.println("第二次请求,读到的是:" + CURRENT_USER.get());
});
executor.shutdown();
}
}
输出结果会是第一行“用户A-1001”,第二行也读到“用户A-1001”。明明第二次请求处理的是另一个用户,业务代码却拿到了上一位用户的身份,这就是“串号”的全过程。
很多人在写单机 Demo 时,喜欢用 new Thread() 起线程跑,那种写法线程结束即销毁,根本不触发复用,所以长时间发现不了问题。换成线程池后,线程会被反复用于执行不同任务,串号才会浮出水面。
1.2 串号成立需要同时满足的三个条件
从我排查过的案例来看,线上出现串号通常不是单一原因,而是下面三个条件同时满足:
- 线程被复用。容器自带的线程池、业务
