客服后台弹出了别人的订单:ThreadLocal 漏 remove 引发的用户串号与 380MB 内存泄漏

📅 2026/8/27 16:11:28
客服后台弹出了别人的订单:ThreadLocal 漏 remove 引发的用户串号与 380MB 内存泄漏
title: 客服后台弹出了别人的订单ThreadLocal 漏 remove 引发的用户串号与 380MB 内存泄漏tags: [Java, ThreadLocal, 内存泄漏, 线程池, 源码解析]一条客诉把问题捅了出来我们的客服工作台有个「快速查单」功能客服输入订单号系统校验这个订单是否属于当前客服所在的服务小组属于才放行。权限上下文用的是很常见的ThreadLocal方案拦截器里从 token 解析出客服身份塞进去service 层直接取。2026 年 5 月一个客服反馈说她点开查单页弹出来的是另一个同事正在处理的订单详情。这在客服系统里是很严重的问题涉及用户隐私数据越权。第一轮排查方向完全错了。我们去查了 Redis 会话、查了前端缓存、查了 Nginx 的 proxy_cache折腾了一整天没结果。转折点是运维提了一句「你们那台机器昨天做了压测吧」对上了。前一天下午我们在预发环境跑了压测Tomcat 线程池被拉满到 200 个线程并长时间复用。而串号只在压测那一小时后出现之后随着线程慢慢空闲又消失了——这是典型的线程复用带出脏数据。出事的代码长什么样public class AgentContextHolder { private static final ThreadLocalAgentContext CTX new ThreadLocal(); public static void set(AgentContext c) { CTX.set(c); } public static AgentContext get() { return CTX.get(); } public static void clear() { CTX.remove(); } } Component public class AgentAuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object h) { String token req.getHeader(X-Agent-Token); if (token null) { resp.setStatus(401); return false; // 直接 return没 set也没走到 after } AgentContext ctx tokenService.parse(token); // 这里可能抛异常 AgentContextHolder.set(ctx); return true; } Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object h, Exception ex) { AgentContextHolder.clear(); // 看起来清了 } }看起来是标准写法afterCompletion里清理了。但有两条路径会跳过清理tokenService.parse(token)抛异常时preHandle抛出去了Spring MVC 的DispatcherServlet只会对已经成功返回 true 的拦截器调用afterCompletion。这个拦截器自己抛的异常它自己的afterCompletion不会被调用。这条路径不会残留因为还没 set但下面这条会。真正的元凶在另一处。有位同事为了做异步导出在 service 里起了子线程并且写了一个「上下文传递」的工具类public void asyncExport(ExportReq req) { AgentContext parent AgentContextHolder.get(); exportPool.submit(() - { AgentContextHolder.set(parent); // 传进子线程 try { doExport(req); } finally { // 这里漏了 clear() } }); }exportPool是一个固定 8 线程的池长期存活。子线程set之后从不remove于是这 8 个线程各自持有一份客服上下文永久残留。而doExport里某个分支会调用一个公共的查单方法那个方法内部取AgentContextHolder.get()做权限校验——拿到的是上一次导出任务留下的客服身份。导出任务是异步的客服 A 触发导出后客服 B 恰好也点了查单如果 B 的请求被路由到某个正在跑导出的线程池线程这里还有一层设计问题导出内部又复用了同一个查单 service而这个 service 是无状态单例就会读到 A 的上下文。ThreadLocalMap 的弱引用到底保护了什么很多文章会说「ThreadLocal 用了弱引用所以不会内存泄漏」这个说法只对了一半。看 JDK 17 的Entrystatic class Entry extends WeakReferenceThreadLocal? { Object value; // 注意value 是强引用 Entry(ThreadLocal? k, Object v) { super(k); // key 是弱引用 value v; } }弱引用挂在keyThreadLocal 对象本身上value是实打实的强引用。引用链是这样的Thread→ThreadLocalMap→Entry→value强而Entry→key是弱引用。所以当ThreadLocal对象没有其他强引用时key 会被 GC 回收变成 nullEntry 变成「stale entry」但value仍然被Entry.value强引用着只要线程还活着就回收不掉。那 JDK 有清理机制吗有但是被动的、探测式的private void set(ThreadLocal? key, Object value) { Entry[] tab table; int len tab.length; int i key.threadLocalHashCode (len-1); for (Entry e tab[i]; e ! null; e tab[i nextIndex(i, len)]) { ThreadLocal? k e.get(); if (k key) { e.value value; return; } if (k null) { // 撞到 stale entry replaceStaleEntry(key, value, i); // 顺手清理一段 return; } } tab[i] new Entry(key, value); int sz size; if (!cleanSomeSlots(i, sz) sz threshold) // 启发式清理 rehash(); }关键点清理只在set、get、remove被调用时碰巧触发而且cleanSomeSlots只扫log2(n)个槽位。如果一个线程 set 完就再也不碰这个 map那些 stale entry 会一直躺着。线程池里长期空闲的线程正是这种情况。expungeStaleEntry是真正干活的那个private int expungeStaleEntry(int staleSlot) { Entry[] tab table; int len tab.length; tab[staleSlot].value null; // 断开 value 强引用这一行是关键 tab[staleSlot] null; size--; // 后面对同一 hash 段做 rehash把因线性探测偏移的 entry 挪回去 Entry e; int i; for (i nextIndex(staleSlot, len); (e tab[i]) ! null; i nextIndex(i, len)) { ThreadLocal? k e.get(); if (k null) { e.value null; tab[i] null; size--; } else { int h k.threadLocalHashCode (len - 1); if (h ! i) { tab[i] null; while (tab[h] ! null) h nextIndex(h, len); tab[h] e; } } } return i; }tab[staleSlot].value null这一行才是解除泄漏的动作。而只有remove()能确定性地走到这里remove→e.clear()→expungeStaleEntry。这就是为什么「必须显式 remove」不是最佳实践建议而是硬性约束。那次泄漏的实际数据我用jmap -histo:live和 MAT 各看了一遍exportPool8 个线程每个的ThreadLocalMap里有 4 个 entry客服上下文、MDC、TransmittableThreadLocal 的一个内部对象、Hibernate 的一个。客服上下文对象本身不大约 400 字节但它持有一个ListServiceGroup而ServiceGroup里又缓存了组内所有客服的简要信息。一个大组有 2000 多人。单个上下文实际保留的对象图大小 47MB8 个线程加上 Tomcat 那边残留的部分合计 381MB。堆是 4G所以没 OOM只是老年代占用长期偏高Full GC 频率从每天 1 次涨到每小时 2 次。顺带发现第二个泄漏本地开发用热部署每次 reload 都会创建新的WebappClassLoader。因为 Tomcat 线程池的线程是复用的它们的ThreadLocalMap里残留着旧 ClassLoader 加载的类的实例导致旧 ClassLoader 无法回收。启动日志里那句The web application appears to have started a thread named ... but has failed to stop it和created a ThreadLocal with key of type ... but failed to remove it就是 Tomcat 的WebappClassLoaderBase.checkThreadLocalsForLeaks()在告警——我们看了两年一直当成噪音。三种上下文传递方案对比方案跨线程池传递清理成本侵入性我的评价裸ThreadLocal 手动 remove不支持每处都要写 finally低只适合单线程链路异步一律出事InheritableThreadLocal只在new Thread时继承线程池无效同上低线程池场景下是个陷阱容易给人错误的安全感TransmittableThreadLocalTTL 2.14.5支持需包装 Runnable 或用 agent由框架在afterExecute回收中需引依赖或挂 agent线程池场景我推荐这个我们最后的选择是 TTL 一条硬性规范。TTL 的核心是在任务提交时抓一次快照执行前replay、执行后restore所以清理是框架保证的不依赖业务代码写 finally。用-javaagent方式接入可以做到业务代码零改动我们试过但线上最终选了显式包装TtlRunnable.get(task)——agent 方式在启动参数被运维脚本覆盖时会静默失效出问题很难查显式包装至少能在代码里看到。规范这块加了三条都写进了 CI 检查// 规范 1所有 ThreadLocal 必须通过统一 Holder 访问禁止在业务类里直接 new ThreadLocal // 规范 2Holder 必须提供 try-with-resources 风格的 scope 方法 public final class AgentContextHolder { private static final TransmittableThreadLocalAgentContext CTX new TransmittableThreadLocal(); public static Scope open(AgentContext c) { CTX.set(c); return CTX::remove; // Scope 是个 AutoCloseable } public static AgentContext get() { AgentContext c CTX.get(); if (c null) { // 规范 3取不到上下文直接抛不允许返回 null 让调用方猜 throw new IllegalStateException(agent context not initialized); } return c; } public interface Scope extends AutoCloseable { Override void close(); // 去掉受检异常调用方不用 catch } }调用方变成try (AgentContextHolder.Scope ignored AgentContextHolder.open(ctx)) { doBusiness(); } // 编译器保证 remove 一定执行这个写法的价值在于把「必须 remove」从人的自觉变成了编译器和语法结构的保证。Scope::close无参数无返回用方法引用CTX::remove一行就实现了。上线后我们在预发跑了同样的压测场景串号复现不了了。规范 3 那条「取不到就抛异常」争议最大有同事担心会把一些原本能跑的边缘路径打挂。我坚持加了理由是权限上下文取到 null 的时候业务代码要么走了「默认放行」越权要么 NPE500。前者是安全事故后者是可见故障而抛明确异常至少能定位问题。上线后确实打出来 3 个之前没人注意到的调用链——都是定时任务直接调 service 层、根本没有客服身份的场景正好该修。复盘数字隐私越权持续时间约 1 小时 40 分压测窗口 线程慢慢回收的尾巴实际触发 3 次其中 1 次被客服上报。定位耗时 1 天半最大的弯路是一直往「缓存」方向查没想到线程复用。泄漏内存 381MBFull GC 频率从每天 1 次到每小时 2 次改完之后回到每天 1 次以内。顺带清掉了 Tomcat 启动日志里 6 条 ThreadLocal 泄漏告警其中 2 条是第三方 SDK 的提了 issue 后对方在 3 周内修了。我的几个判断ThreadLocal的问题从来不是内存泄漏而是数据串号。内存泄漏最多让你多加点内存、多做几次 Full GC串号是数据正确性和安全问题一旦涉及权限或金额就是事故。所以我评审代码时看到ThreadLocal第一个问题永远是「谁负责 remove」第二个是「会不会跨线程」。InheritableThreadLocal我不建议在任何有线程池的项目里用。它只在Thread构造时复制父线程的 map线程池的线程是提前创建、反复复用的绝大多数情况下继承到的是「创建线程池那一刻」的上下文比拿不到更危险——因为它有时候能拿到值让人误以为方案是对的。不要为了「省一次数据库查询」把大对象塞进 ThreadLocal。我们那个上下文里带 2000 人的组信息本来是为了避免每次查权限都打 DB。这属于用内存和风险换性能而实际收益是省了一次 3ms 的 Redis 查询。改造后我们只在上下文里存客服 ID 和组 ID 两个 long其余按需查带本地缓存内存占用从 47MB 降到不到 1KB。Tomcat 那句 ThreadLocal 泄漏告警不该被忽略。它是WebappClassLoaderBase在 stop 时主动扫描每个线程的ThreadLocalMap得出的误报率很低。我们把它加进了发布后的日志检查项出现即拦截发布。留个问题如果一个ThreadLocal被声明为static final大多数写法都是这样那么它作为 key 的弱引用其实永远不会被回收——因为类的静态字段持有它的强引用。在这种情况下ThreadLocalMap里的 entry 永远不是 stale entry那么 JDK 的探测式清理机制对它完全无效。这是不是意味着「弱引用设计」在最常见的用法下根本没起作用你怎么看这个设计欢迎在评论区讨论。