日志里的 TID 为什么是 N/A:SkyWalking 异步线程池 traceId 传递实战

日志里的 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」的分阶段策略下,这套方案同样有效。

四、三个容易踩的坑

  1. 包名搞错 。正确是 org.apache.skywalking.apm.toolkit.trace.RunnableWrapper,不是 ...toolkit.thread.RunnableWrapper。搜到旧版本博客容易抄错。
  2. 以为要加依赖 。RunnableWrapper 就在已有的 apm-toolkit-trace 里,零新增依赖。
  3. 在本地调试环境验证 。本地没挂 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 的测试环境:

  1. 调用设备状态上报接口(携带 name 触发改名同步);
  2. 看 deviceRenameSync- 线程的应用日志,[TID:xxx] 应与该请求 api 日志的 trace_id 完全一致;
  3. 在 ES 中按该 trace_id 检索,应能同时捞到:请求入口 api 日志、改名同步的 external 日志、应用 error 日志 ------ 一条链路完整闭环。

如果 TID 依然是 N/A、UI 里也没有异步 segment,优先排查两件事:activations/ 下的 toolkit 插件是否被裁剪、Agent 是否真的挂上了(看 Agent 的启用开关环境变量)。

八、结论与团队约定

  • 问题本质不复杂:trace 上下文是 ThreadLocal,跨线程不会自动传递 。但它的排查成本高------现象只是一个 N/A,很容易误判成日志配置问题。
  • 修复成本极低:一行 setTaskDecorator(RunnableWrapper::of),零依赖、零架构改动。
  • 沉淀成约定,避免反复踩:
    1. 新建 ThreadPoolTaskExecutor 时,统一追加 setTaskDecorator(RunnableWrapper::of);
    2. 使用 CompletableFuture 并行时必须显式传入已装饰的线程池 ,禁止无参调用(ForkJoinPool.commonPool 无法装饰,还有全局争抢和 OOM 风险);
    3. 其他微服务引入自定义线程池执行异步任务时,按同款方案处理。

一句话总结:凡是「有 trace 的线程」把任务交给「没 trace 的线程」,就必须显式传递上下文;而 SkyWalking 已经把这件事做成了两行代码,剩下的只是别忘了写。

相关推荐
AscendKing21 小时前
可观测性选型:ELK vs SkyWalking,到底哪个更重?是二选一,还是两者一起部署?
elk·skywalking·apm·可观测
AscendKing1 天前
Docker Compose 一键部署 SkyWalking 10.2.0 + Elasticsearch8.11,分布式链路追踪完整实战
elasticsearch·docker·skywalking·链路追踪·微服务监控
飘然生1 天前
traceId链路 skywalking java agent
java·开发语言·skywalking
Thomas.Sir3 天前
第27课:SkyWalking全链路追踪实战 & 微服务调用链路分析
spring cloud·微服务·skywalking
lisanmengmeng1 个月前
分布式追踪与监控:Skywalking(三)
分布式·skywalking
lisanmengmeng1 个月前
分布式追踪与监控:Skywalking介绍(一)
分布式·skywalking
Jinkxs2 个月前
SkyWalking - 生产级部署:集成 Elasticsearch 作为后端存储
大数据·elasticsearch·skywalking
就叫_这个吧2 个月前
IDEA本地springcloud项目 接入SkyWalking实现链路追踪
spring cloud·intellij-idea·skywalking