分布式追踪核心概念: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 在箭头间传递。

分布式追踪核心概念关系图 Trace · Span · SpanContext 工作关系 Trace ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890 服务 A Span ID: A001 ParentSpanID: null SpanContext: TraceID, A001 传递 SpanContext 服务 B Span ID: B001 ParentSpanID: A001 SpanContext: TraceID, B001 继续传递 服务 C Span ID: C001 ParentSpanID: B001 返回响应(同 TraceID) 核心要点 Trace = 所有 Span 集合 · Span = 单个操作 · SpanContext = 跨服务传播的上下文 通过 TraceID 串联,SpanID 定位,ParentSpanID 构建树结构

一个完整的代码示例

光看概念有点虚。我来写个极简的示例,演示 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 管传递。把它们搞懂,你后面学采样、存储、分析就好办了。