《Spring Boot 实战》16.2 链路追踪

讲清 Spring Boot 4.x 的链路追踪栈:为什么 Sleuth 已被 Micrometer Tracing 取代,OTel 与 Brave 桥接包怎么选,management.tracing 的采样率与传播格式如何配置,traceId 如何进入日志 MDC,@Observed 与 Observation API 的用法,以及 HTTP 客户端与 Kafka 的上下文传播。

本节目标:搭起一条从入口到数据库、下游 HTTP、消息队列的完整调用链,让每个 span 都带上 traceId,并理解采样率、传播格式与桥接实现的取舍。
适用版本:Spring Boot 4.1.x(Java 21)

16.2 链路追踪

16.1 的指标告诉你「借书接口 P99 涨到了 800ms」,但它回答不了「这 800ms 花在哪一跳」。book-loan 的一次借书要经过:HTTP 入口 → 校验会员 → 查图书库存 → 写借阅单 → 发一条逾期提醒消息。指标只能告诉你整体慢了,要定位到具体环节,需要链路追踪:把一次请求经过的每一跳串成一条带时间戳的调用链。

本节从「为什么 Sleuth 不见了」讲起,再落到依赖选择、配置、代码埋点与上下文传播。

Sleuth 已退出历史舞台

如果你在旧项目里见过 spring-cloud-starter-sleuth 和日志里的 [book-loan,7f3a1c9e2b4d,1a2b3c4d],那是 Spring Cloud Sleuth 的痕迹。Sleuth 已经不再使用:从 Spring Cloud 2022.0(对应 Spring Boot 3.0)起,它被 Micrometer 生态里的 Micrometer Tracing 取代。4.x 的追踪栈是:

应用代码 / 自动埋点
        ↓
Micrometer Observation API   (io.micrometer:micrometer-observation)
        ↓
Micrometer Tracing            (io.micrometer:micrometer-tracing)
        ↓
桥接实现:Brave  或  OpenTelemetry
        ↓
后端:Zipkin / Jaeger / OTLP 收集器

这里有个关键认知:Micrometer Observation 是「指标 + 追踪」的统一门面。同一处埋点既产出指标(16.1)又产出 span(本节),所以业务代码只写一次。Sleuth 时代那种「指标用 Micrometer、追踪用 Sleuth」两套 API 的分裂已经不存在了。

Sleuth 的类名(TraceContext、brave.Tracer 直接注入)在 4.x 里都不要再写,取而代之的是 io.micrometer.tracing.Tracer 与 io.micrometer.observation.ObservationRegistry。

依赖与桥接选择

Micrometer Tracing 本身只是 API,必须选一个桥接实现。两条路:

<!-- 方案 A:OpenTelemetry 桥接 + OTLP 导出 -->
<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
  <groupId>io.opentelemetry</groupId>
  <artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>

<!-- 方案 B:Brave 桥接 + Zipkin 导出 -->
<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
  <groupId>io.zipkin.reporter2</groupId>
  <artifactId>zipkin-reporter-brave</artifactId>
</dependency>

注意 artifact 的 groupId 是 io.micrometer(不是 org.springframework),坐标分别是 micrometer-tracing-bridge-otel 与 micrometer-tracing-bridge-brave。这两个包不能同时引——同时存在时自动配置会因桥接实现冲突而失败。

4.0 还新增了一个 spring-boot-starter-opentelemetry,它会连带自动配置 OpenTelemetry SDK(含 SdkTracerProvider、SdkLoggerProvider、SdkMeterProvider),适合「直接走 OTel 全家桶」的场景。若只需要追踪、指标仍走 Prometheus,用上面的方案 A 更轻。

两者的取舍:

维度BraveOpenTelemetry
生态背景Zipkin / Brave,较老CNCF 标准,业界趋势
传播头B3(X-B3-TraceId 等)W3C traceparent(兼容 B3)
后端主要 ZipkinJaeger / Tempo / 任意 OTLP
指标打通与 Micrometer 各管各OTLP 可统一 traces/metrics/logs
迁移成本已有 Zipkin 的团队低新项目推荐

新项目选 OTel:它是可观测性三大信号(trace/metric/log)的事实标准,后端选择面更广;已有 Zipkin 存量、不想动收集器的团队可继续用 Brave。两者对应用代码的影响几乎为零,因为埋点都走 Micrometer Observation API——这也是「用统一门面」的价值。

management.tracing.* 配置

追踪的行为由一组 management.tracing.* 属性控制(均已在 4.1.1 的自动配置元数据中核实):

management:
  tracing:
    enabled: true                       # 总开关
    sampling:
      probability: 0.1                  # 默认 0.1,即采 10%
    propagation:
      type: [w3c]                       # 本服务主动产出时用 W3C
      consume: [w3c, b3, b3_multi]      # 接收时兼容多种
    export:
      enabled: true

逐条解释:

  • management.tracing.sampling.probability 默认 0.1(10%)。生产上 10% 是常见起点;本地开发设成 1.0 看全量。
  • management.tracing.propagation.type 指定产出的传播格式,默认 w3c;consume 默认接受 w3c、b3、b3_multi,这样能兼容上游还在用 B3 头的旧服务。
  • management.tracing.enabled 是总开关;management.tracing.export.enabled 控制是否上报到后端(本地调试可只记日志不上报)。
  • 4.1 新增了 management.opentelemetry.tracing.sampler 用于配置 OTel 的 sampler,以及 management.opentelemetry.tracing.limits.* 配置 SpanLimits(属性数上限等),走 OTel 桥接时可进一步细化。

traceId 的生成与传播由入口决定:采样决策在链路的第一跳做出,后续所有 span 继承这个决定。这带来一个后果——采样是「全链路一致」的,被采样到的请求整条链都有数据,没被采到的整条链都没有。所以采样率调低会同步降低所有环节的数据量,而不是随机丢掉中间的某一跳。

traceId/spanId 进入日志 MDC

光有追踪后端还不够。排障时最顺手的路径是:从日志里看到一条报错,拿它的 traceId 直接跳到追踪系统看完整链路。这要求日志里带上 traceId。

Micrometer Tracing 会自动把当前 span 的 traceId 与 spanId 写进 SLF4J 的 MDC,键名就是 traceId 和 spanId(Boot 的 CorrelationIdFormatter 默认按 traceId(32),spanId(16) 解析)。Boot 的默认日志 pattern 里预留了相关占位,由 logging.pattern.correlation 控制,默认形态是 [应用名,traceId,spanId]。要自定义:

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

效果是每行日志前缀多出一段:

2026-10-05T14:02:11.204+08:00  INFO 51230 --- [book-loan,7f3a1c9e2b4d5e6f,1a2b3c4d5e6f] [nio-8080-exec-3] c.e.loan.LoanService : 借阅单创建成功 loanId=90211

注意 %X{traceId:-} 里的 :-:它表示「MDC 里没有这个键时输出空」,避免在追踪未生效的线程(如某些定时任务)里打出 %X{traceId} 字面量。只在主线程打日志、异步线程漏掉 traceId 是常见现象,因为 MDC 是基于 ThreadLocal 的,跨线程不会自动传递——这正是 4.1 新增 @Async 上下文传播要解决的问题。

@Observed 与 Observation API

自动埋点覆盖了 HTTP、数据库、缓存这些框架边界,但业务内部的关键步骤需要手动埋点。最省事的方式是 @Observed 注解:

package com.example.loan;

import io.micrometer.observation.annotation.Observed;
import org.springframework.stereotype.Service;

@Service
public class LoanService {

    @Observed(name = "bookloan.loan.create",
              contextualName = "create-loan",
              lowCardinalityKeyValues = {"branch", "main"})
    public Long createLoan(long memberId, long bookId) {
        // 这段方法的执行时长会被记为一个 span,同时产出同名指标
        return doCreate(memberId, bookId);
    }

    private Long doCreate(long memberId, long bookId) {
        return 90211L;
    }
}

@Observed 由 ObservedAspect 驱动,需要两个前提:引入 AOP 支持(spring-boot-starter-aspectj),并且 management.observations.annotations.enabled=true(4.x 默认开启)。少了任一个,注解静默失效——方法照常执行,就是没有 span。

注解之外,也可以在代码里显式用 Observation API,适合需要动态记录高基数信息的场景,直接操作 ObservationRegistry:

package com.example.loan;

import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.stereotype.Service;

@Service
public class InventoryService {

    private final ObservationRegistry registry;

    public InventoryService(ObservationRegistry registry) {
        this.registry = registry;
    }

    public boolean reserve(long bookId) {
        Observation observation = Observation.createNotStarted("bookloan.inventory.reserve", registry)
            .lowCardinalityKeyValue("warehouse", "main")
            .highCardinalityKeyValue("bookId", String.valueOf(bookId));   // 高基数进 span,不进指标
        return observation.observe(() -> doReserve(bookId));
    }

    private boolean doReserve(long bookId) {
        return true;
    }
}

低基数与高基数的区分是重点:lowCardinalityKeyValue 的值会同时进入指标标签(受 16.1 讲的基数约束,必须是有限枚举);highCardinalityKeyValue 只进 span 属性,可以放 bookId 这种唯一值,因为它不会变成指标序列。放错地方会导致指标基数爆炸。

HTTP 客户端与 Kafka 的上下文传播

「上下文传播」指把当前 span 的信息(traceId、spanId、采样标志)塞进跨进程调用的载体,让下游服务接着同一条链。Micrometer Tracing 对常见出站组件做了自动埋点:

组件传播方式是否自动
RestClient / RestTemplate注入 traceparent(或 B3)请求头是,Boot 自动配置
WebClient同上,Reactor 上下文传播是
Kafka KafkaTemplate写入消息 header是
Kafka @KafkaListener从消息 header 恢复上下文是
@Async 方法线程池装饰器传递上下文是,4.1 新增

HTTP 客户端一侧无需写代码:只要引入了桥接包,自动配置的 RestClient 就会挂上 observation,出站请求自动带传播头,下游服务用同样的配置就能接上。Kafka 一侧同理,KafkaTemplate 与 @KafkaListener 会自动注入与提取 header——消息队列的埋点方式见 10.2 消息队列 。

4.1 补上了 @Async 的上下文传播:以前 @Async 方法在新线程里跑,MDC 与当前 span 都会丢,日志里 traceId 为空、追踪链断掉。现在 Boot 会自动给异步执行器加装饰,把上下文传过去。但仅限于 Boot 自动配置的 TaskExecutor;如果你自己 new ThreadPoolExecutor(...),仍要手动传递。

一个容易忽略的点:跨进程传播依赖 header 透传。如果中间有网关、Nginx、或某个自研客户端在转发时丢掉了 traceparent / X-B3-* 头,链路就会在下游断开。排查「链路不完整」时,先确认中间件有没有保留这些 header。

采样率设置

采样率是成本与可观测性的直接权衡:

场景建议采样率理由
本地开发 / 联调1.0(100%)每条请求都要能看到
预发环境1.0 或 0.5流量小,全采成本可接受
生产常规0.1 或更低存储与带宽成本随采样率线性增长
排障期间临时调高通过配置中心动态调整(见 3.3)

三点认知:

  • 采样是头部采样(head-based):入口决定采不采,之后全链路一致。它实现简单、无额外延迟,但无法「事后决定」——一个请求在入口时并不知道它稍后会不会出错。
  • 指标不受采样影响。16.1 的 Counter / Timer 是全量统计的,所以「错误率、P99」这类聚合告警应该基于指标;追踪只用于「看具体某条慢请求的细节」。这就是为什么告警不能用 trace 数据。
  • 尾部采样(tail-based sampling) 需要 OTel Collector 这类组件:先把所有 span 收上来,再按「是否有错误 / 是否超时」决定保留哪些。它能保住所有错误链路,代价是收集侧要承受全量数据。流量大且对错误可观测性要求高时值得上。

Brave 与 OTel 的取舍

回到选型,给一张决策表:

你的情况建议
新项目、后端未定OTel(bridge-otel + OTLP)
已有 Zipkin 集群Brave(bridge-brave + zipkin-reporter-brave)
想统一 trace/metric/log 到一个后端OTel(4.0 的 spring-boot-starter-opentelemetry)
只想要追踪、指标继续用 Prometheus任选,二者对 Micrometer 指标无影响
团队熟悉 Brave API、有历史埋点Brave,迁移收益不明显

无论选哪个,应用层代码不变:埋点走 @Observed / Observation,注入的是 io.micrometer.tracing.Tracer。切换桥接只需要换依赖,不动业务代码。这也是本节反复强调「统一门面」的原因。

与 16.3 的日志关联方式

追踪与日志的接合点就是 traceId:

  • 日志 → 追踪:在日志系统里搜一条错误日志,取它的 traceId,去追踪后端查同 ID 的完整链路。
  • 追踪 → 日志:在追踪 UI 看到某条慢 span,取 traceId,回日志系统按 traceId 过滤出这条请求打过的所有日志。

要做到「按 traceId 过滤」,前提是日志是结构化的、traceId 是一个可查询字段——这正是 16.3 要讲的。行内 pattern 把 traceId 拼进字符串虽然肉眼可见,但机器不好按字段筛;结构化日志把 traceId 作为独立字段,才能在日志平台里精确检索。两节合起来才是闭环。

常见坑

  • 同时引两个桥接包:bridge-brave 与 bridge-otel 共存会让自动配置失败。
  • 写了 @Observed 却没有 AOP 依赖:注解静默失效,没有报错也没有 span。
  • 异步线程丢上下文:自建线程池不会自动传播,日志里 traceId 为空。
  • 高基数键值放进了 lowCardinalityKeyValue:指标序列爆炸,重演 16.1 的基数事故。
  • 中间件丢传播头:链路在下游断开,排查时先看网关是否保留 traceparent / B3 头。
  • 生产把采样率设成 1.0:追踪存储与带宽成本飙升,通常没必要。
  • 指望 trace 做告警:采样数据不完整,聚合统计不可靠,告警必须基于指标。

小结

4.x 的追踪栈是「Micrometer Observation + Micrometer Tracing + 桥接实现」,Sleuth 已退役。桥接选 OTel(新项目、多信号统一)或 Brave(存量 Zipkin);坐标是 io.micrometer:micrometer-tracing-bridge-otel 或 -brave,二者不可共存。行为由 management.tracing.* 控制,采样率默认 0.1、传播默认产出 W3C、兼容消费 B3。traceId/spanId 自动进 MDC,用 logging.pattern.correlation 打进日志;@Observed 与 Observation API 负责业务埋点,注意低基数与高基数的分流。HTTP 客户端与 Kafka 自动传播,4.1 起 @Async 也支持。采样是头部、全链路一致,且指标不受采样影响——所以告警基于指标,追踪用于下钻。

阅读导航:上一节:16.1 Actuator 与指标 · 下一节:16.3 日志聚合与告警 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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