先讲一件我经历过的线上事故。某天下午运维转了个工单过来,说用户在 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 串号成立需要同时满足的三个条件
从我排查过的案例来看,线上出现串号通常不是单一原因,而是下面三个条件同时满足:
- 线程被复用。容器自带的线程池、业务自定义线程池、MQ 消费线程池,都可以让同一条线程执行不同用户的任务。
- 某个请求或任务把值写进了 ThreadLocal,却在生命周期结束时没有执行 remove()。这是最常见的违规点。
- 下一个任务没有先 set 自己的值,或者执行路径中存在“读后写”的顺序,导致残留值先被读走。
第三个条件很关键。很多人觉得“我每个方法入口都会 set 一遍啊”,但真正的业务链路往往不是每个入口都统一 set。比如只有登录拦截器会 set,而某些白名单接口、回调接口不经过拦截器;或者一个任务分成多段执行,第一段设了值,第二段代码只 get 不 set。这时候只要上一次任务没清理,当前任务读到的一定是脏数据。
1.3 清理的规范姿势:try/finally + remove,别赌概率
正确清理方式没有那么多花活,就是出入口成对:
java复制try {
UserContext.set(userId);
// 业务逻辑,无论中间抛不抛异常
} finally {
UserContext.clear(); // clear 内部调用 ThreadLocal.remove()
}
这里有两个实操层面的细节容易被忽略:
- 用
remove(),不要用set(null)。set(null)确实能让下一次 get 返回 null,但当前线程的 ThreadLocalMap 里仍保留着 Entry 结构,value 虽然被置空了,可这个 Entry 在极端情况下仍然可能造成内存占用问题。remove()会把这个 key 对应的 Entry 整个删掉,更干净。 - 不要在 Filter 或 AOP 切面里随便打了个 try/finally 就觉得万事大吉,还要确认“业务里有没有私自 new 线程、往别的线程池丢任务”。一旦任务被丢到另一个线程池里,外层 Filter 的 finally 只能清理 Tomcat 线程,清理不到子线程里的 ThreadLocal。
这套规范在单机请求模型里已经够用。但真正的难点在于,你处理的不只是“一条 Tomcat 线程上的同步请求”,而是一条会跨线程、跨服务、跨消息队列的分布式调用链。到了这一步,ThreadLocal 的问题会复杂好几倍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分布式环境把串号问题放大成三倍的底层原因
2.1 原因一:线程池不止一个,每个池子都在复用线程
很多人理解了“Tomcat 线程池复用会导致串号”后,以为把入口 Filter 清理干净就结束了。但其实一次请求处理过程中,可能会经历:Tomcat 工作线程 -> 业务异步线程池 -> RPC 客户端线程 -> MQ 消费线程池。ThreadLocal 原本只在当前线程内有效,一旦发生线程切换,子线程里根本拿不到父线程的值。
典型的翻车现场是 CompletableFuture.runAsync():
java复制CompletableFuture.runAsync(() -> {
// 这里 UserContext.get() 是 null
// 因为 async 线程池里的线程和当前请求线程完全不同
});
值丢了还不是最可怕的。更可怕的是,如果线程池里的线程之前处理过某个任务,而那个任务没有清理自己的 ThreadLocal,那么这个“别人的值”会残留在池子里。下一任务不管是谁,只要没重新 set 就会中招。分布式环境下线程池数量多、任务来源杂,这个问题会被成倍放大。
2.2 原因二:ThreadLocal 活不过一次 RPC,上下文是靠链路协议传的
ThreadLocal 本身只是 JVM 层面当前线程的局部变量,它没有一个“分布式通知”的通道。跨服务调用时,上游进程的 ThreadLocal 不可能自动跑到下游进程的 ThreadLocal 里。
分布式链路里的用户身份、租户ID、链路 traceId,全部要依赖 RPC 协议的附加字段或 HTTP Header 显式传递。比如 Feign 要加 RequestInterceptor,Dubbo 要用 RpcContext 的 attachment。如果你只在服务内部做了 ThreadLocal 存取,却忘了在调用下游时把它写入 Header,那么下游服务拿不到用户上下文,会出现两类问题:
- 下游代码用 null 兜底,把请求当成了默认账户或系统账户,用户数据被错误关联。
- 下游做了本地缓存/本地登录态,但每个节点只认自己内存里的状态,负载均衡切节点后表现成“用户身份跳来跳去”。
这两种都是分布式场景里“串号”的变种,只是不再表现为 ThreadLocal 残留,而表现为上下文断链。
2.3 原因三:异步链路里上下文要么丢失,要么残留
同步 RPC 链路中,Header 和 ThreadLocal 的配合还能做到“请求进、请求出”。一旦中间隔了一个 MQ 消息,或者一个分布式定时任务,原来的请求上下文马上失效。
拿 RocketMQ 消费线程池举例:同一个消费线程会处理不同用户的消息,如果每条消息的消费者代码没有在入口统一 set/清理,只靠消息体里的业务字段做路由,非常容易把上一条消息的上下文串到下一条。分布式任务调度平台(比如 XXL-Job)的分片广播也会遇到同样的问题:多个分片线程并发处理任务,任务里如果存放了租户维度信息又没清理,就可能 A 租户的任务把数据写进 B 租户的空间。
说起来这些场景都指向同一个本质:分布式环境下,上下文要么传不过去,要么传过去后没人回收。这两种死法并不互斥,很多系统是同时踩了。接下来我详细说三个我见过的高危大坑,每个都能让系统在特定条件下出大事故。
3. 分布式大坑一:用户上下文留在本地,网关一切流就“身份串号”
3.1 事故复盘:本地 Session 和本地 UserContext 是最早的“分布式串号”源头
很多早期从单机转型分布式的团队,第一个踩的坑就是把用户登录态放在单机内存里。单体架构下一台 Tomcat 自己存 HttpSession,用户每次请求都落到同一台机器上,一切正常。上了负载均衡之后,用户请求被分发到不同节点:
- 第一次请求落到节点 1,节点 1 生成了 Session 并写入自身内存;
- 第二次请求被路由到节点 2,节点 2 内存里没有该 Session,判断用户未登录,于是重新跳登录页;
- 如果后端代码在解析用户身份时用了各节点本地缓存的 UserContext,而两个用户恰好被路由到同一节点,不同请求间的本地上下文就可能互相干扰,表现成“我看到的是别人账号的内容”。
这就是一种容易被误诊的串号。它的表象和 ThreadLocal 不清理很像,但根因已经不再是“单条线程里的变量残留”,而是“会话状态没有设计成可跨节点访问”。排查时如果只盯着 Filter、线程池,永远查不出问题,因为问题出在会话存储模型上。
3.2 修复方案:集中会话 / 无状态 Token + 服务间上下文显式透传
要解决这个坑,核心思路不是继续在每个节点内存里塞会话,而是让服务节点无状态化。
- 会话态放进 Redis:Session 数据集中存储或直接用 JWT/Token,服务节点不保存用户会话。
- 在网关或统一入口解析完 Token 后,把 userId、tenantId 等关键身份信息写入请求 Header 向下游传递。
- 下游服务通过统一的 UserContextFilter 把 Header 中的用户信息解析到当前线程的 ThreadLocal 中,在请求结束时清理,确保业务代码在读用户上下文时总是有值且只对本请求有效。
过滤器参考实现:
java复制@Component
public class UserContextFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String userId = httpRequest.getHeader("X-User-Id");
try {
if (userId != null && !userId.isEmpty()) {
UserContext.set(userId);
}
chain.doFilter(request, response);
} finally {
UserContext.clear();
}
}
}
如果服务间用 Feign 调用,必须有 RequestInterceptor 把当前 UserContext 里的值继续传递下去:
java复制@Bean
public RequestInterceptor userContextRequestInterceptor() {
return template -> {
String userId = UserContext.getUserId();
if (userId != null && !userId.isEmpty()) {
template.header("X-User-Id", userId);
}
};
}
这套方案的关键不是代码多复杂,而是约定统一。所有服务都遵守“入口解析 Header 写入 ThreadLocal、出站 RPC 再写回 Header”的接力规则,用户身份才不会在链路中间“断线”或“串线”。
4. 分布式大坑二:多线程任务消费时租户上下文串位,比想象中更隐蔽
4.1 多租户系统里的串号往往不表现为“用户看错页面”,而是“数据写错地方”
单机请求的串号通常容易复现,因为在同一个线程上有残留值。多租户系统的串号则隐蔽得多,经常发生在批处理任务、消息补偿任务、定时对账任务中。我用一个简化例子说明:
java复制void processMessage(Message msg) {
// 从消息体里拿到当前租户
TenantContext.set(msg.getTenantId());
// 数据源路由、获取用户列表、写业务表等
orderService.handle(msg.getOrderNo());
// 忘记 TenantContext.clear()
}
如果消息消费线程的下一条消息来自另一个租户,而这条消息的代码没有重新执行 TenantContext.set,就可能拿着上一家的租户 ID 去做数据源路由或行级隔离,最终把 B 租户的数据写进 A 租户的库里。
更可怕的是,这种问题不一定报错。很多数据源路由框架在找不到租户时,会兜底走默认数据源。于是你以为写到了“当前租户”,实际写进了默认库,日志和监控还看不出来异常。只有等到对账时才发现数据少了一批,那个排查过程相当考验心态。
4.2 根治思路:进入异步池子前做快照,运行完做清理,并且要由框架统一收口
ThreadLocal 的传递不能靠业务代码人肉 set。正确做法是在提交任务时捕获父线程的上下文快照,到了子线程先回放,任务结束再清理。这个思路有两套落地方式:
第一套,如果你希望尽量少改业务代码,可以用阿里开源的 TransmittableThreadLocal(TTL)。它解决的最大痛点是:父线程的上下文在提交任务给线程池时能自动传递给子线程,并且子线程在执行完任务后会恢复成原来的状态,不会把回放进去的值污染到下一次任务。
java复制// 初始化一个可传递的 ThreadLocal
TransmittableThreadLocal<String> context = new TransmittableThreadLocal<>();
context.set("user-1001");
ExecutorService executor = TtlExecutors.getTtlExecutorService(
new ThreadPoolExecutor(4, 8, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>()));
executor.submit(() -> {
// 在子线程里能拿到 user-1001,执行完后 TTL 会自动恢复
String userId = context.get();
});
但要注意,TTL 并不能自动解决所有问题。如果你绕过了 TtlExecutors,直接用裸的 ThreadPoolExecutor 提交 Runnable,或者项目里还有人用裸 new Thread(),那该串还是会串。这也是我为什么强调:线程池必须统一收口,禁止业务团队自己创建。
第二套,自研异步任务包装器。如果团队不想引入额外依赖,也可以在自定义 Runnable 里做快照传递与清理:
java复制public class ContextTask implements Runnable {
private final Runnable task;
private final String userId;
public ContextTask(Runnable task, String userId) {
this.task = task;
this.userId = userId;
}
@Override
