《Spring Boot 高级》10.2 分布式追踪实现

从 Tracer/Span 门面为什么存在讲起,用本机 jar 核实的签名拆解一次 span 的生命周期,说明 ObservationHandler 链如何让一次观察同时产出指标与 span,对比 Brave 与 OpenTelemetry 两种桥接、MDC 日志关联、概率与限速采样的组合,以及 W3C traceparent 与 B3 的报文差异。

本节目标:讲清 Micrometer Tracing 的门面为什么长成 Tracer / Span,一次 span 从 nextSpan() 到 end() 经过了什么,Observation 的 handler 链怎么把同一次观察同时喂给指标与追踪,以及采样与传播格式的选择如何决定「链路断不断」。
适用版本:Spring Boot 4.1.x(Java 21)

10.2 分布式追踪实现

延续借阅服务:LoanService.borrow() 通过 HTTP 调用 NotificationService.notifyReader()。我们想看到一条跨两个服务的链路,并且让两个服务的日志能用同一个 traceId 串起来。本节用本机 micrometer-tracing-1.7.1.jar、micrometer-tracing-bridge-brave-1.7.1.jar、micrometer-tracing-bridge-otel-1.7.1.jar 核实所有类名与签名。

10.2.1 门面设计:为什么是 Tracer / Span 而不是 Brave / OTel

io.micrometer.tracing.Tracer 与 io.micrometer.tracing.Span 是一层门面(facade)。业务代码、Spring 的观测 handler 只依赖 micrometer-tracing-api 里的这两个接口,具体用 Zipkin(Brave)还是 OTLP(OpenTelemetry)由运行时 classpath 上的桥接实现决定。

这样设计的直接收益:换后端只换一个依赖,不碰业务代码。micrometer-tracing-bridge-brave 里的实现类是 io.micrometer.tracing.brave.bridge.BraveTracer,micrometer-tracing-bridge-otel 里是 io.micrometer.tracing.otel.bridge.OtelTracer——两者都 implements io.micrometer.tracing.Tracer(本机核实)。框架只认接口。

门面的代价是功能取交集。Brave 与 OTel 的能力并不相同(比如 OTel 有 SpanLimits、Brave 有 span joining),门面只能暴露两者都有的部分,桥接实现独有的能力要么通过配置暴露,要么你得拿到桥接实现类自己强转。

10.2.2 门面方法:一次 span 的生命周期

Tracer 的公开方法(本机 javap 核实):

Span         nextSpan();                 // 造一个子 span(继承当前上下文)
Span         nextSpan(Span parent);      // 显式指定父 span
Tracer.SpanInScope withSpan(Span span);  // 把 span 设为当前,try-with-resources 用
ScopedSpan   startScopedSpan(String name);
Span.Builder spanBuilder();              // 更细的构造(指定 parent / kind / 起始时间)
TraceContext.Builder traceContextBuilder();
CurrentTraceContext currentTraceContext();
SpanCustomizer currentSpanCustomizer();
Span         currentSpan();              // 取当前上下文里的 span

Tracer 还 extends io.micrometer.tracing.BaggageManager(本机核实),因此 baggage 的读写也从同一个门面走:createBaggage(String)、createBaggageInScope(String, String)、getAllBaggage()、getBaggageFields()。baggage 是随链路传播的自定义键值(如 tenant),和 span 标签不同,它会跟着请求一路传下去。

Span 的关键方法(本机核实):

方法作用
start()标记起点(nextSpan() 之后需显式 start 或由 builder 处理)
name(String)改 span 名(HTTP 场景常被改成「方法 + 路由」)
tag(String, String)打标签,只进 span,不进指标
event(String)打一个时间点事件(不产生子 span)
error(Throwable)记录异常,会置 span 状态为 error
end() / end(long, TimeUnit)结束 span,必须成对
abandon()放弃 span(不导出,用于「这条不该记」)
remoteServiceName(String)标注远端服务名(跨进程 span 才有意义)
context()拿到 TraceContext

TraceContext 只有四个方法:traceId()、parentId()、spanId()、sampled()(返回 Boolean,可空表示「未决定」)。这四个值就是一条链路的最小身份证,也是日志关联要用的东西。

一个容易出错的点:nextSpan() 只造对象,不自动设为当前。要让子调用继承上下文,得用 try (Tracer.SpanInScope ws = tracer.withSpan(span)) { ... } 或 ScopedSpan。忘了这一步,子 span 会挂到错误的父上,甚至成为新的根。

10.2.3 Observation 如何同时产出指标与 span

10.1 说 Observation 停下会触发一串 handler。追踪侧就是这些 handler(本机核实):

Handler类触发时机与作用
DefaultTracingObservationHandlerio.micrometer.tracing.handleronStart 建 span,onStop 结束 span
PropagatingReceiverTracingObservationHandler同包服务端:extract 上游 traceparent,接上父 span
PropagatingSenderTracingObservationHandler同包客户端:inject 当前上下文到出站请求头
TracingAwareMeterObservationHandler同包包装 MeterObservationHandler,给指标样本补上 traceId(exemplar)

它们都实现 io.micrometer.tracing.handler.TracingObservationHandler<T>,而该接口又 extends io.micrometer.observation.ObservationHandler<T>(本机核实)。这就是「一次观察同时进指标与追踪」的接缝:指标侧挂 DefaultMeterObservationHandler,追踪侧挂 DefaultTracingObservationHandler,同一个 Observation 的生命周期事件被两边各处理一次。

传播由 io.micrometer.tracing.propagation.Propagator 负责(本机核实):

List<String>        fields();                                    // 该格式用到哪些头
<C> void            inject(TraceContext, C carrier, Setter<C>);  // 出站:写头
<C> Span.Builder    extract(C carrier, Getter<C>);               // 入站:读头、还原父

PropagatingReceiverTracingObservationHandler 与 PropagatingSenderTracingObservationHandler 分别持有它,在 onStart 里做 extract / inject。所以「链路在服务之间断掉」通常就两类原因:出站没 inject(客户端 handler 没挂上),或入站没 extract(服务端 handler 没挂上)。

10.2.4 桥接实现的取舍:Brave 与 OpenTelemetry

两条桥接线的差异可以按「协议原生度」和「配置面」来选:

维度micrometer-tracing-bridge-bravemicrometer-tracing-bridge-otel
原生后端Zipkin(Brave 出身)OTLP(OpenTelemetry 出身)
传播格式PropagationType:AWS / B3 / W3C / CUSTOM(本机核实)BaggageTextMapPropagator 等 OTel propagator
采样器类ProbabilityBasedSampler / RateLimitingSamplerOTel 的 sampler(4.1 可用 management.opentelemetry.tracing.sampler 配)
日志关联Brave 的 brave.context.slf4j.MDCScopeDecorator(本机核实存在于 brave-context-slf4j)io.micrometer.tracing.otel.bridge.Slf4JBaggageEventListener
Spring Boot 支持老牌,配置项稳定4.0 起有 spring-boot-starter-opentelemetry,4.1 补了环境变量与 limits

选择的经验法则:后端是 Zipkin 或已有 Brave 生态,用 Brave 桥;目标是 OTLP/OpenTelemetry Collector、想统一 metrics/logs/traces 三件套,用 OTel 桥。 4.1 起 OTel 线明显加强(management.opentelemetry.enabled 可整体关掉 SDK、management.opentelemetry.tracing.limits.* 配 SpanLimits),新项目倾向 OTel 是合理的。

注意 PropagationType 枚举里除了 W3C 和 B3 还有 AWS 和 CUSTOM——AWS 是 X-Ray 的 X-Amzn-Trace-Id,跨云混合部署时会用到。

10.2.5 traceId / spanId 注入日志(MDC)

日志里能带上 traceId 靠的是 MDC:桥接实现把当前 TraceContext 的 traceId / spanId 写进 slf4j 的 MDC(Brave 走 MDCScopeDecorator,OTel 走 Slf4JBaggageEventListener,本机核实两个类都存在),日志模板再用 %X{traceId} 引用。

Spring Boot 4.1 把这段从「写死在默认 pattern 里」抽成了一个可配置项。本机 spring-boot-4.1.1.jar 的 org/springframework/boot/logging/logback/defaults.xml 里,默认 pattern 用的是:

${LOG_CORRELATION_PATTERN:-}

对应的配置属性是 logging.pattern.correlation(本机 LoggingSystemProperty.CORRELATION_PATTERN 枚举核实,环境变量名 LOG_CORRELATION_PATTERN,应用属性名 logging.pattern.correlation),转换器是 org.springframework.boot.logging.logback.CorrelationIdConverter(本机核实)。要自定义关联段,覆盖这个属性即可:

logging:
  pattern:
    correlation: "[${spring.application.name:},%X{traceId:-},%X{spanId:-}]"

这样每行日志都会带上服务名与 traceId,两个服务的日志就能按 traceId 串起来。前提是采样命中了——未采样的请求,MDC 里通常没有 traceId,日志也就串不起来,这正是下一节要讲的采样。

10.2.6 采样策略与组合

采样决定「这条链路记不记」。management.tracing.sampling.probability 控制根 span 的采样概率,默认 0.1(官方 3.0 配置变更记录明确写出)。Brave 桥里对应的实现是 ProbabilityBasedSampler,另一条路是 RateLimitingSampler(本机核实两者都存在):

策略类 / 配置语义适用
概率采样ProbabilityBasedSampler / management.tracing.sampling.probability=0.1按固定概率抽流量平稳、要可预测的采样率
限速采样RateLimitingSampler每秒最多 N 条流量波动大、要卡住后端写入量
父级决定由上游 traceparent 的 sampled 位继承跟随父 span 决定跨服务时保证链路完整

关键组合原则:采样决策在根 span 做一次,然后沿着链路传播下去。 子 span 不应各采各的——否则一条链路会缺几段,变成断链。TraceContext.sampled() 返回的 Boolean 可空,正是「父级没给决定,需要本地决定」的表示。

在「父级决定 + 概率」的组合里,入站请求如果带 sampled=1,本服务必须跟着采;只有真正没有父上下文(链路起点)时才用 probability 抽。这也是为什么跨服务断链时,第一件要查的是「采样位有没有正确传播」,而不是「采样率是不是调低了」。

10.2.7 W3C traceparent 与 B3 的报文差异

management.tracing.propagation.type 决定用哪套头,默认 W3C(官方配置记录明确写出)。两套格式的差别:

维度W3C traceparentB3
头数量单头(+ 可选 tracestate)多头 X-B3-* 或单头 b3
报文00-<32位hex traceId>-<16位hex spanId>-<2位hex flags>traceId / spanId / sampled / parentSpanId 各一个头
traceId 长度16 字节(32 hex)16 或 8 字节
采样位flags 最低位X-B3-Sampled: 1/0
生态OpenTelemetry / W3C 标准Zipkin / Brave 原生

traceparent 的报文示例(示例输出,非本机实测):

# 示例输出:W3C traceparent
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
tracestate: vendor=value
# 示例输出:B3 多头形式
X-B3-TraceId: 4bf92f3577b34da6a3ce929d0e0e4736
X-B3-SpanId:  00f067aa0ba902b7
X-B3-Sampled: 1

跨系统联调时的选型:新系统用 W3C(它是 OpenTelemetry 的默认,也是 W3C 标准);只有要接入只认 B3 的老 Zipkin 链路时才切 B3。切换传播格式会让新旧两段链路互相接不上,所以切换窗口要选在链路可以断开的时间点。

由于本机没有 Jaeger / Zipkin 后端,下面的界面描述与查询结果是示例输出:

# 示例输出(本机未部署 Zipkin/Jaeger,非实测)
Trace: 4bf92f3577b34da6a3ce929d0e0e4736
└─ loan.borrow (http server, 12ms)
   └─ notification.notifyReader (http client, 8ms)
      └─ notification.send (http server, 6ms)

10.2.8 手动埋点的正确姿势

自动配置覆盖了 HTTP、JDBC、消息这些「标准边界」,但业务内部的关键步骤(比如「扣减库存」「生成借阅单」)需要手动埋点。两种写法,各有适用:

@Service
class LoanService {
    private final Tracer tracer;

    LoanService(Tracer tracer) { this.tracer = tracer; }

    // 写法一:显式门面,控制最细
    public Loan borrow(String bookId) {
        Span span = tracer.nextSpan().name("loan.create").start();
        try (Tracer.SpanInScope ws = tracer.withSpan(span)) {
            span.tag("book.id", bookId);
            Loan loan = doBorrow(bookId);
            span.event("loan.persisted");
            return loan;
        } catch (RuntimeException ex) {
            span.error(ex);          // 记录异常并置 error 状态
            throw ex;
        } finally {
            span.end();              // 必须成对,否则 span 泄漏
        }
    }
}
// 写法二:@Observed,一行搞定指标 + span
@Observed(name = "loan.create", contextualName = "loan.create")
public Loan borrow(String bookId) { ... }

选择原则:优先 @Observed(写法二),它同时产出指标与 span,且不用手写 try-finally;只有需要精细控制(比如给 span 打多个 tag、记录多个 event、或在非 Spring 管理的对象里埋点)时才用写法一。写法一最大的坑是 span.end() 漏调用——span 不结束就不会导出,链路里会出现「只有父、没有子」的空洞。

remoteServiceName(String) 与 remoteIpAndPort(String, int) 是跨进程 span 专用的标注(本机核实),用来告诉后端「这个 span 代表一次对外调用」。业务内部 span 不需要它们。

10.2.9 采样配置的一个完整例子

把「概率 + 父级决定」写进配置,并给 baggage 划定要关联进日志的字段:

management:
  tracing:
    export:
      enabled: true                 # 4.0 起由 management.tracing.enabled 改名而来
      otlp:
        endpoint: http://otel-collector:4318
    propagation:
      type: W3C                     # 默认即 W3C,显式写出便于审阅
    sampling:
      probability: 0.1              # 根 span 采样率,默认 0.1
    baggage:
      correlation:
        enabled: true               # baggage 与日志上下文关联
        fields: tenant              # 只把 tenant 关联进 MDC
      remote-fields: tenant         # tenant 随出站请求传播

两个容易混淆的属性:management.tracing.export.enabled(4.0 起替代 management.tracing.enabled,控制是否导出)与 management.tracing.sampling.probability(控制采样率)是两件事——关掉导出不会改变采样决策,调低采样率也不会让后端收到更少(因为根本没导出的 span 与采样的 span 是两回事)。生产排障时先确认导出开着,再看采样率。

10.2.10 知道之后能做什么

验证 extract / inject 的成对性。 在 PropagatingSenderTracingObservationHandler.onStart 与 PropagatingReceiverTracingObservationHandler.onStart 上下断点,跑一次跨服务调用,看 traceparent 头在出站被写、入站被读——链路断掉时能立刻定位是哪一侧没触发。

确认采样位传播。 故意把上游 traceparent 的 flags 设成 00(未采样),观察下游是否也放弃采样;再设 01,确认下游跟着采。这能验证「父级决定优先于本地概率」。

检查日志关联。 覆盖 logging.pattern.correlation 加上 %X{traceId:-},用 curl 打一个会被采样的请求,确认日志行里出现非空的 traceId;未采样请求则应看到占位符 -。

给 baggage 划边界。 用 Tracer.createBaggage("tenant") 传租户号,观察它随链路传播;但不要把高基数、敏感值放进 baggage,它会进每一个出站请求头。

小结

  • Tracer / Span 是门面,业务只依赖 micrometer-tracing-api,换后端只换桥接依赖。
  • nextSpan() 只造对象,必须用 withSpan / ScopedSpan 设为当前,子 span 才会挂对父。
  • Observation 的 handler 链让指标 handler 与追踪 handler 各处理一次同一生命周期,传播由 Propagator 的 extract / inject 完成。
  • Brave 与 OTel 桥各有原生后端;4.1 明显加强了 OTel 线(management.opentelemetry.*)。
  • 日志关联靠 MDC + logging.pattern.correlation,未采样时串不起来。
  • 采样默认 0.1,决策在根做、沿链路传播;跨服务用 W3C 还是 B3 要一致,否则断链。

下一节离开应用内视角,讲应用「卡住 / 内存涨 / 死锁」时,怎么用 JDK 自带的 JFR、jcmd、jstack、堆转储把问题钉在具体一行代码上。

阅读导航:上一节:10.1 Micrometer 指标模型 · 下一节:10.3 生产问题诊断手段 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计