本节目标:搭起一条从入口到数据库、下游 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 更轻。
两者的取舍:
| 维度 | Brave | OpenTelemetry |
|---|---|---|
| 生态背景 | Zipkin / Brave,较老 | CNCF 标准,业界趋势 |
| 传播头 | B3(X-B3-TraceId 等) | W3C traceparent(兼容 B3) |
| 后端 | 主要 Zipkin | Jaeger / 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 日志聚合与告警 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。