Skip to content

追踪上下文在哪一跳丢失 ​

一条订单请求在追踪系统里只显示了前半段:网关、订单服务,然后就断了。库存服务的日志里明明有这次调用,却挂在另一条毫不相干的 trace 下面。排查下来,订单服务把扣库存放进了一个线程池异步执行。

分布式追踪靠一个上下文把一次请求经过的所有工作串起来:进程内,上下文随着当前线程传递;跨进程时,上下文被写进请求头(W3C 的 traceparent),由下游取出来继续。这两条路上只要有一处没接上,后面的 span 就会成为一条新 trace 的根,整条链路从那里断开。

本文用 OpenTelemetry Java SDK 1.67.0 的手工埋点,逐个验证上下文会在哪里丢失、怎样接上,以及传播、采样和导出为什么是三件不同的事。实验见文末。

子 span 在哪里创建结果同一线程同一条 trace普通线程池新 trace 的根CompletableFuture.runAsync(公共池)新 trace 的根虚拟线程新 trace 的根Context.taskWrapping 包装的线程池同一条 traceContext.current().wrap(task)同一条 trace手写 HttpClient,不注入请求头服务端开始另一条 trace注入 traceparent(标志位 03)同一条 trace传播、采样、导出是三件事:不采样时 traceparent 标志位为 02,下游跟随父级也不记录;span 只有 end() 之后才会导出
图 1 · 实测(OpenTelemetry Java 1.67.0):上下文存在线程局部存储里,换线程就丢;包装执行器或包装任务可以接上。跨进程要把 traceparent 写进请求头,否则服务端开始一条新 trace。不采样的根 span 照样传播上下文,标志位让下游也不记录;没有 end() 的 span 不会导出。

一、进程内:上下文跟着线程走 ​

OpenTelemetry 的当前上下文存放在线程局部存储里。span.makeCurrent() 之后,同一线程里创建的 span 自动以它为父:

text
同一线程里创建子 span:同一条 trace,parent 正确
提交到普通线程池:新的 trace(子 span 成了根)
CompletableFuture.runAsync(公共池):新的 trace(子 span 成了根)
Thread.ofVirtual().start:新的 trace(子 span 成了根)

任务一旦换到另一个线程执行,那个线程上没有当前上下文,子 span 就成了根。普通线程池、CompletableFuture 的公共池、虚拟线程都一样:虚拟线程也是独立的线程,线程局部变量不会从创建它的线程继承过来。

开头的问题就出在这里:扣库存的任务在线程池里执行,调用库存服务时没有当前上下文,请求头里也就没有 traceparent。

二、两种接上的方式 ​

text
提交到 Context.taskWrapping 包装的线程池:同一条 trace,parent 正确
CompletableFuture.runAsync 传入包装后的线程池:同一条 trace,parent 正确
普通线程池,但提交前用 Context.current().wrap(task):同一条 trace,parent 正确
  • 包装执行器:Context.taskWrapping(executor) 返回一个新的执行器,每次提交时捕获提交线程的上下文,在执行线程上恢复。把应用里的线程池统一包装一次,所有提交都接得上。
  • 包装任务:Context.current().wrap(task) 只对这一个任务生效,适合无法替换执行器的地方。

用 CompletableFuture 时,记得把包装后的执行器作为参数传进去;不传就落到公共池,上下文照样丢失。

使用 OpenTelemetry 的 Java agent 时,常见的线程池和 HTTP 客户端会被自动包装,这些问题大多不会出现;但 agent 覆盖不到的地方(自己写的调度器、消息消费的回调、某些第三方库内部的线程)仍然需要手工处理。实验里的结论针对的就是这些没有被自动埋点覆盖的代码。

三、跨进程:上下文要写进请求头 ​

text
手写 HttpClient 调用,不注入请求头:服务端收到 traceparent=null;服务端 span 是另一条 trace 的根
注入 traceparent 后:服务端收到 traceparent=00-<traceId>-<spanId>-03;服务端 span 与客户端同一条 trace

跨进程时,客户端用传播器把当前上下文注入请求头,服务端从请求头提取后作为父上下文。只要中间有一个手写的 HTTP 调用没有注入,或者一个网关、代理没有转发这个头,下游就从零开始。

traceparent 的四段是版本、trace ID、父 span ID 和标志位。标志位 01 是「采样」,02 是 W3C Trace Context Level 2 新增的「trace ID 是随机生成的」;03 表示两者都有。

消息队列也是跨进程的一跳:上下文要写进消息头,消费者取出来再创建 span。消息被重试、批量消费时,一个消费 span 可能对应多条消息,这时用 span 链接(link)而不是父子关系更合适。压测流量的标记沿着同样的路径传递,也会在同样的位置丢失,见压测标记的传播。

四、传播、采样与导出是三件事 ​

text
根 span 采样器为 parentBased(alwaysOff):服务端收到 traceparent 末尾标志=02(采样位为 0,随机 trace-id 位为 1),客户端导出 0 个 span;服务端用默认的 parentBased(alwaysOn) 采样器,跟随父级不采样,导出 0 个
开始但没有调用 end() 的 span,导出数=0
  • 传播:上下文被传下去了,标志位告诉下游「这条 trace 不采样」。下游默认的采样器跟随父级的决定,于是整条链路都不记录。这是期望的行为:采样决定在入口做一次,下游保持一致,才不会得到半条 trace。
  • 采样:决定是否记录。不采样的 span 仍然有 trace ID,日志里可以关联,只是追踪系统里看不到。
  • 导出:只有结束了的 span 才会交给处理器导出。忘记调用 end()(例如异常路径上没有放在 finally 里),这个 span 就永远不会出现。

所以「追踪系统里看不到某一段」至少有四种原因:上下文没有传过来、上游决定不采样、span 没有结束、导出失败(缓冲区满、收集器不可用)。排查时按这个顺序逐个排除,比直接怀疑追踪系统更快。日志、指标与追踪各自承担什么,见可观测性。

五、检查清单 ​

  • 应用里的线程池统一用 Context.taskWrapping 包装;CompletableFuture 传入包装后的执行器。
  • 虚拟线程、自己写的调度器、消息回调要显式传递上下文。
  • 每个手写的 HTTP 或消息调用都注入上下文,网关与代理转发 traceparent。
  • 采样在入口决定一次,下游跟随父级。
  • span 的 end() 放在 finally 里。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权