链路追踪核心概念:Trace、Span、SpanContext 与分布式传播

做链路追踪这几年,我最大的感受是:概念不搞清楚,后面所有的设计都是空中楼阁。不管你用什么开源框架——Jaeger、Zipkin、SkyWalking,还是自研的 APM,底子都是这几个核心概念。下面我一个一个拆开给你看。

1. Trace:一次完整的请求旅程

Trace 是什么?说白了,就是一次请求从客户端发出,经过网关、微服务 A、微服务 B、缓存、数据库,最后返回给客户端的完整路径。每个 Trace 有唯一的 Trace ID,标识整条链。

打个比方:你下单买东西,订单服务调用库存服务,库存服务查数据库,数据库返回结果给库存服务,库存服务再返回给订单服务。这整个调用链就是一条 Trace。

我的习惯:在一开始设计 Trace ID 时,我会用全局唯一且能按时间戳排序的 ID(比如 Snowflake 算法)。这样排错时能快速定位时间范围,省不少事。

2. Span:Trace 里的每一小步

Span 是 Trace 的最小工作单元。每个 Span 记录了一个操作:比如一次 RPC 调用、一次 SQL 查询、一个消息发送。Span 有开始时间、结束时间、状态、标签等。

同样用下单的例子:
一条 Trace 包含:
- SpanA:订单服务调用库存服务
- SpanB:库存服务查询数据库
- SpanC:数据库执行 SQL

每个 Span 必须携带它所属的 Trace ID,以及它自己的 Span ID。

Span 的核心字段

字段说明我踩过的坑
TraceId全局唯一的 Trace 标识长度固定 16 字节,别用随机字符串
SpanId当前 Span 的标识父子 SpanId 要足够随机,避免碰撞
ParentSpanId父 Span 的 ID(根 Span 为空)空值处理不当会导致树结构断裂
OperationName操作名(如 "POST /order")命名风格要统一,我常用 HTTP_METHOD /path
StartTime / EndTime开始和结束时间戳精度用微秒,纳秒在跨语言时容易丢精度
Tags键值对标签(如 http.status_code=200)标签数别超过 20 个,否则存储压力大

3. SpanContext:Span 的身份证+快递单

SpanContext 是跨服务传递的最小信息包。它包含:
- Trace ID
- Span ID
- 其他传播必需的元数据(如采样标记、Baggage)

为什么要单独抽一个 Context 出来?
因为 Span 对象很大,包含开始时间、结束时间、标签、日志等。在 HTTP 头或消息队列里传递整个 Span 太浪费,所以只传递关键信息——SpanContext。

我的经验:记得有一次我看到微服务之间传了整条 Span 的 JSON,导致 Header 过大被 Nginx 拒绝。改用 SpanContext 后,请求头从 8KB 降到 200 字节。所以记住:跨服务只传 SpanContext,别传 Span。

4. Parent-Child 关系:Span 的家族树

每个 Span 除了有自己 ID,还有父 Span ID。根 Span 的 ParentSpanId 为空。这种父子关系构成了一棵有向无环图(DAG)。

举个例子:

TraceId: abc123
├── Span A (ParentSpanId: 空)   —— 根 Span,订单服务接收请求
│   ├── Span B (ParentSpanId: A) —— 查询库存服务
│   │   ├── Span D (ParentSpanId: B) —— 执行 Redis 查询
│   │   └── Span E (ParentSpanId: B) —— 执行 MySQL 查询
│   └── Span C (ParentSpanId: A) —— 发送支付请求
│       └── Span F (ParentSpanId: C) —— 支付回调

这种模型的好处是:你可以精确知道每个操作的耗时和调用顺序。哪个环节慢,一目了然。

但注意:子 Span 可能不是严格按照顺序执行的。比如 Span D 和 Span E 可以是并行的。所以追踪系统需要根据时间戳还原真实时序,不能只依赖父子关系。

5. 分布式传播方式:让 SpanContext 跑起来

分布式传播,就是把 SpanContext 从上游服务传到下游服务。常见的方式有:

5.1 HTTP 传播:通过请求 Header

最经典的做法是在 HTTP 请求头里注入 SpanContext。两个主流协议:

协议Header 名称示例值
W3C Trace Contexttraceparent00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01
Zipkin B3X-B3-TraceId
X-B3-SpanId
X-B3-ParentSpanId
0af7651916cd43dd
00f067aa0ba902b7
... 可选
我的建议:如果你是新项目,直接上 W3C Trace Context。它已经成了业界标准,兼容性好。我以前用 B3 做过一个系统,后来接第三方 SDK 时发现对方只认 W3C,不得不在网关层做转换,想想都后悔。

5.2 消息队列传播:通过消息属性

对于 RabbitMQ、Kafka 等中间件,SpanContext 会被放入消息的 Header 里。生产者在发送消息前注入,消费者在接收后提取。

// 生产者注入
const headers = {
  'traceparent': `00-${traceId}-${spanId}-01`
};
await channel.sendToQueue(queueName, content, { headers });

// 消费者提取
const traceparent = msg.properties.headers['traceparent'];
// 解析 traceparent 得到 traceId, spanId
我曾经踩过的坑:在 Kafka 里,如果生产者是异步的,SpanContext 可能已经在主线程关闭了。记得要在生产者回调里重新链接上下文,否则子 Span 会丢失父 Span ID,变成孤立 Span。

5.3 gRPC 传播:通过 Metadata

gRPC 原生支持拦截器(Interceptor),可以在客户端和服务端拦截器里自动注入和提取 SpanContext。大部分框架(如 OpenTelemetry)已经内置了 gRPC 传播中间件,开箱即用。

6. 一张图总结核心概念

Trace & Span 关系图 Trace、Span 与 Parent-Child 关系 Span A (根Span) SpanId: A, ParentSpanId: 空 Span B ParentSpanId: A Span C ParentSpanId: A Span D Parent: B Span E Parent: B Span F Parent: C 说明 ① 一条 Trace 包含多个 Span ② 根 Span 没有父节点 ③ 子 Span 通过 ParentSpanId 关联 ④ SpanContext 携带 TraceId + SpanId 跨服务传递

7. 实践中的两个关键点

第一,不要让 Span 变成性能杀手。 我见过一个团队把每个循环里的数据库查询都做成一个 Span,结果内存和 IO 直接爆炸。适当采样就好,比如 10% 的请求。

第二,一定注意 Baggage 的滥用。 Baggage 是 SpanContext 里可以随请求传递的键值对。有人用它传用户 ID、租户 ID,甚至传 ,导致每次请求 Header 越来越大。我的原则:Baggage 只传链路追踪需要的元数据,业务数据交给业务 Header。

曾经有一次踩坑:我在一个项目的 SpanContext 里传了完整的 JWT (50KB),结果每个 HTTP 请求 Header 暴涨,Nginx 直接 413 Request Entity Too Large。后来不得不紧急上线修改,教训惨痛。

好了,核心概念就这些。有了 Trace、Span、SpanContext 和父子关系,你就能把任意调用的因果关系清晰地记录下来。下一块内容我们会深入实际搭建追踪系统时遇到的坑——传播协议兼容性、采样策略、存储选型等。到时候再跟你细聊。