客服工单里的串号:A 用户看到了 B 用户的手机号
7 月初的一个下午,客服转过来一张截图:用户 A 在个人中心看到的手机号,是另一个用户的。我第一反应是不信,把那串号码脱敏后在库里一查,确实属于用户 B,两个人八竿子打不着。
我们那个接口长这样,用户信息是从一个 ThreadLocal 里取的,网关在过滤器里塞进去:
public class UserContext {
private static final ThreadLocal<UserInfo> HOLDER = new ThreadLocal<>();
public static void set(UserInfo user) {
HOLDER.set(user);
}
public static UserInfo get() {
return HOLDER.get();
}
}
// 过滤器
public class AuthFilter implements Filter {
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) {
UserInfo user = parseToken(((HttpServletRequest) req).getHeader("Authorization"));
UserContext.set(user);
chain.doFilter(req, resp);
}
}
单看这段代码,怎么想都不该串。线程 A 塞进去的用户,线程 B 凭什么读到?
先怀疑自己:ThreadLocalMap 到底存在哪
我翻了 JDK 8 的源码,ThreadLocal.set() 其实没往 ThreadLocal 对象里存东西,而是拿到了当前线程,往线程自己的 map 里塞:
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t); // 就是 t.threadLocals
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
也就是说数据挂在 Thread 实例上,key 是 ThreadLocal 对象自己。那串号只有一种可能:同一个线程,上一次请求的用户没被清掉。
问题就在这——Tomcat 的 http-nio-8080-exec-* 线程是线程池复用的。过滤器里只 set 不 remove,请求处理完线程回到池子里,threadLocals 里的 UserInfo 还挂着。下一个请求如果走的分支没重新 set(我们有个内部健康检查的 URI 被过滤器放过了),UserContext.get() 拿到的就是上一个用户。
我加了一行日志打印线程名和 userId,压了 200 个请求,日志里明明白白出现了同一个 exec-3 线程连续服务两个不同用户的情况。串号复现了。
顺带搞清楚那个"弱引用泄漏"
查资料的时候,几乎所有文章都在讲 ThreadLocal 内存泄漏,说 key 是弱引用会被 GC 掉,value 却还在,形成 null -> value 的强引用链。我盯着源码看了半天才理顺:
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // key 是弱引用
value = v; // value 是强引用
}
}
}
画成引用关系就是这样:
Thread (强) -> ThreadLocalMap (强) -> Entry (强)
|
key: WeakReference -> ThreadLocal 对象
value: 强引用 -> UserInfo 对象
如果 ThreadLocal 这个变量本身是静态的(像我们的 HOLDER),key 永远不会被回收,泄漏的前提都不成立。真正会泄漏的是这种写法:ThreadLocal 是个实例变量,对象被回收后 key 变 null,但线程还活着(线程池核心线程基本不死),value 就一直挂在 Entry 上,等到下次 get/set 时清理不掉就积少成多。
我们这次的锅严格说不是"内存泄漏",而是线程池复用导致的脏数据。但根因是同一个:用了 ThreadLocal 却没有配对清理。
修复:remove 放在 finally 里
师傅看完我的排查笔记,说了句"你这个过滤器少了个 finally"。改完是这样:
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
throws IOException, ServletException {
try {
UserInfo user = parseToken(((HttpServletRequest) req).getHeader("Authorization"));
UserContext.set(user);
chain.doFilter(req, resp);
} finally {
UserContext.clear(); // 关键
}
}
public static void clear() {
HOLDER.remove();
}
三个细节:
remove()必须在finally里。放 try 末尾的话,业务代码抛异常就跳过了,而异常恰恰是最容易复现串号的场景。- 用
remove()而不是set(null)。set(null)只是把 value 置空,Entry 还在,key 还在;remove()会把整个 Entry 从表里删掉。 - 所有放过过滤器的 URI 也要清理。我把健康检查路径直接挪到过滤器白名单之外单独配置了,避免半清理状态。
上线前我在测试环境用 JMeter 跑了 5000 次请求,100 并发,日志里加了校验:每次请求结束打印 Thread.currentThread().getName() + " -> " + userId,然后写了个脚本比对同一个线程名相邻两次请求的 userId 是否重复。改之前有 37 处不一致,改之后为 0。
还有一个坑:线程池里的异步任务
排查过程中发现另一个地方也在用 ThreadLocal,是给下游 Dubbo 调用传 traceId 的。这里有个更隐蔽的问题——如果主线程往线程池提交任务,子线程是读不到父线程的 ThreadLocal 的:
UserContext.set(user);
executor.submit(() -> {
System.out.println(UserContext.get()); // null
});
要传就得用 InheritableThreadLocal,但它在线程池场景下更危险:线程池里的线程是提前创建好的,InheritableThreadLocal 只在线程创建时拷贝一次,之后父线程再改值,子线程看到的还是旧的那份。所以线程池里用 InheritableThreadLocal 等于给自己埋雷,正确做法是任务提交时把值显式传进去。
小结
这次的教训是,ThreadLocal 不是"设置完就不用管"的容器,它更像借来的东西,用完必须还。我给自己定了两条规矩:一是只要写了 set,立刻在同一屏内把 remove 的 finally 补上;二是 ThreadLocal 变量一律声明成 private static final,避免 key 被回收引发真正的内存泄漏。
另外那个串号问题,从客服反馈到定位出来花了大概 6 个小时,其中 5 个小时花在"这不可能啊"上。后来师傅说,遇到觉得不可能的 bug,先假设你用的那个东西你其实没搞懂——这话在 ThreadLocal 上应验了。