一、现象:一条 traceId 链路,断在三个地方
去年 Q4 排查一次「下游超时」工单时,我拿 traceId 去全链路日志里搜,结果断成了三段:
- 网关与入口服务(JDK 17.0.9、Spring Boot 3.2.2)的日志 traceId 正常,
ac1f3e…一路可查; - 入口服务内部 4 个异步子任务(走自定义
ThreadPoolExecutor,core=16 / max=32 / queue=256)的日志里 MDC 是空的,traceId 字段为-,只能靠时间戳硬对齐; - 更诡异的是:同一个线程池,部分请求的 MDC 正常、部分为空,同一台机器、同一时刻,像随机丢。
量化影响:该接口 P99 基线 210ms,下游(风控 RPC)偶发 2.4s 超时时,因为异步日志缺 traceId,平均排查耗时 40 分钟/单;一个月 11 单,等于每月约 7 小时纯靠人肉对齐日志。定位后发现三处断点成因各不相同:
- 自定义线程池的
execute()没做任何上下文传递——MDC 天然不跨线程; - 一个用
CompletableFuture.supplyAsync(supplier)没传 executor 的分支——走ForkJoinPool.commonPool(availableProcessors()-1个线程,进程级单例),即使你包装了业务池也管不到它; - 一处 Netty 回调(gRPC 下游响应)——线程完全由 Netty EventLoop 调度,任何业务侧包装都拦不到。
二、根因:MDC 是 ThreadLocal,而线程池的本质是「线程复用 + 线程切换」
MDC.put() 底层是 Logback 的 ThreadLocal<Map<String,String>>(JDK 21 之后 Logback 1.4+ 才支持 ScopedValue,1.3.x 仍是 ThreadLocal)。它有两个硬约束:
- 作用域 = 线程。任务被提交到另一个线程执行时,那个线程的 ThreadLocalMap 里根本没有这个 key,
MDC.get("traceId")返回null,日志 pattern 里%X{traceId}打成-; - 线程池里线程是复用的。worker 线程执行完任务 A 不销毁,接着执行任务 B——如果 A 结束时没清 MDC,B 会读到 A 的 traceId(脏上下文),比「丢」更难查,因为它看起来「有值」,只是值错了。
所以跨线程上下文传播必须满足两个契约:传递(提交时把当前线程的值快照,带到执行线程)和清理(执行完恢复执行线程原来的值,而不是简单 remove——因为 worker 线程自己可能本来就有值)。这两个契约任何方案都必须显式保证。
三、三种实现的对比与落地代码
方案 A:手动包装(executor 装饰器)
核心是「提交时捕获快照、执行时 set、finally 恢复」:
public class ContextExecutor implements Executor {
private final Executor delegate;
public ContextExecutor(Executor delegate) { this.delegate = delegate; }
@Override
public void execute(Runnable task) {
// 提交线程上捕获快照(注意:必须在调用方线程执行,不能进 worker)
Map<String, String> snapshot = MDC.getCopyOfContextMap();
delegate.execute(() -> {
Map<String, String> backup = MDC.getCopyOfContextMap();
if (snapshot != null) MDC.setContextMap(snapshot); else MDC.clear();
try {
task.run();
} finally {
// 恢复而非 remove:worker 线程可能有自己的上下文
if (backup != null) MDC.setContextMap(backup); else MDC.clear();
}
});
}
}
// 使用:把业务池整体包一层,业务代码无感
Executor biz = new ContextExecutor(new ThreadPoolExecutor(16, 32, 60, SECONDS, new LinkedBlockingQueue<>(256)));
取舍:零依赖、无字节码侵入、每任务开销 < 0.1μs(一次 HashMap 浅拷贝,MDC 通常 3~5 个 key);但只保护你包装过的那一层——CompletableFuture 的 thenApply/thenCompose 组合、Netty EventLoop、gRPC stub 回调全都漏掉,这就是我们案例里断三处的直接原因。
方案 B:TransmittableThreadLocal(TTL)
把 MDC 换成 TTL 提供的 TtlMdcAdapter,并用字节码增强拦截所有 Executor.execute():
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>2.14.5</version>
</dependency>
// 1) MDC 包装成 TTL(Logback 场景)
TtlMdcAdapter.setMdcAdapter(new TtlMdcAdapter.MdcAdapter() {
public void put(String key, String value) { MDC.put(key, value); }
public String get(String key) { return MDC.get(key); }
public void remove(String key) { MDC.remove(key); }
public void clear() { MDC.clear(); }
});
TtlMdcAdapter.TRANSMITTED_MDC = new TransmittableThreadLocal<>();
# 2) JVM 启动参数挂 agent(注意:必须 -javaagent,不能只加依赖)
-javaagent:/libs/transmittable-thread-local-2.14.5.jar
取舍:自动拦截标准 Executor/ExecutorService/ScheduledExecutorService,对 CompletableFuture 组合算子也生效(因为组合算子底层仍走 commonPool 的 ForkJoinWorkerThread 任务提交,TTL 对 commonPool 有专门处理);代价是字节码增强——冷启动多 1~2s 类增强时间,且对非标准执行器(Netty EventExecutorGroup、gRPC io.grpc 线程池)必须手动 TtlRunnable.get(runnable) 包装,否则照样断。
方案 C:JDK 21 ScopedValue(面向未来)
public static final ScopedValue<String> TRACE_ID =
ScopedValue.newInstance("traceId");
// 入口线程绑定
ScopedValue.where(TRACE_ID, traceId).run(() -> {
// 这里及所有 fork 的虚拟线程内 TRACE_ID.isBound() == true,不可变
virtualThreadFactory.newThread(() ->
System.out.println(TRACE_ID.get())).start();
});
取舍:绑定即不可变(天然无「中途被改」的脏数据)、零清理负担(作用域结束自动解绑,不存在「忘了 remove」)、对虚拟线程友好(每虚拟线程一份绑定,不是每载体一份,避免了 ThreadLocal 在百万虚拟线程下的内存爆炸)。但只解决「向下继承」,不解决「跨已有线程池传递」——它不能把值塞进一个已经在跑的 Tomcat 工作线程;且要求 JDK 21 + Logback 1.4+/SLF4J 2.x 全家桶升级。
三方案对比
| 维度 | 手动包装 | TTL | ScopedValue | |---|---|---|---| | 依赖/侵入 | 无 | 字节码 agent | JDK 21 + 日志栈升级 | | CompletableFuture 组合算子 | ❌ 漏 | ✅ 覆盖 | 虚拟线程场景 ✅ | | Netty/gRPC 非标准池 | ❌ 漏 | 需手动 TtlRunnable | 不适用 | | 清理语义 | 自己写对(恢复而非 remove) | agent 保证 | 编译器保证 | | 单任务额外开销 | <0.1μs(Map 浅拷贝) | ≈0.3μs(快照+增强路径) | ≈0(不可变引用) | | 适用 JDK | 8+ | 8+ | 21+ |
我的取舍:JDK 17 存量服务用「方案 A 包业务池 + 方案 B 兜底 commonPool/组合算子」双层;新起的 JDK 21 + 虚拟线程服务直接用方案 C。
四、踩过的坑(全是真的)
orTimeout超时后任务还在跑,MDC 恢复时序错乱:supplyAsync(...).orTimeout(3s)超时只取消get的等待,supplier 线程仍在执行;它 finally 里的 MDC 恢复发生在超时 3s 之后,期间 worker 线程若被复用执行别的任务,会短暂持有已死请求的 traceId——日志里出现过「一条请求的日志横跨两个 traceId」的诡异现象。finally里用MDC.clear()而不是「恢复原值」:worker 线程本来带着一个长生命周期的上下文(如批次号),被你的任务 clear 掉后,同线程后续 3 秒内的日志全部丢批次号。正确写法是 backup/restore。- TTL agent 没挂:只加了 Maven 依赖、忘了
-javaagent,TTL 退化为普通 ThreadLocal,行为正确但完全不传递——不报错、不告警,只有日志断链。排查时jcmd <pid> VM.command_line确认 agent 在不在,是第一步。 CompletableFuture.supplyAsync不传 executor:走 commonPool,你包装的业务池对它无效。要么显式传 executor,要么上 TTL。我们案例断点 2 就是这条。- 虚拟线程 + ThreadLocal 内存:迁移 JDK 21 虚拟线程后,一个
ThreadLocal<Connection>让内存从 40MB 涨到 900MB(每虚拟线程一份 Entry)——这是 ThreadLocal 方案的结构性天花板,ScopedValue 没有这个问题。
五、结论与可复用清单
结论一句话:MDC 断链不是「加个工具类」的事,而是「先枚举所有跨线程边界,再按边界选方案」的枚举题——业务池用装饰器(零依赖、可控),commonPool/组合算子用 TTL 兜底(agent 必挂),Netty/gRPC 回调手动包,JDK 21 新服务直接 ScopedValue。
可复用清单(按顺序自查):
- [ ]
grep -rn "supplyAsync\|thenApply\|parallelStream"枚举所有 commonPool 边界; - [ ]
grep -rn "new Thread\|Executors\.\|EventExecutorGroup"枚举非标准执行器; - [ ] 业务池统一包
ContextExecutor,finally 用 backup/restore 而非 clear; - [ ] 上 TTL 则
jcmd <pid> VM.command_line验证-javaagent存在; - [ ] 压测时打一条「入口 traceId == 异步日志 traceId」的断言日志,跑 1 万请求确认零断链;
- [ ] JDK 21 新服务:MDC 改 ScopedValue,淘汰所有跨线程 ThreadLocal(尤其连接类)。
这套自查清单我们固化进了团队 checklist,此后新服务上线前过一遍,traceId 断链类工单从每月 10+ 降到 0。