追踪上下文在哪一跳丢失
一条订单请求在追踪系统里只显示了前半段:网关、订单服务,然后就断了。库存服务的日志里明明有这次调用,却挂在另一条毫不相干的 trace 下面。排查下来,订单服务把扣库存放进了一个线程池异步执行。
分布式追踪靠一个上下文把一次请求经过的所有工作串起来:进程内,上下文随着当前线程传递;跨进程时,上下文被写进请求头(W3C 的 traceparent),由下游取出来继续。这两条路上只要有一处没接上,后面的 span 就会成为一条新 trace 的根,整条链路从那里断开。
本文用 OpenTelemetry Java SDK 1.67.0 的手工埋点,逐个验证上下文会在哪里丢失、怎样接上,以及传播、采样和导出为什么是三件不同的事。实验见文末。
一、进程内:上下文跟着线程走
OpenTelemetry 的当前上下文存放在线程局部存储里。span.makeCurrent() 之后,同一线程里创建的 span 自动以它为父:
同一线程里创建子 span:同一条 trace,parent 正确
提交到普通线程池:新的 trace(子 span 成了根)
CompletableFuture.runAsync(公共池):新的 trace(子 span 成了根)
Thread.ofVirtual().start:新的 trace(子 span 成了根)任务一旦换到另一个线程执行,那个线程上没有当前上下文,子 span 就成了根。普通线程池、CompletableFuture 的公共池、虚拟线程都一样:虚拟线程也是独立的线程,线程局部变量不会从创建它的线程继承过来。
开头的问题就出在这里:扣库存的任务在线程池里执行,调用库存服务时没有当前上下文,请求头里也就没有 traceparent。
二、两种接上的方式
提交到 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 覆盖不到的地方(自己写的调度器、消息消费的回调、某些第三方库内部的线程)仍然需要手工处理。实验里的结论针对的就是这些没有被自动埋点覆盖的代码。
三、跨进程:上下文要写进请求头
手写 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)而不是父子关系更合适。压测流量的标记沿着同样的路径传递,也会在同样的位置丢失,见压测标记的传播。
四、传播、采样与导出是三件事
根 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里。
配套实验
- codesphere-labs/system-design/trace-context-propagation:OpenTelemetry Java SDK 1.67.0、JDK 25.0.4.1:线程池、CompletableFuture、虚拟线程、包装执行器与包装任务,手写 HTTP 调用与 traceparent 注入,采样标志,未结束的 span(验证记录)
参考资料