分布式追踪核心概念:Trace、Span 与 SpanContext
好,咱们开始聊点实在的。你想想,一个请求过来,经过十几个甚至几十个微服务,它到底经历了什么?哪个环节慢?哪一步挂了?
分布式追踪就是解决这个问题的。说白了,它把一次请求在所有服务里的“旅程”串起来。我最早接触这东西,是在公司第一次遇到微服务故障排查时。几百个服务日志,挨个翻,崩溃了。后来才明白,没有追踪系统,你就像在黑夜里找一根针。
Trace:一次请求的完整旅程
Trace 是什么?你可以把它理解成一次请求的“完整档案”。这个请求从客户端发起,到网关,到业务服务A,再调服务B、服务C……直到最终返回结果。整个过程,就是一个 Trace。
- 唯一标识:每个 Trace 都有一个全局唯一的 ID,叫 TraceID。
- 跨服务:一个 Trace 一定穿过多个服务节点。
- 树形结构:你仔细想想,一个请求发散出去,再汇聚回来,它就是一棵倒着的树。
我个人习惯:遇到性能问题,先看 Trace 有没有断。如果 Trace 在某一步突然消失,那大概率是那个节点挂了或者丢数据了。
Span:追踪的最小工作单元
Span 是构成 Trace 的零件。每一个 Span 代表一个“操作”,比如一个 RPC 调用、一次数据库查询、一段代码逻辑。
- SpanID:每个 Span 有自己的 ID。
- ParentSpanID:指向它的父 Span。没有父 Span 的,就是根 Span。
- 时间维:记录开始时间、结束时间、耗时。
举个例子。你点了个“查询订单”。这个请求走了一个 Trace。里面包含好几个 Span:
——Span A: 网关接收请求(根 Span)
——Span B: 查询用户信息(子 Span)
——Span C: 查询订单列表(子 Span)
——Span D: 查询商品详情(C 的子 Span)
| 概念 | 作用 | 关键字段 |
|---|---|---|
| Trace | 标识一次完整的请求流程 | TraceID |
| Span | 标识请求中的一个操作 | SpanID, ParentSpanID, OperationName |
| SpanContext | 传播追踪上下文 | TraceID, SpanID, Baggage |
避坑指南:我曾经遇到过一个问题,某个 Span 的 ParentSpanID 指向了自己。你想想,那会怎么样?循环引用,整个 Trace 树直接炸了。排查了一天,最后发现是 SDK 版本 bug。所以,检查 Span 的父子关系,是排查的第一道坎。
SpanContext:上下文传播的“信使”
SpanContext 是连接每个 Span 的“信使”。它负责在服务间传递 TraceID、SpanID 等关键信息。
- 跨进程传播:你从服务A调服务B,怎么让B知道这属于同一个Trace?靠 SpanContext 通过 HTTP Header 或 RPC 元数据传递。
- Baggage:SpanContext 里还能带一些“行李”,比如用户ID、租户ID。这些数据会跟着请求走遍所有服务。但切记,别塞太多东西,不然性能会受影响。
我建议:SpanContext 里的 Baggage 只放最核心的业务上下文。比如用户 ID、请求来源。放太多日志级别、临时变量,反而让链路变重,不好排查。
三者之间的关系
咱们来捋一捋。一个 Trace 由多个 Span 组成。每个 Span 有它的 SpanContext。SpanContext 负责“传话”,把 TraceID 和 SpanID 从一个服务带到下一个服务。就这样,所有 Span 串起来了。
你可以把 Trace 想象成一部电影。Span 就是每一帧画面。SpanContext 是胶卷,把画面连在一起。没有胶卷,你看到的只是一堆散乱的图片。
注意:SpanContext 的传播,必须靠基础设施支持。如果你的 RPC 框架(如 gRPC、Dubbo)没有自动注入,你需要手动在代码里传递。很多同学忽略了这一步,导致 Trace 断链。嗯,这里要特别小心。
核心流程框架图
为了更直观,我画了一张图。它展示了一次请求中,Trace、Span 和 SpanContext 是怎么协同工作的。你可以看到 Trace 贯穿所有服务,Span 分割每个操作,SpanContext 在箭头间传递。
一个完整的代码示例
光看概念有点虚。我来写个极简的示例,演示 SpanContext 如何跨服务传递。假设你用 Java 和 OpenTelemetry SDK。
// 服务 A:创建 Span 并调用下游
Span spanA = tracer.spanBuilder("query-user")
.setParent(Context.current())
.startSpan();
try (Scope scope = spanA.makeCurrent()) {
// 携带 SpanContext 发起 HTTP 请求到服务 B
HttpURLConnection conn = (HttpURLConnection) new URL("http://service-b/api").openConnection();
// 注入 SpanContext 到请求头
TextMapPropagator propagator = OpenTelemetry.getPropagators().getTextMapPropagator();
propagator.inject(Context.current(), conn, (carrier, key, value) -> {
((HttpURLConnection) carrier).setRequestProperty(key, value);
});
conn.connect();
// ... 处理响应
} finally {
spanA.end();
}
// 服务 B:提取 SpanContext 并创建子 Span
// 从请求头中提取
TextMapPropagator propagator = OpenTelemetry.getPropagators().getTextMapPropagator();
Context extractedContext = propagator.extract(Context.current(), request, (carrier, key) -> {
return ((HttpServletRequest) carrier).getHeader(key);
});
Span spanB = tracer.spanBuilder("inner-process")
.setParent(extractedContext)
.startSpan();
// ... 后续操作
你看,关键就是 propagator.inject 和 propagator.extract。没有这两步,服务B就不知道这个请求属于哪个 Trace。很多线上问题,最后都发现是注入/提取环节配置错了。
我个人的小技巧:排查 Trace 断链时,先看请求头里有没有 traceparent、tracestate 这些字段。没有?那肯定是注入丢了。有?再对比一下 SpanID 是否一致。大部分问题,光靠抓包工具看 Header 就能定位。
好了,这一章的内容就是这些。记住这三个核心概念:Trace 管结构,Span 管细节,SpanContext 管传递。把它们搞懂,你后面学采样、存储、分析就好办了。