本节目标:讲清 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 | 类 | 触发时机与作用 |
|---|---|---|
DefaultTracingObservationHandler | io.micrometer.tracing.handler | onStart 建 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-brave | micrometer-tracing-bridge-otel |
|---|---|---|
| 原生后端 | Zipkin(Brave 出身) | OTLP(OpenTelemetry 出身) |
| 传播格式 | PropagationType:AWS / B3 / W3C / CUSTOM(本机核实) | BaggageTextMapPropagator 等 OTel propagator |
| 采样器类 | ProbabilityBasedSampler / RateLimitingSampler | OTel 的 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 traceparent | B3 |
|---|---|---|
| 头数量 | 单头(+ 可选 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 生产问题诊断手段 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。