分布式链路追踪核心概念: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:全局唯一追踪 IDspanId:当前 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 组成,无独立 ID 字段(靠 traceId 关联) | 从请求进入系统到完全响应 | 否(SpanContext 传递时携带 traceId) |
| Span | 一次单一操作 | spanId, parentSpanId, 操作名, 时间, 标签, 日志 | 从操作开始到结束 | 否(Span 本身不跨进程传送) |
| SpanContext | 跨进程传递的“快递单” | traceId, spanId, parentSpanId, 采样标记, Baggage | 随一次调用上下文存活 | 是(HTTP Header、RPC 属性等) |
最后补充一句:很多初学者会把 Trace 和 SpanContext 搞混。其实很简单——Trace 是概念上的“整体旅程”,SpanContext 是实际在网络上跑来跑去的“包裹”。你写代码时操作的是 Span 和 SpanContext,Trace 是由追踪系统后端根据所有 Span 拼装出来的。理解了这个,你写埋点代码就不会迷路了。