日志里的 TID 为什么是 N/A:SkyWalking 异步线程池 traceId 传递实战
一次把「异步任务丢 traceId」从现象查到根因,并用一行配置统一修复 8 处线程池的完整记录。
一、问题:同一笔业务,两条日志对不上
线上日志里出现了这样一条:
2026-10-12 09:01:16.926 [TID:N/A] [deviceRenameSync-1] ERROR
DeviceRenameSyncServiceImpl - 设备名称同步上游失败 id=*** name=***
诡异的地方在于:同一笔改名同步业务,入口那条 HTTP 请求日志的 TID 是正常的 ,只有落到 deviceRenameSync-1 线程里执行的这段是 N/A。
这意味着全链路排查断了:ES 里按请求的 trace_id 检索,能查到入口日志、能查到 Kafka 推出去的 api 日志,唯独查不到这条最有价值的失败原因。
TID 有两个来源,而且同源:
| 观测字段 | 来源 | 异步线程表现 |
|---|---|---|
应用日志 TID |
logback 的 TraceIdJsonProvider 调 TraceContext.getTraceId() |
N/A |
业务日志 trace_id(log_type=api/external) |
业务代码调 TraceContext.traceId() |
兜底空串 |
两个字段都来自 Agent 的 TraceContext,所以只要修好上下文传递,两个一起恢复。
二、根因:trace 上下文活在 ThreadLocal 里
SkyWalking Agent 通过 javaagent 做字节码增强,拦截到请求后生成 trace 上下文(traceId、segmentId、spanId),并把它存放在当前线程的 ThreadLocal 中。
业务线程(HTTP 请求线程)向自定义线程池提交任务后:
请求线程(有上下文)──提交任务──► 队列 ──► 线程池工作线程(裸线程,无上下文)
工作线程是首次创建的裸线程,ThreadLocal 里什么都没有,于是 TraceContext.traceId() 取不到值 ------ 日志就出现了 N/A。
这里有两个非常常见的误区,值得单独说:
误区一:以为 Agent 会自动增强线程池。
不会。SkyWalking 的插件体系能自动增强 Tomcat、Dubbo、Kafka 等框架入口,但自定义线程池的提交行为是业务语义,Agent 无从猜测,跨线程传递必须由业务显式声明。
误区二:想手动往 MDC 里塞 traceId。
无效。logback 输出 TID 字段时走的是 TraceIdJsonProvider,它直接调用 TraceContext.getTraceId(),根本不读 MDC。往 MDC 塞值不会改变 TID 的输出。
三、方案:RunnableWrapper + TaskDecorator
SkyWalking 官方 toolkit 提供了跨线程工具类,配合 Spring 线程池的任务装饰器,一行配置覆盖该池的全部提交点。
原理
业务线程(有 trace 上下文) 线程池工作线程
────────────────────────── ──────────────────────────
executor.execute(task)
│
├─ TaskDecorator.decorate(task)
│ └─ RunnableWrapper.of(task)
│ └─ 捕获当前线程 trace 快照并绑定
│
└─ 任务入队 ──────────────────────────► wrapper.run()
├─ 恢复 trace 快照到本线程
├─ 执行原 task ← TID / trace_id 正常
└─ 清理快照(防线程复用串值)
关键点:
- 快照在提交时捕获,任务在队列里排多久都不影响,恢复的永远是提交瞬间的上下文;
TaskDecorator作用于该池所有 经execute提交的任务(submit(Callable)内部也走execute,同样生效),一次配置覆盖全部提交点,不用去每个execute()调用处包一层。
代码
java
import org.apache.skywalking.apm.toolkit.trace.RunnableWrapper;
// SkyWalking 跨线程 trace 传递:提交线程捕获快照、执行线程恢复
executor.setTaskDecorator(RunnableWrapper::of);
另一个前提:Agent 端插件必须在位
toolkit jar 里的 RunnableWrapper 只是占位 API,未挂 Agent 时是直通实现;真正的捕获/恢复逻辑由 Agent 端的 toolkit activation 插件提供。
SkyWalking 9.x 官方发行包已经自带,按功能拆分放在 activations/ 目录下(plugins/ 和 activations/ 都会在 Agent 启动时加载),随镜像天然就位,无需任何额外配置:
bash
# 容器内确认插件在位(文件名随版本略有差异,认准 toolkit activation)
kubectl exec <pod> -- ls /opt/skywalking-agent/activations | grep toolkit
# 期望输出含:apm-toolkit-trace-activation-*.jar
还有一个额外好处:TID / trace_id 是 Agent 本地注入的,不依赖 OAP 连通性。所以哪怕在「只注入 traceId、暂不上报 OAP」的分阶段策略下,这套方案同样有效。
四、三个容易踩的坑
- 包名搞错 。正确是
org.apache.skywalking.apm.toolkit.trace.RunnableWrapper,不是...toolkit.thread.RunnableWrapper。搜到旧版本博客容易抄错。 - 以为要加依赖 。
RunnableWrapper就在已有的apm-toolkit-trace里,零新增依赖。 - 在本地调试环境验证 。本地没挂 Agent,toolkit 是直通实现,TID 依旧是
N/A------ 别在这儿怀疑人生,必须在挂了 Agent 的环境验证。
五、落地:8 处线程池,一处一行
项目里所有自定义 ThreadPoolTaskExecutor 统一处理,共 8 处代码位置(覆盖 10 个线程池).
覆盖性核查:还有没有漏网的跨线程路径?
改完之后必须反向核查一遍,否则「以为修全了」比「知道没修全」更危险:
| 跨线程路径 | 核查结果 | 结论 |
|---|---|---|
@Async 注解 |
全工程无使用 | 无遗漏 |
无 executor 参数的 CompletableFuture.supplyAsync/runAsync(走 ForkJoinPool.commonPool,无法装饰) |
无此类调用,全部 6 处均显式传入已装饰的线程池 | 无遗漏 |
parallelStream() |
全工程无使用 | 无遗漏 |
日志推送专用池(MetricAspect.logPushExecutor、ExternalCallLogUtil.logPushExecutor) |
trace_id 在业务线程构造日志 JSON 时已取好,推送线程只做 Kafka send、不读 trace 上下文 |
无需装饰 |
Spring Boot 自动配置的 applicationTaskExecutor |
未用于异步业务任务 | 不涉及 |
六、边界:这些情况仍然 N/A,是预期而非缺陷
不想过度承诺,所以边界写清楚:
| 场景 | 表现 | 说明 |
|---|---|---|
@Scheduled 定时任务、Kafka 消费线程直接触发的链路 |
TID 仍为 N/A |
链路源头本身就没有 trace,快照无可捕获。这类链路的日志关联靠 log_subtype / 业务字段,不靠 trace_id |
| 本地调试(未挂 Agent) | TID 仍为 N/A |
toolkit 直通实现,环境问题 |
| SkyWalking UI 里的异步段 | 同一 trace 下是新 segment,与父段无 span 级父子关系 | traceId 一致,按 trace_id 检索 ES 不受影响,UI 上表现为异步分支 |
| 任务在队列中排队 | 无影响 | 快照在提交时捕获 |
七、验证方法
在挂了 Agent 的测试环境:
- 调用设备状态上报接口(携带 name 触发改名同步);
- 看
deviceRenameSync-线程的应用日志,[TID:xxx]应与该请求 api 日志的trace_id完全一致; - 在 ES 中按该 trace_id 检索,应能同时捞到:请求入口 api 日志、改名同步的 external 日志、应用 error 日志 ------ 一条链路完整闭环。
如果 TID 依然是 N/A、UI 里也没有异步 segment,优先排查两件事:activations/ 下的 toolkit 插件是否被裁剪、Agent 是否真的挂上了(看 Agent 的启用开关环境变量)。
八、结论与团队约定
- 问题本质不复杂:trace 上下文是 ThreadLocal,跨线程不会自动传递 。但它的排查成本高------现象只是一个
N/A,很容易误判成日志配置问题。 - 修复成本极低:一行
setTaskDecorator(RunnableWrapper::of),零依赖、零架构改动。 - 沉淀成约定,避免反复踩:
- 新建
ThreadPoolTaskExecutor时,统一追加setTaskDecorator(RunnableWrapper::of); - 使用
CompletableFuture并行时必须显式传入已装饰的线程池 ,禁止无参调用(ForkJoinPool.commonPool无法装饰,还有全局争抢和 OOM 风险); - 其他微服务引入自定义线程池执行异步任务时,按同款方案处理。
- 新建
一句话总结:凡是「有 trace 的线程」把任务交给「没 trace 的线程」,就必须显式传递上下文;而 SkyWalking 已经把这件事做成了两行代码,剩下的只是别忘了写。