分布式链路追踪核心概念:Trace、Span、SpanContext 的定义与关系

咱们开始干真正的硬核内容了。这一节我会把分布式链路追踪最基础的三样东西——Trace、Span、SpanContext——拆开了讲清楚。你搞懂了它们,后面看任何追踪系统(Jaeger、Zipkin、SkyWalking)都不会发怵。

Trace:一次请求的“完整旅途”

先问个问题:一次用户请求从浏览器发到你的系统,经过 Gateway→订单服务→库存服务→支付服务,最后返回。这中间每个服务都可能调 RPC、读数据库、写消息队列——如果某一步慢了,你怎么定位?

Trace 就是用来描述这次请求的全路径的。它是一棵由 Span 组成的树,根节点是入口请求,每个子节点是一次内部调用。每个 Trace 有唯一的 traceId,贯穿整个请求生命周期。

我记得在电商公司接的第一套链路系统,线上有个下单接口偶尔超时。当时我们没 Trace,只能挨个服务看日志,改一次代码重启一次,折腾了两天才找到是库存服务的 Redis 连接池满了。后来上了 Trace,这种问题看一眼就锁定了。

Trace 有几个核心特征:

  • 全局唯一 ID:traceId,通常 64 位或 128 位随机数。
  • 树状结构:一个 Trace 包含多个 Span,Span 之间有父子关系。
  • 生命周期:从请求进入系统到完全响应的整个时间段。
  • 无状态:Trace 本身不存储状态,状态都在 Span 里。

Span:一次调用的“快照”

说白了,一个 Span 就是 Trace 树上的一个节点。它描述了一次具体的操作:比如“订单服务调库存服务”这个 RPC 调用,或者“查询数据库”这个 SQL 执行。每个 Span 有自己的 spanId,还记录了父 Span 的 parentSpanId,从而形成调用链。

一个 Span 包含这些信息:

  • 操作名(Operation Name):比如 GET /api/inventory/check
  • 开始/结束时间戳
  • 标签(Tags):键值对,比如 db.type: mysql、http.status_code: 200
  • 日志(Logs):时间戳 + 事件描述,比如异常栈
  • SpanContext:用于跨进程传递的上下文(下面细讲)

个人建议:Span 的命名一定要有规范。我以前接手过一个项目,Span 名既有 com.mycompany.controller.xx.method,又有 GET /xx,还有 xxService.xx,查问题时根本没法聚合。后来我们定了规则:RPC 调用用 HTTP 方法+路径,数据库操作用 SQL 摘要,消息队列用 Topic 名。这样查起来一目了然。

Span 的状态很重要,通常四种:OK、ERROR、WARNING、UNSET。我见过很多团队只打 OK 和 ERROR,这样其实丢了很多信息。比如超时但没抛异常的场景,你应该标记为 WARNING 并带上超时时间,方便后续分析。

SpanContext:跨服务传递的“身份证”

好,现在问题来了:服务 A 调用服务 B 时,怎么让 B 知道它属于同一个 Trace?

答案就是 SpanContext。它是一组元数据,随调用一起传递(HTTP Header、RPC 协议附加字段等)。核心包含:

  • traceId:全局唯一追踪 ID
  • spanId:当前 Span 的 ID
  • 可选:parentSpanId,采样标记、Baggage(自定义键值对)

为什么要单独定义 SpanContext?你想,如果你在每个 Span 对象里直接序列化整个结构,那跨进程传递就太重了。SpanContext 只保留最小必要信息,轻量、可序列化,不管你是用 Thrift、HTTP 还是 Kafka,都能挂载上去。

避坑指南:我曾经在一个项目里发现,链路追踪在异步消息(MQ)场景下断了——生产者发了消息,消费者消费时 Trace 完全丢失。后来查了半天,原来是消息体里没传递 SpanContext,消费者无法关联到上游 Trace。解决办法就是在消息 Header 里显式传递 traceId 和 spanId,并让消费者在消费入口重新创建子 Span。这个坑很多新手都会踩,你记住:只要调用链路跨越进程边界,就一定要显式传递 SpanContext。

三者关系:一棵树、一片叶子、一片“叶子柄”

我觉得用树来比喻最形象:

  • Trace = 整棵树,从根到所有枝叶的完整结构。
  • Span = 树上的一片叶子,代表一次独立的调用操作。
  • SpanContext = 连接这片叶子到树枝的“连接器”,里面记录了这片叶子在树里的位置信息。

举个具体例子:用户请求进入你的网关,网关生成一个 Trace,创建第一个 Span(根 Span),SpanContext 包含 traceId=abc123、spanId=0001。然后网关调订单服务,通过 HTTP Header 把 SpanContext 传递过去。订单服务收到后,解析 SpanContext,创建子 Span(parentSpanId=0001、spanId=0002),继续处理。订单服务再调库存服务,同样传递 SpanContext……这样整个调用链就串起来了。

下面这张图帮你直观理解它们的关系:

Trace、Span、SpanContext 关系图 Trace (traceId=abc) Span (根) Span (订单服务) SpanContext (traceId,spanId) Span (库存服务) SpanContext (parentSpanId传递) Span (支付服务) SpanContext (HTTP Header) 图例说明: 根 Span — Trace 中的第一个操作 子 Span — 下游服务的调用单元 SpanContext — 在调用链路上传递的上下文(traceId + spanId + parentSpanId)

核心概念对比表

概念本质包含信息生命周期跨进程传递?
Trace一次请求的完整追踪由多个 Span 组成,无独立 ID 字段(靠 traceId 关联)从请求进入系统到完全响应否(SpanContext 传递时携带 traceId)
Span一次单一操作spanId, parentSpanId, 操作名, 时间, 标签, 日志从操作开始到结束否(Span 本身不跨进程传送)
SpanContext跨进程传递的“快递单”traceId, spanId, parentSpanId, 采样标记, Baggage随一次调用上下文存活是(HTTP Header、RPC 属性等)

最后补充一句:很多初学者会把 Trace 和 SpanContext 搞混。其实很简单——Trace 是概念上的“整体旅程”,SpanContext 是实际在网络上跑来跑去的“包裹”。你写代码时操作的是 Span 和 SpanContext,Trace 是由追踪系统后端根据所有 Span 拼装出来的。理解了这个,你写埋点代码就不会迷路了。