1. 数据串台从一次诡异的线上事故说起前两年做电商中台的时候有段时间线上频繁出现用户打开购物车看到的是别人商品的事故。当时排查日志发现一个特别诡异的规律接口的入参userId明明是对的但业务代码里从ThreadLocal取出来的用户上下文却是另一个人的。更要命的是这个错误用户ID和真实用户ID之间有某种对应关系——同一个线程池的线程编号总能看到固定的几个userId在来回串。这就是典型的数据串台。在高并发场景下线程池复用线程上一个任务在ThreadLocal里留下的数据没有在下个任务开始前清掉于是下一个任务一读到ThreadLocal拿到的就是脏数据。串台问题在Java后端项目里非常常见尤其当你用ThreadLocal传递登录用户、traceId、租户信息、语言环境这类上下文数据时。你可能遇到过明明每个请求是独立的结果日志里的traceId混了或者A租户的数据被带到了B租户的SQL里。这类问题排查起来极其隐蔽因为不是每次都必现只在线程池恰好复用到了同一条线程的时候冒出来。本文要聊的TransmittableThreadLocal以下简称TTL就是专门解决这个问题的方案。它不是简单的换一个ThreadLocal实现而是把数据的传递时机、作用域边界都重新设计了一遍。我会从原理讲到实战再把几个坑一并讲透方便你在项目里直接落地。2. ThreadLocal 的“线程私有”和线程池的“线程复用”天生冲突2.1 ThreadLocal 为什么会串台先回顾一下ThreadLocal的基本模型。ThreadLocal不是把数据存在某个全局Map里的它只是存了一个key真正的值存放在当前线程对象内部的ThreadLocalMap里。每个线程都有一份独立的Map所以不同线程之间天然隔离互不干扰。这个设计在高并发下没问题但遇到线程池就麻烦了。线程池的线程是复用的比如一个核心线程数为10的线程池这10条线程会在整个生命周期里反复执行不同任务。任务A在某条线程里往ThreadLocal塞了个userId任务A跑完线程并没有销毁ThreadLocalMap里的userId也还在。接着任务B被分配到同一条线程任务B没有主动去set于是拿到的还是任务A留下的userId。代码长这样public class ThreadLocalLeakDemo { private static final ThreadLocalString CURRENT_USER new ThreadLocal(); public static void main(String[] args) throws InterruptedException { ExecutorService pool Executors.newFixedThreadPool(1); // 任务A设置用户A pool.submit(() - { CURRENT_USER.set(userA); sleep(100); System.out.println(taskA user CURRENT_USER.get()); // 注意没有remove }); // 任务B什么都没设置却可能拿到userA pool.submit(() - { System.out.println(taskB user CURRENT_USER.get()); }); pool.shutdown(); } private static void sleep(long millis) { try { Thread.sleep(millis); } catch (InterruptedException ignored) {} } }运行结果通常是这样taskA useruserA taskB useruserA任务B明明没有设置用户却拿到了任务A的值。这就是串台。2.2 InheritableThreadLocal 为什么也解决不了有人会说Java不是提供了InheritableThreadLocal吗子线程会自动继承父线程的值。确实InheritableThreadLocal在创建新线程时会从创建者线程拷贝一份值到新线程。但线程池的场景不适用因为线程池里的线程不是新创建的它们在线程池初始化时就已经建好了后续任务来了只是复用这些线程并不会触发新线程创建时的值继承逻辑。所以用InheritableThreadLocal同样会串台甚至更隐蔽。我见过不少项目先用了InheritableThreadLocal上线后串台问题依然复现排查了大半天才发现是线程池不触发继承机制。2.3 线程池本身没错错的是生命周期没有被管理这里要厘清一个关键认知线程池的线程复用不是问题ThreadLocal值的作用域没有被正确管理才是问题。你在一个任务里set了值就应该在任务结束前负责清掉或者在提交任务时把这些值和任务捆绑在一起。很多人靠条件反射式地在finally里手动remove()来规避串台但在业务复杂、提交点分散的代码里漏写一处就前功尽弃。TTL的思路不是要求每个开发者在每个角落都记得清理而是把捕获-回放-恢复这套动作规范到框架层去自动完成。3. TTL 的设计精髓捕获、回放、恢复3.1 从“线程创建时传递”到“任务提交时传递”TransmittableThreadLocal的核心思想是把数据传递的时机从线程创建改成了任务提交。普通ThreadLocal的数据隔离靠的是每个线程一份Map传递靠的是InheritableThreadLocal在线程创建时复制。TTL则换了一种方式当你向线程池提交一个任务时TTL会先捕获当前线程里所有TTL变量的快照把这个快照和任务绑定在一起任务真正执行前把快照里的值回放到执行线程上任务执行完后再把执行线程上的值恢复成任务开始前的样子。这个过程叫capture / replay / restore翻译过来就是捕获、回放、恢复。你可以在脑海里想象一个简易的数据漂流瓶提交时写纸条塞进瓶子执行时打开瓶子取纸条执行完把瓶子复原。3.2 源码流程拆解TTL内部的核心方法是TtlRunnable的自定义run()逻辑。它大致做了三件事public void run() { // 1. 捕获拿到提交时线程上所有TTL变量的值快照 MapTransmittableThreadLocal?, Object captured TransmittableThreadLocal.Transmitter.capture(); // 2. 回放把快照里的值写入当前执行线程 Object backup TransmittableThreadLocal.Transmitter.replay(captured); try { // 3. 执行真正的业务逻辑 actualRunnable.run(); } finally { // 4. 恢复把执行线程恢复成执行前的状态避免串台 TransmittableThreadLocal.Transmitter.restore(backup); } }这个过程在每次任务执行时都会发生。它不依赖线程创建只依赖任务提交因此完美契合线程池复用线程的场景。这里有个容易被忽略的点TTL并不是复制整个ThreadLocalMap它只处理被标识为TransmittableThreadLocal类型的变量。使用的时候要把ThreadLocal替换成TransmittableThreadLocal普通ThreadLocal不在它的管辖范围内。这一点一定要记住否则你换了TTL却发现普通ThreadLocal还是串台还以为方案没效。3.3 为什么 TTL 不会影响性能很多人刚接触TTL时会担心每次提交任务都做一次快照捕获会不会有性能问题实际测试下来这种担心在绝大多数业务场景下是多余的。TTL的capture操作本质上只是遍历了已注册的TTL变量集合并从当前线程的ThreadLocalMap里取一下值不涉及深拷贝也不涉及序列化。一个系统里通常只有几个TTL变量比如traceId、userId、租户ID这个开销是纳秒到微秒级的。真正要注意的性能风险点在于如果你往TTL里塞了非常大、且可变的对象那虽然TTL本身不拷贝但多个线程共享同一个可变对象会带来并发安全问题和额外的GC压力。后面我会单独讲这个坑。4. 实战落地从裸 ThreadLocal 改造到 TTL4.1 第一步替换 ThreadLocal 为 TransmittableThreadLocal改造的第一步很简单把代码里的ThreadLocal换成TransmittableThreadLocalimport com.alibaba.ttl.TransmittableThreadLocal; public class UserContext { private static final TransmittableThreadLocalString CURRENT_USER new TransmittableThreadLocal(); public static void set(String userId) { CURRENT_USER.set(userId); } public static String get() { return CURRENT_USER.get(); } public static void clear() { CURRENT_USER.remove(); } }这一步替换之后普通的线程池提交场景还不会立刻生效。因为TTL需要包装任务或包装线程池告诉它哪些任务是需要在执行前做回放动作的。4.2 第二步用 TtlRunnable 包装任务最直接的方式是在提交任务时用TtlRunnable.get()包装一下ExecutorService pool Executors.newFixedThreadPool(4); UserContext.set(userA); pool.submit(TtlRunnable.get(() - { // 这里能拿到userA System.out.println(UserContext.get()); }));TtlRunnable.get()做的事就是上面讲到的三步封装。如果你提交的是Callable对应的是TtlCallable.get()。这种方式的优点是精确、可控缺点是每个提交点都要记得包一层。如果团队里提交点很分散总有人会漏。4.3 第三步用 TtlExecutors 包装线程池更推荐的做法是用TtlExecutors把线程池整体包装起来。这样一来所有通过这个包装后的线程池提交的任务都会自动完成捕获、回放、恢复不需要在提交点逐个修改ExecutorService pool Executors.newFixedThreadPool(4); ExecutorService ttlPool TtlExecutors.getTtlExecutorService(pool); // 后面提交任务时不需要再手动包装Runnable ttlPool.submit(() - { // UserContext.get() 能拿到提交时的值 });同理ScheduledExecutorService对应TtlExecutors.getTtlScheduledExecutorService()。我个人的建议是在项目里做一个统一的线程池工厂所有线程池都由这个工厂创建工厂内部自动返回TtlExecutors包装后的实例。这样从源头保证所有提交路径都被覆盖不需要靠自觉。4.4 补充Java Agent 方式TTL还提供了一个Java Agent方式可以做到更彻底的透明连线程池包装都不用在代码里显式操作。具体是把transmittable-thread-local的jar包作为-javaagent参数加进JVM启动命令agent会在类加载时自动改写线程池的提交逻辑。这个方式适合那种存量代码特别大、没法统一改提交点的老项目。但它对JVM启动参数有侵入性且要求对Java Agent的原理有基本认知否则排查问题时容易一头雾水。我建议中小型团队优先用TtlExecutors统一包装线程池Agent方案作为备选。5. 几个真实场景里的实战案例与踩坑记录5.1 案例MQ消费线程池里的 traceId 传递中间件团队经常遇到一个问题RocketMQ的消费者内部有自己的线程池在consumeMessage里打印的日志traceId总是丢失。原因是消费者在收到消息、构建上下文之后异步线程池去执行业务逻辑时线程发生了切换。改造前public class OrderMessageListener implements MessageListener { Override public void consumeMessage(ListMessageExt msgs, ConsumeConcurrentlyContext context) { String traceId msgs.get(0).getProperty(traceId); TraceContext.set(traceId); // 普通ThreadLocal // 内部把任务提交到异步线程池执行 bizExecutor.submit(() - { // 这里拿不到traceId doBiz(); }); } }改造后只要把TraceContext的内部实现改成TransmittableThreadLocal同时把bizExecutor换成TtlExecutors包装traceId就能自动穿过线程池传递到任务执行线程里。这个场景是我在实际项目里感知最明显的——日志链路直接完整了排查问题效率高了很多。5.2 案例并发拆分任务并汇总时的上下文传递有个数据导出的场景主线程接收导出请求把数据按分页拆成多个任务提交到线程池每个子任务都需要读取当前操作人ID和租户ID。之前也是用ThreadLocal任务一多必现串台导出文件里的数据竟然混进了别的租户的数据。用TTL改造后主线程设置好上下文所有子任务无论被分配到哪条线程都拿到同一份操作人ID和租户ID。这背后其实就是capture的快照语义在提交任务的那个时刻捕获一次上下文值。线程池里的线程是复用的但每个任务携带的快照是独立的互不覆盖。5.3 坑TTL 默认不会回传子线程修改的值有一种容易误解的场景子线程里修改了TTL变量然后主线程想读取修改后的值。TTL的设计是单向传递的只保证父线程的值能传到子线程不保证子线程修改后能自动传回父线程。如果你在主线程里想拿子线程修改后的上下文需要显式地通过Future.get()返回值、共享对象、或者CompletableFuture等方式做回传。我见过有同学子线程里set了一个值返回到主线程后直接get结果拿到的是旧值误以为是TTL失效。记住TTL只管任务提交时的快照回放不要把它当成线程间共享的Map。5.4 坑可变对象传的不是副本是引用TTL的capture和replay对value的处理是引用传递不是深拷贝。如果value本身是可变对象那么多个线程操作的是同一个对象实例一样会产生并发问题。举个例子public class TraceContext { private static final TransmittableThreadLocalMapString, String CONTEXT new TransmittableThreadLocal(); }如果多个异步任务拿到同一个HashMap实例任务A往Map里put了一个键任务B读取时可能就会受影响。这不是TTL自己的bug而是使用方没有处理好可变性。最佳实践是TTL里只放不可变对象或者每次set的时候放入一个新构建的不可变快照。比如用Collections.unmodifiableMap()包一层或者直接放String、Long、Integer这类不可变类型。如果一定要放复杂对象就保证这个对象内部也没有可变状态或者业务上明确它只会被只读使用。6. 避坑清单与最佳实践6.1 不要依赖“自动清理”入口和出口都要规范TTL虽然负责了任务执行后的restore但主线程本身的值会在什么时候被清掉还是要由业务代码控制。如果你在一个请求入口set了上下文请求处理完没有remove这条线程回到线程池后下一次提交任务时capture照常会捕获到这个旧值并传给下一个任务。这其实没有串台但会造成上下文泄漏到不该传递的场景。所以规范是在请求入口set上下文在请求出口finally块remove上下文提交给线程池的任务尽量不依赖主线程后续的地毯式清理6.2 一个上下文对象优于一堆TTL实例我见过一个项目里定义了十几个TransmittableThreadLocal分别存userId、userName、deptId、权限点等。这虽然功能上没问题但每次任务提交时TTL遍历注册的变量就多一份开销更重要的是跨线程传递的语义不清晰维护成本高。推荐的做法是定义一个上下文对象用一个TTL变量持有public class BizContext { private final String userId; private final String tenantId; private final String traceId; // getter、构造函数 } public class ContextHolder { private static final TransmittableThreadLocalBizContext CTX new TransmittableThreadLocal(); }这样整体逻辑更清晰capture的开销也最小。6.3 验证方案时写并发测试别靠肉眼观察TTL替换完之后一定要写一个模拟串台的并发测试而不是简单跑两个任务看一眼。测试逻辑可以是ExecutorService pool Executors.newFixedThreadPool(4); ExecutorService ttlPool TtlExecutors.getTtlExecutorService(pool); CountDownLatch start new CountDownLatch(1); for (int i 0; i 1000; i) { int taskId i; ttlPool.submit(() - { ContextHolder.set(user taskId); try { start.await(); // 让所有任务尽量同时开始 Thread.sleep(new Random().nextInt(50)); String current ContextHolder.get(); if (!(user taskId).equals(current)) { System.out.println(串台了: task taskId , got current); } } catch (InterruptedException ignored) { } finally { ContextHolder.remove(); } }); } start.countDown(); pool.shutdown();如果没有TTL这个测试会刷出一堆串台记录换上TTL并包装线程池后应该是安静的。6.4 第三方库里的 ThreadLocal 不属于 TTL 的管辖范围这是很多人在集成时踩的坑。TTL只管用TransmittableThreadLocal声明的变量。如果你的业务依赖了某个框架框架内部用的是原生ThreadLocal那这个框架自身上下文在线程池里还是会丢TTL帮不上忙。遇到这种情况思路是看框架是否提供了自定义上下文传递的SPI或者把框架需要的字段自己提取到TTL里进入异步任务后再重新塞给框架的ThreadLocal。整个过程相当于自己做了一层桥接。7. 融会贯通TTL 与 ThreadLocal、InheritableThreadLocal 的选型做一个简短的对比方便大家在不同场景下做选择方案传递触发时机适用场景注意点ThreadLocal不传递线程内隔离请求线程内数据私有、生命周期可控必须手动remove线程池场景容易串台InheritableThreadLocal线程创建时从父线程复制手动new Thread的场景不适用于线程池线程复用后无复制动作TransmittableThreadLocal任务提交时捕获快照执行前回放线程池、异步线程池、MQ消费等场景只传引用不深拷贝需要包装线程池或任务选型建议如果你确定代码只在同一个线程内使用上下文且每个请求都能在finally里remove用普通ThreadLocal就够。如果你是手动创建线程、不经过线程池InheritableThreadLocal勉强够用但还是建议直接上TTL免得以后改造成线程池时再踩一遍坑。只要你的项目里用了线程池并且需要在任务间传递上下文直接上TTL别犹豫。现代Java业务几乎没有不用线程池的所以TTL基本是标配。从团队治理角度我还要补充一句引入TTL不是一劳永逸的它解决的是传递这个环节而数据值本身是否正确构建、业务里是否误用了共享可变对象这样的问题仍需要你自己守好。我见过一个团队用了TTL之后又花了两周排查为什么B任务总能看到A任务里被修改过的对象内容最终定位到value是一个共享的HashMap实例——TTL把这个Map传过去了也把Map的可变状态传过去了。最后说说我的落地体会这些年带团队做过几次上下文传递改造最深的感受是技术选型不难难的是把规范落到位。TTL这类的工具本质上是在跟人一定会忘记remove这件事对抗。如果你在项目里定了规范用TTL就一定要从线程池工厂层面统一包装不然总有人绕过包装器直接new线程池串台问题就会换个方式回来。实践下来最省心的组合是一个全局的上下文对象 一个TransmittableThreadLocal持有者 一个统一封装的线程池工厂。前两步完成数据结构的收敛最后一步保证所有异步入口都被兜住。再搭配一套并发测试基本能覆盖99%的串台场景。剩下的1%就要靠线上日志里的traceId持续监控了。如果你现在正在被日志链路断了、用户数据串了、租户隔离失效这类问题折磨不妨先把手上的ThreadLocal替换成TransmittableThreadLocal再按文中的方式包装线程池跑一遍并发测试看看效果。多数情况下你会明显感觉到排查问题时的日志变顺了——这个体感比任何原理说明都直白。