☰
OpenTracing+Jaeger+Spring自动埋点源码剖析:链路追踪断点排查与跨线程传播
2026/10/15 5:01:16 网站建设 项目流程

1. 从一次链路断点排查说起:为什么我要啃这套源码

去年帮一个团队排查线上问题,现象很典型:订单服务调用库存服务超时,但日志里只有订单服务自己打印的“调用库存超时”,库存服务那边干干净净,什么记录都没有。运维说“链路追踪不是接了吗”,开发说“Jaeger 面板上确实有这条 trace,但只有订单服务一个 span,库存服务的 span 根本没出现”。

这个现象把我拉回到了一个老问题上:分布式链路追踪的“自动埋点”到底自动到了什么程度,哪些环节它管,哪些环节它不管。很多团队接了 Spring Cloud 之后,以为加个依赖、配个地址就万事大吉,结果一到跨线程、跨中间件、异步调用的场景,链路就断得七零八落。

opentracing-java-spring-jaeger这套组合,本质上解决的就是“Spring 生态里怎么把 OpenTracing 的埋点自动化”这件事。它不是一个单一库,而是三层东西叠在一起:底层是 OpenTracing 的 Java API 规范,中间是 Jaeger 的客户端实现,上层是 Spring 的自动装配和 AOP 切面。三层各管一段,任何一层理解不到位,链路就会在你想不到的地方断掉。

这篇文章适合两类人看:一类是已经接了 Jaeger 但链路老是断、想搞清楚底层到底怎么运作的开发者;另一类是准备自研或深度定制链路追踪方案、需要参考成熟实现思路的架构人员。我会从源码结构、自动装配机制、跨线程传递、采样策略、上报链路几个角度,把这套东西拆开讲透,中间穿插我自己踩过的坑和验证过的结论。

2. 三层架构拆解:OpenTracing、Jaeger 客户端、Spring 自动装配各管什么

2.1 OpenTracing 只定义接口,不负责实现

很多人第一次看 OpenTracing 的源码会懵:io.opentracing.Tracer这个接口里方法一大堆,但翻遍整个包也找不到一个能直接 new 出来的实现类。这不是 bug,是设计如此。

OpenTracing 的定位是规范,类似 JDBC。它只规定“一个 Tracer 应该有哪些能力”:怎么开一个 Span、怎么设置 tag、怎么注入和提取上下文、怎么结束 Span。至于 Span 数据存哪、怎么发出去、采样怎么算,它一概不管。

这套规范里最核心的几个抽象:

  • Tracer:入口,负责创建 Span、提取和注入上下文。
  • Span:一次操作的记录单元,有开始时间、结束时间、tag、log、baggage。
  • SpanContext:跨进程传递的上下文载体,核心是 traceId 和 spanId。
  • TextMap/HttpHeaders:上下文注入和提取的载体抽象,HTTP 场景下就是请求头。

理解这一层的关键在于:OpenTracing 的 API 是“面向切面”设计的。它假设你会在方法入口 start span、方法出口 finish span,中间的业务逻辑完全不感知追踪的存在。这个假设直接决定了后面 Spring AOP 切面的设计方式。

2.2 Jaeger 客户端:把规范落地成可上报的数据

Jaeger 的 Java 客户端(jaeger-client-java)是 OpenTracing 规范的一个具体实现。它做的事情可以概括成四件:

第一,实现 Tracer 接口。JaegerTracer继承自 OpenTracing 的 Tracer,把 start、inject、extract 这些方法都实现了。

第二,管理采样。采样器决定一个 trace 要不要被记录。常见的有ConstSampler(全采或全不采)、ProbabilisticSampler(按概率采)、RateLimitingSampler(限流采样)。采样决策在 trace 的第一个 span 创建时就定了,后续所有 span 跟着走。

第三,组织上报。Span 结束后不是立刻发出去,而是先进入一个内存队列,由后台线程批量发送。这个设计是为了避免上报阻塞业务线程。队列满了怎么办、发送失败了怎么办,都是这一层要处理的。

第四,上下文传播。通过TextMapInject和TextMapExtract把 SpanContext 塞进 HTTP 头或者从 HTTP 头里读出来。Jaeger 默认用的格式是uber-trace-id,值形如traceId:spanId:parentSpanId:flags。

这里有个容易忽略的点:Jaeger 客户端的版本和 OpenTracing API 的版本是有对应关系的。老版本的 jaeger-client 用的是 opentracing-api 0.31 左右,新版本用的是 0.33。版本对不上会出现NoSuchMethodError这种运行时错误,编译期完全看不出来。我在一个老项目上升级时就遇到过,排查了半天才发现是依赖传递带进来的旧版本 API 覆盖了新版本。

2.3 Spring 自动装配:把埋点从手动变成自动

到了 Spring 这一层,目标就一个:让开发者不用手写 start span 和 finish span。实现手段主要是三样:

  • 自动配置类:通过spring.factories或者AutoConfiguration.imports注册,应用启动时自动创建 Tracer Bean、TracerRegistry Bean 等。
  • AOP 切面:拦截带有特定注解的方法(比如@Traced),在方法前后自动开闭 Span。
  • Web 拦截器:拦截 HTTP 请求,从请求头提取上下文,创建入口 Span,响应时结束 Span 并注入上下文。

这三样东西的组合,构成了“自动埋点”的全部。但注意,自动埋点的覆盖范围是有限的。它默认只覆盖 Spring MVC 的 Controller 入口、RestTemplate 的调用出口、以及被@Traced标注的方法。像线程池里的异步任务、MQ 消费、定时任务,默认都不在覆盖范围内。这就是为什么很多团队的链路会在异步环节断掉。

下面这张表可以帮你快速定位问题出在哪一层:

现象可能出问题的层排查方向
完全没有 traceSpring 自动装配检查 Tracer Bean 是否创建、采样器配置
有 trace 但只有入口 spanAOP / 拦截器检查切面是否生效、拦截器是否注册
跨服务调用后链路断上下文传播检查 HTTP 头注入提取、格式是否匹配
异步调用后链路断跨线程传递检查线程池是否包装、Scope 是否传递
span 有但上报延迟大Jaeger 客户端上报检查队列大小、flush 间隔、网络

3. 自动装配的源码链路:从 spring.factories 到 Tracer Bean 的诞生

3.1 入口在哪里:自动配置类的注册机制

Spring Boot 的自动装配入口在META-INF/spring.factories(Spring Boot 2.7 之前)或者META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(2.7 之后)。opentracing-java-spring这个库会在这里注册自己的自动配置类。

打开这个文件,你会看到类似这样的内容:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ io.opentracing.contrib.spring.tracer.configuration.TracerAutoConfiguration

这个TracerAutoConfiguration就是整个自动装配的起点。它上面通常挂着几个条件注解:

@Configuration @ConditionalOnClass(Tracer.class) @ConditionalOnMissingBean(Tracer.class) @EnableConfigurationProperties(TracerProperties.class) public class TracerAutoConfiguration { // ... }

@ConditionalOnClass(Tracer.class)的意思是:只有 classpath 上存在 OpenTracing 的 Tracer 类,这个配置才生效。@ConditionalOnMissingBean(Tracer.class)的意思是:如果用户已经自己定义了一个 Tracer Bean,那自动配置就不插手。这两个条件保证了自动配置的“礼貌性”——不越权、不覆盖用户的自定义。

3.2 Tracer Bean 是怎么被创建出来的

在TracerAutoConfiguration内部,通常会有一个@Bean方法负责创建 Tracer。这个方法做的事情,本质上是把TracerProperties里的配置项翻译成 Jaeger 客户端的构建参数。

配置项大概包括这些:

  • opentracing.jaeger.service-name:服务名,会作为 Jaeger 里的 service 标识。
  • opentracing.jaeger.udp-sender.host和port:上报地址。
  • opentracing.jaeger.sampler.type和param:采样类型和参数。
  • opentracing.jaeger.log-spans:是否打印 span 日志,调试时很有用。

构建过程大致是:先根据 sampler 配置创建 Sampler 对象,再创建 Sender(UDP 或 HTTP),然后组装成JaegerTracer.Builder,最后 build 出 Tracer。

这里有个细节值得注意:Tracer 的创建是有副作用的。Jaeger 客户端在 build 的时候会启动后台上报线程,如果配置了 UDP Sender,还会尝试建立 socket。这意味着如果配置写错了(比如 host 不可达),应用启动时可能不会报错,但运行时会静默丢数据。我建议在开发环境把log-spans打开,至少能看到 span 有没有被创建。

3.3 全局 Tracer 的注册与获取

OpenTracing 有一个GlobalTracer的概念,它是一个静态的 Tracer 持有者。很多第三方库(比如一些 HTTP 客户端、数据库驱动)在埋点时不会去依赖注入 Tracer,而是直接从GlobalTracer.get()拿。

opentracing-java-spring在创建 Tracer Bean 之后,通常会调用GlobalTracer.register(tracer)把它注册成全局的。这一步很关键,因为如果你的应用里有非 Spring 管理的组件需要埋点,它们只能通过 GlobalTracer 拿到 Tracer。

但这里有个坑:GlobalTracer 只能注册一次。如果应用里有多个地方尝试注册,第二次会抛异常或者被忽略。在 Spring Cloud 多模块项目里,如果每个模块都引入了这个自动配置,就可能出现重复注册的问题。解决办法是用@ConditionalOnMissingBean配合一个标志位,或者确保只有一个模块负责创建 Tracer。

3.4 自动装配的生效顺序问题

Spring Boot 的自动配置是有顺序的。TracerAutoConfiguration必须在 Web 相关的自动配置之前生效,否则 Web 拦截器拿不到 Tracer Bean。

这个顺序通过@AutoConfigureBefore或者@AutoConfigureAfter来控制。如果你发现拦截器没生效,可以检查一下自动配置的排序是否正确。在 Spring Boot 2.7 之后,还可以通过AutoConfiguration.imports文件里的顺序来隐式控制,但显式注解更可靠。

我在一个项目里遇到过这样的情况:自定义了一个WebMvcConfigurer来注册拦截器,但拦截器里注入的 Tracer 是 null。排查后发现是自定义配置类的加载顺序早于 TracerAutoConfiguration,导致注入时 Tracer Bean 还没创建。解决办法是在自定义配置类上加@AutoConfigureAfter(TracerAutoConfiguration.class)。

4. 跨线程与跨服务:上下文传播的两个断点高发区

4.1 HTTP 调用中的上下文注入与提取

跨服务传播是链路追踪的核心能力。当一个服务通过 HTTP 调用另一个服务时,调用方需要把当前 SpanContext 注入到请求头里,被调方需要从请求头里提取出来,作为自己 Span 的 parent。

在 Spring 生态里,这个动作通常由RestTemplate的拦截器或者ClientHttpRequestInterceptor来完成。源码里的实现逻辑大致是:

public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) { Tracer.SpanBuilder spanBuilder = tracer.buildSpan("http-call") .withTag("http.url", request.getURI().toString()); // 尝试从当前上下文提取 parent SpanContext parent = tracer.activeSpan() != null ? tracer.activeSpan().context() : null; if (parent != null) { spanBuilder.asChildOf(parent); } Span span = spanBuilder.start(); // 注入上下文到请求头 tracer.inject(span.context(), Format.Builtin.HTTP_HEADERS, new HttpHeadersCarrier(request.getHeaders())); try (Scope scope = tracer.scopeManager().activate(span)) { return execution.execute(request, body); } finally { span.finish(); } }

这段代码里有几个关键点:

第一,parent 的获取依赖 activeSpan。如果当前线程没有 active span,那这个调用就是新 trace 的起点。这就是为什么异步调用后链路会断——异步线程里没有 active span。

第二,注入的格式必须是 HTTP_HEADERS。Jaeger 默认用uber-trace-id头,如果被调方用的是别的格式(比如 B3),就提取不到。

第三,Scope 的 activate 和 close 必须配对。Scope 的作用是把 Span 绑定到当前线程,让后续的activeSpan()能拿到。如果忘了 close,会导致 Span 泄漏,线程复用时会拿到错误的 active span。

4.2 线程池场景下的上下文丢失

这是链路断点最常见的原因,没有之一。Java 的线程池会复用线程,而 OpenTracing 的 active span 是绑定在 ThreadLocal 上的。当你把任务提交到线程池时,任务执行线程和提交线程不是同一个,ThreadLocal 里的 active span 自然就拿不到了。

解决思路有两种:

方案一:手动传递。在提交任务前拿到当前 Span,在任务内部用asChildOf建立父子关系。

Span parent = tracer.activeSpan(); executor.submit(() -> { Span span = tracer.buildSpan("async-task") .asChildOf(parent) .start(); try (Scope scope = tracer.scopeManager().activate(span)) { // 业务逻辑 } finally { span.finish(); } });

方案二:包装线程池。用TracerThreadPoolExecutor或者自己实现一个ExecutorService装饰器,在execute和submit方法里自动完成上下文的捕获和恢复。

public class TracingExecutorService extends AbstractExecutorService { private final ExecutorService delegate; private final Tracer tracer; @Override public void execute(Runnable command) { Span parent = tracer.activeSpan(); delegate.execute(() -> { try (Scope scope = parent != null ? tracer.scopeManager().activate(parent) : null) { command.run(); } }); } // 其他方法委托给 delegate }

方案二更优雅,但要注意:Scope 的 activate 只是把 Span 绑定到线程,并不会自动创建子 Span。如果你需要异步任务有自己的 Span,还得在任务内部手动 buildSpan。

还有一个更隐蔽的坑:CompletableFuture 的默认线程池。如果你用CompletableFuture.supplyAsync()而不传自定义 Executor,它会用 ForkJoinPool.commonPool(),这个池子完全不受你的包装控制。解决办法是显式传入包装过的 Executor。

4.3 MQ 和定时任务场景的上下文处理

MQ 消费和定时任务也是链路断点的高发区。MQ 场景下,生产者在发送消息时要把上下文注入到消息头里,消费者从消息头提取。定时任务则完全没有上游,需要自己作为 trace 的起点。

对于 MQ,关键是消息头的注入和提取。不同 MQ 的消息头结构不一样,需要针对性地实现TextMap接口。比如 Kafka 的Headers、RocketMQ 的MessageProperties,都要做适配。

对于定时任务,建议在任务开始时创建一个 root span,并设置span.kind=server或者span.kind=producer,这样在 Jaeger 面板上能清楚看到这是一个独立的 trace 起点。

5. 采样与上报:数据从产生到落盘的完整路径

5.1 采样决策发生在什么时候

采样决策发生在 trace 的第一个 span 创建时。这个“第一个 span”通常是一个 root span,没有 parent。采样器根据配置的策略决定这个 trace 是否被记录,决策结果会写入 SpanContext 的 flags 字段,随上下文传播到后续所有服务。

Jaeger 支持的采样类型:

采样类型配置值适用场景注意事项
全采const, 1开发调试生产环境慎用,数据量巨大
全不采const, 0关闭追踪用于临时关闭
概率采样probabilistic, 0.01生产环境0.01 表示 1%
限流采样ratelimiting, 10控制总量每秒最多 10 条 trace
远程采样remote动态调整需要 Jaeger 后端支持

概率采样的一个特点是:决策是本地做的,不同服务可能采到不同的 trace。比如服务 A 采样率 1%,服务 B 采样率 10%,那 A 发起的 trace 只有 1% 被记录,B 发起的 trace 有 10% 被记录。这会导致 Jaeger 面板上的数据分布不均匀。解决办法是统一各服务的采样率,或者用远程采样让后端统一决策。

5.2 上报队列与批量发送机制

Span 结束后不是立刻发出去,而是先进入一个内存队列。Jaeger 客户端默认用的队列实现是一个有界阻塞队列,大小可以通过JAEGER_REPORTER_MAX_QUEUE_SIZE配置,默认值我记得是 100。

后台有一个 reporter 线程,定期从队列里取一批 span,通过 Sender 发出去。批量大小和间隔可以配置。UDP Sender 的默认最大包大小是 65000 字节,超过会分片。

这里有几个实操中容易踩的坑:

坑一:队列满了会丢数据。当队列满时,新的 span 会被直接丢弃,而且默认是静默丢弃,不打日志。如果你的服务 QPS 很高,span 产生速度超过上报速度,就会丢数据。解决办法是调大队列、提高上报频率、或者降低采样率。

坑二:应用关闭时队列里的数据会丢。JVM 退出时,如果 reporter 线程还没把队列里的 span 发完,这些数据就没了。解决办法是注册一个 shutdown hook,在应用关闭时调用tracer.close(),它会 flush 队列并等待发送完成。

坑三:UDP 是不可靠的。UDP Sender 发出去就不管了,网络丢包、接收端缓冲区满都会导致数据丢失。如果对数据完整性要求高,应该用 HTTP Sender。但 HTTP Sender 的性能开销更大,需要权衡。

5.3 我在上报环节踩过的两个真实坑

第一个坑是时间戳精度问题。Jaeger 的 span 时间戳是微秒级的,但有些环境的System.currentTimeMillis()精度只有毫秒。这会导致同一毫秒内创建的多个 span 时间戳相同,在 Jaeger 面板上看起来是并行的,实际上有先后关系。解决办法是用System.nanoTime()配合一个基准时间来计算微秒级时间戳,Jaeger 客户端内部就是这么做的。

第二个坑是服务名配置错误导致数据分散。Jaeger 用 service name 来区分不同服务。如果多个服务配了同一个 service name,或者一个服务在不同实例上配了不同的 service name,Jaeger 面板上的数据就会乱。建议把 service name 做成配置项,和 Spring 的spring.application.name保持一致,避免硬编码。

6. 几个值得单独拎出来说的源码设计细节

6.1 ScopeManager 的设计与 ThreadLocal 绑定

ScopeManager是 OpenTracing 里负责管理 Span 和线程绑定关系的组件。默认实现是ThreadLocalScopeManager,内部用一个 ThreadLocal 存储当前线程的 active span。

activate(span)方法会创建一个 Scope,把 span 存到 ThreadLocal 里,并返回 Scope 对象。Scope.close()会把 ThreadLocal 恢复成之前的值(注意是恢复,不是清空)。这个“恢复”的设计支持嵌套:你可以在一个 span 里 activate 另一个 span,close 之后外层 span 重新变成 active。

这个设计很优雅,但有个陷阱:如果 close 没有在 finally 里调用,ThreadLocal 就会泄漏。在线程池场景下,泄漏的 ThreadLocal 会被下一个任务读到,导致链路错乱。所以我在 code review 时,看到activate没有配对的close,一定会打回去。

6.2 Baggage 的传播机制与性能影响

Baggage 是 OpenTracing 里用来跨服务传递业务数据的机制。和 tag 不同,tag 只存在本地,baggage 会随上下文传播到所有下游服务。

Baggage 的实现原理是在 SpanContext 里维护一个 map,注入时把所有 baggage 项都写进 HTTP 头,提取时再读出来。这意味着 baggage 越多,HTTP 头越大,网络开销越高。

我见过有团队用 baggage 传递用户 ID、租户 ID 这类信息,方便下游做日志关联。这个用法本身没问题,但要注意:baggage 会传播到所有下游,包括你不关心的服务。如果链路很长,baggage 的累积开销不可忽视。建议只放真正需要跨服务传递的少量数据。

6.3 与 Spring Cloud Sleuth 的关系和取舍

Spring Cloud 生态里还有一个链路追踪方案是 Sleuth。Sleuth 也支持对接 Jaeger,但它的实现思路和 opentracing-java-spring 不一样。

Sleuth 是 Spring 自己的一套追踪抽象,深度集成在 Spring 的各个组件里(Web、RestTemplate、Feign、Hystrix 等),自动埋点的覆盖范围更广。而 opentracing-java-spring 是基于 OpenTracing 规范的,更通用,但覆盖范围相对窄一些。

怎么选?我的经验是:

  • 如果项目是纯 Spring Cloud 技术栈,且不想自己处理埋点,Sleuth 更省事。
  • 如果项目里有非 Spring 的组件需要统一追踪,或者未来可能换追踪后端,OpenTracing 方案更灵活。
  • 两者也可以共存,Sleuth 负责 Spring 组件的埋点,OpenTracing 负责自定义埋点,但要注意上下文格式的统一。

7. 从源码分析到落地:一份可复用的接入检查清单

把上面这些内容浓缩成一份检查清单,接入或排查时按顺序过一遍,能覆盖大部分问题:

依赖层检查

  • opentracing-api 和 jaeger-client 版本是否匹配
  • 是否有多个版本的 opentracing-api 被传递引入
  • spring-boot-starter 版本和自动配置类是否兼容

配置层检查

  • service name 是否和 spring.application.name 一致
  • 采样器类型和参数是否合理
  • 上报地址和端口是否可达
  • 是否开启了 log-spans 用于调试

埋点层检查

  • Web 拦截器是否注册、顺序是否正确
  • RestTemplate 是否被包装、拦截器是否生效
  • 异步任务是否传递了上下文
  • MQ 生产消费是否注入了上下文
  • 定时任务是否创建了 root span

上报层检查

  • 队列大小是否足够
  • 是否注册了 shutdown hook 做 flush
  • UDP 还是 HTTP,可靠性要求是否匹配
  • 是否有监控能看到丢数据情况

验证方法

  • 开发环境开 log-spans,看 span 是否被创建
  • 用 Jaeger 面板看 trace 是否完整
  • 构造跨服务调用,看上下文是否传播
  • 构造异步调用,看链路是否连续
  • 压测时观察是否有数据丢失

这套东西我前后在三个项目里落地过,每次都会遇到新的边界情况。源码分析的价值不在于背下每个类的实现,而在于当链路断掉时,你能快速判断是三层里的哪一层出了问题,然后直奔对应的源码位置去验证。这种定位能力,比记住任何配置项都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询