title: traceId 一进线程池就丢:InheritableThreadLocal 为什么只在第一次生效
tags: [Java, ThreadLocal, InheritableThreadLocal, 线程池, 链路追踪]
从「日志串不起来」开始
我们的日志规范是每条日志前面带 traceId,出问题时用 traceId 一搜就能把一次请求经过的所有服务、所有异步分支全捞出来。这套东西平时很好用,直到有一次排查订单状态不一致,我搜 traceId 只搜到了 6 条日志——而按代码逻辑,那次请求至少该有 20 多条。
丢掉的那些日志有个共同特征:全是在线程池里执行的异步任务打的。它们的 traceId 字段是空的,或者更糟——是别的请求的 traceId。
拿着别人的 traceId 打日志,比没有 traceId 更可怕。那意味着排查时我会把两次不相干的请求当成一次来分析。
当时的实现,和它为什么「看起来能用」
traceId 存取用的是这么一个工具类:
public class TraceContext { // 用 Inheritable 版本,本意是让子线程能继承父线程的 traceId private static final InheritableThreadLocal<String> TRACE_ID = new InheritableThreadLocal<>(); public static void set(String traceId) { TRACE_ID.set(traceId); } public static String get() { return TRACE_ID.get(); } public static void clear() { TRACE_ID.remove(); } }写这段代码的同学思路是对的:普通ThreadLocal在子线程里拿不到父线程的值,所以换成InheritableThreadLocal。他还专门写了个 demo 验证,确实能传下去:
TraceContext.set("trace-001"); new Thread(() -> System.out.println(TraceContext.get())).start(); // 输出 trace-001,验证通过问题是这个 demo 用的是new Thread(),而线上用的是线程池。
关键源码:继承发生在线程创建那一刻
InheritableThreadLocal的传递是在Thread构造函数里完成的,只此一次。看Thread.init的核心几行:
private void init(ThreadGroup g, Runnable target, String name, long stackSize, AccessControlContext acc, boolean inheritThreadLocals) { // ... Thread parent = currentThread(); // 只有父线程的 inheritableThreadLocals 非空,才做一次拷贝 if (inheritThreadLocals && parent.inheritableThreadLocals != null) this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); // ... }逐句拆开看:
Thread parent = currentThread():这里的「父线程」指的是执行 new Thread() 这行代码的那个线程,不是逻辑上的调用方。createInheritedMap做的是一次浅拷贝,把父线程 map 里的键值对复制到新线程自己的 map 里。拷贝完两者就没关系了,父线程后续改值不会同步给子线程。- 整个逻辑写在
init里,也就是说:继承只在线程被创建的瞬间发生一次。
线程池的特点恰恰是线程复用。一个核心线程在池子启动时(或者第一次提交任务时)被创建,那一刻它继承了当时那个提交者的 traceId,然后这个线程活几天几个月,处理成千上万个不同请求的任务,inheritableThreadLocals里躺着的永远是最初那一次继承来的值。
这就完美解释了线上两种现象:任务打不出 traceId(线程创建时提交者还没 set),或者打出别人的 traceId(线程创建时继承了那一次请求的值,之后一直用它)。
用一段代码把这个现象钉死
public class InheritableInPoolDemo { private static final InheritableThreadLocal<String> CTX = new InheritableThreadLocal<>(); public static void main(String[] args) throws Exception { ExecutorService pool = Executors.newFixedThreadPool(1); // 只有 1 个线程 CTX.set("request-A"); pool.submit(() -> System.out.println("task1 sees: " + CTX.get())).get(); CTX.set("request-B"); pool.submit(() -> System.out.println("task2 sees: " + CTX.get())).get(); pool.shutdown(); } }输出是:
task1 sees: request-A task2 sees: request-A第二个任务明明是在CTX.set("request-B")之后提交的,却看到了request-A。因为池里那唯一的线程是在 task1 提交时创建的,那一刻继承了 A,之后再也不会重新继承。
我把这段代码贴到团队群里,比讲十分钟原理有效——好几个人当场去翻自己的代码。
三种解法,我们最后选了哪个
解法一:手动传递(最笨但最可靠)
提交任务前把 traceId 抓出来,在任务里手动 set,finally里清理:
public static Runnable wrap(Runnable task) { final String traceId = TraceContext.get(); // 在提交线程(父线程)抓取 return () -> { String backup = TraceContext.get(); // 备份池线程原有值 TraceContext.set(traceId); try { task.run(); } finally { if (backup == null) TraceContext.clear(); else TraceContext.set(backup); // 还原,避免污染下一个任务 } }; }这段的三个要点:traceId必须在 lambda 外面取(否则取到的是池线程的值);finally必须还原而不是简单 remove(嵌套提交时会把外层的值也清掉);backup为 null 时要clear而不是set(null),否则 map 里会留一个 null 值的 entry,虽然不致命但没必要。
解法二:TransmittableThreadLocal(阿里开源的 TTL)
TransmittableThreadLocal的思路是在任务提交时捕获快照、在任务执行前回放、执行后恢复,正好补上线程池复用这个缺口:
private static final TransmittableThreadLocal<String> TRACE_ID = new TransmittableThreadLocal<>(); // 用 TtlExecutors 包装原有线程池即可,业务代码不用改 ExecutorService pool = TtlExecutors.getTtlExecutorService( new ThreadPoolExecutor(8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(200)));我们用的是transmittable-thread-local2.14.2。它还提供 javaagent 方式,连包装线程池这一步都能省掉,对存量项目改动最小。
解法三:换成 MDC + 框架埋点
如果你已经在用 SkyWalking、OpenTelemetry 这类 APM,它们的 agent 通常自带线程池的跨线程上下文传播,直接用MDC.get("traceId")就行,不需要自己维护 ThreadLocal。
| 方案 | 侵入性 | 线程池复用是否正确 | 嵌套提交是否正确 | 适合谁 |
|---|---|---|---|---|
| 手动 wrap | 高(每处提交都要包) | 正确 | 需自己备份还原 | 异步点少、不想引依赖 |
| TTL 包装线程池 | 低(只改建池处) | 正确 | 正确 | 大多数存量 Java 项目 |
| APM agent + MDC | 最低(无代码改动) | 正确 | 正确 | 已有完整 APM 体系 |
| InheritableThreadLocal | 低 | 错误 | 错误 | 只适合 new Thread 的场景 |
我们选了 TTL。理由很实在:项目里有 40 多处线程池创建点,但异步任务提交点有 300 多处。改 40 处建池代码,比改 300 处提交代码划算得多,而且以后新增提交点不会漏。
Spring 项目里还有个更省事的口子:TaskDecorator
如果你的异步任务走的是 Spring 的@Async或ThreadPoolTaskExecutor,其实不用包装每个任务,Spring 提供了TaskDecorator扩展点,专门用来做上下文传递:
public class TraceTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { // 这行在【提交线程】执行,能拿到正确的上下文 String traceId = TraceContext.get(); Map<String, String> mdc = MDC.getCopyOfContextMap(); return () -> { // 这里已经在【池线程】了 try { TraceContext.set(traceId); if (mdc != null) MDC.setContextMap(mdc); runnable.run(); } finally { TraceContext.clear(); MDC.clear(); // 必须清,否则污染这个池线程的下一个任务 } }; } } @Bean("bizExecutor") public ThreadPoolTaskExecutor bizExecutor() { ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor(); exec.setCorePoolSize(8); exec.setMaxPoolSize(16); exec.setQueueCapacity(200); exec.setThreadNamePrefix("biz-async-"); exec.setTaskDecorator(new TraceTaskDecorator()); // 关键一行 exec.initialize(); return exec; }decorate方法的执行时机是理解它的关键:它在submit/execute被调用时、由提交线程同步执行,所以在这里取上下文是安全的。返回的那个 lambda 才是真正跑在池线程里的部分。这个先后顺序如果搞反——比如把TraceContext.get()写进 lambda 内部——那就等于什么都没做。
我们的 MDC 也一起在这里传了,否则 logback 的%X{traceId}在异步日志里还是空的。这一点很多人只处理了自己的 ThreadLocal 却忘了 MDC,日志里依然缺失。
别忘了泄漏这件事
顺带说一个容易被忽略的点。ThreadLocalMap的 key 是WeakReference<ThreadLocal>,value 是强引用。如果 ThreadLocal 对象本身被回收了但线程还活着,就会留下一个 key 为 null、value 还在的 entry,这就是经典的泄漏点。
线程池里这个问题格外严重,因为线程几乎不死。虽然get/set时expungeStaleEntry会顺手清理一部分,但那是概率性的,不能依赖。所以上面 wrap 的finally里那句还原不只是为了正确性,也是为了不让 value 长期挂在池线程上。
我们上线 TTL 之后专门看了一下堆:修复前 dump 里能看到某个池线程的ThreadLocalMap里挂着 17 个 entry,其中 5 个 key 已为 null;修复后稳定在 3 个 entry,无 null key。
复盘数字
- 问题存在时长:从功能上线算起约 4 个月,期间无人发现,因为大部分排查场景只看主线程日志。
- 抽样统计:异步任务日志中 traceId 缺失占 62%,串到其他请求 traceId 的占 9%。这 9% 是真正危险的部分。
- 改造范围:40 处线程池创建点包装 TTL,耗时约 1.5 天,包含回归测试。
- 改造后异步日志 traceId 正确率抽样 100%(1000 条采样)。
我的判断
InheritableThreadLocal这个类的名字有很强的误导性,它容易让人以为「只要用了它,上下文就能跨线程传」。实际上它的适用面窄得多——只在「显式 new Thread 且创建时刻上下文已就绪」时才对。在有线程池的项目里,我基本不建议用它,因为它会给你一种「已经处理好了」的错觉,而错误的 traceId 比没有 traceId 危害更大。
如果非要给个简单判断标准:你的异步任务是不是跑在复用线程上?只要是,就别指望 InheritableThreadLocal。
思考题
假设有嵌套提交的场景:线程池 A 里的任务又往线程池 B 提交了任务。用 TTL 时上下文能正确传两层吗?如果 A 里的任务在提交给 B 之前修改了 traceId,B 拿到的是修改前还是修改后的值?建议你写段代码亲手验证一遍,这个细节在做全链路压测标记(比如染色流量)时会直接决定标记会不会漏。