02 链路追踪核心概念
摘要:
上一篇从”为什么”的角度论证了链路追踪的必要性。本篇进入”是什么”的精确定义:Trace、Span、SpanContext、Baggage、Sampling 这些概念各自的语义是什么?为什么要这样设计?不这样设计会怎样?本文以 OpenTelemetry 的数据模型为基准(它是当前的行业标准),同时对比 Apache SkyWalking 和 Zipkin 在数据模型上的差异,帮助读者建立严谨的概念体系。
第 1 章 Trace:一个请求的完整生命周期
1.1 Trace 的精确定义
Trace(追踪) 是链路追踪系统中的顶层概念,它代表一个端到端的请求在分布式系统中的完整执行路径。
形式化地说:一个 Trace 是一组有因果关系的 Span 的集合,通过一个全局唯一的 Trace ID 关联在一起。Trace 本身不是一个独立的数据结构——它是由所有共享同一个 Trace ID 的 Span 隐式组成的。
为什么这一点很重要?因为在大多数链路追踪系统中,并不存在一个”Trace 对象”被显式地创建和传递。系统存储的最小单元是 Span;当你查询一个 Trace 时,后端实际上是按 Trace ID 聚合所有相关 Span,然后根据 Span 之间的父子关系重建出调用树。这意味着:
- 如果调用链中某个服务没有上报 Span(未接入链路追踪),调用树中会出现”断裂”——子 Span 找不到父 Span,显示为孤立节点
- 如果调用链中某个服务的 Span 上报延迟(异步上报 + 网络延迟),查询时可能看到不完整的 Trace
1.2 Trace ID 的设计
Trace ID 是一个全局唯一标识符,用于关联属于同一个请求的所有 Span。
不同系统的 Trace ID 格式:
| 系统 | Trace ID 格式 | 长度 | 示例 |
|---|---|---|---|
| OpenTelemetry / W3C | 32 位十六进制字符串 | 128 bit | 4bf92f3577b34da6a3ce929d0e0e4736 |
| Zipkin(旧版) | 16 位十六进制字符串 | 64 bit | 463ac35c9f6413ad |
| Zipkin(新版) | 32 位十六进制字符串 | 128 bit | 与 OTel 格式兼容 |
| SkyWalking | 自定义格式(含服务实例信息) | 变长 | uuid.thread_id.timestamp |
为什么 128 bit 而不是 64 bit?
64 bit 的 Trace ID 在大规模系统中存在碰撞风险。假设每秒生成 100 万个 Trace ID,根据生日悖论(Birthday Paradox),大约在 2^32 ≈ 43 亿个 ID 后(约 72 分钟),碰撞概率就达到 50%。128 bit 将这个时间延长到天文数字(约 2^64 个 ID 后才有 50% 碰撞概率),对于任何实际系统都足够安全。
SkyWalking 的 Trace ID 设计比较特殊——它不是纯随机 ID,而是包含了服务实例 ID、线程 ID、时间戳等信息。这种设计的好处是 Trace ID 本身就携带了部分上下文信息,在调试时更友好;缺点是 ID 较长,且格式与 W3C 标准不兼容(SkyWalking 通过额外的兼容层解决这个问题)。
第 2 章 Span:链路追踪的原子单元
2.1 Span 的精确定义
Span(跨度) 是链路追踪系统中的原子数据单元,代表一个有名字、有时间范围的操作。一个 Span 通常对应以下操作之一:
- 一次 HTTP 请求的处理
- 一次 RPC 调用
- 一次数据库查询
- 一次消息队列的发送或消费
- 一段有意义的业务逻辑处理
2.2 Span 的核心字段
以 OpenTelemetry 的 Span 数据模型为基准,一个 Span 包含以下核心字段:
必需字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| Trace ID | 128-bit | 所属 Trace 的唯一标识 |
| Span ID | 64-bit | 本 Span 的唯一标识 |
| Parent Span ID | 64-bit(可选) | 父 Span 的 ID,根 Span 为空 |
| Operation Name | string | 操作名称,如 GET /api/orders、MySQL SELECT |
| Start Timestamp | uint64(微秒) | 操作开始的绝对时间 |
| Duration | uint64(微秒) | 操作持续时间 |
| Span Kind | enum | Span 类型(见下文) |
| Status | enum + string | 操作结果状态(OK / Error + 错误信息) |
可选字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| Attributes | key-value map | 标签/属性(如 http.method=GET、db.system=mysql) |
| Events | timestamped list | 时间线事件(如异常发生时刻、重试时刻) |
| Links | list of SpanContext | 指向其他 Trace/Span 的引用(批处理场景) |
2.3 Span Kind:为什么需要区分 Span 类型
Span Kind(Span 类型) 是一个容易被忽视但极其重要的字段。它描述了当前 Span 在调用关系中的”角色”:
| Span Kind | 含义 | 示例 |
|---|---|---|
| CLIENT | 发起远程调用的一方 | 订单服务调用支付服务的 HTTP Client |
| SERVER | 接收远程调用的一方 | 支付服务接收 HTTP 请求的 Handler |
| PRODUCER | 异步消息的发送方 | 向 Kafka Topic 发送消息 |
| CONSUMER | 异步消息的消费方 | 从 Kafka Topic 消费消息 |
| INTERNAL | 进程内部的操作 | 本地的业务逻辑处理、内存计算 |
为什么要区分 Span Kind?
在一次 RPC 调用中,调用方(Client)和被调用方(Server)各自创建一个 Span,共两个 Span。如果不区分 Kind:
- 调用树中会出现两个看起来相同的 Span(都叫
GET /api/payment),无法区分哪个是调用方、哪个是被调用方 - 无法正确计算网络延迟:CLIENT Span 的 duration 包含了网络传输时间 + 服务处理时间,SERVER Span 的 duration 只包含服务处理时间,两者的差值就是网络延迟(单程 × 2)
时间线:
CLIENT Span: |-------- 网络 -------->|---- 等待服务端处理 ----|<-- 网络 --|
| |
SERVER Span: |--- 服务端处理 --------|
CLIENT duration = 网络RTT + 服务端处理时间
SERVER duration = 服务端处理时间
网络延迟 ≈ CLIENT duration - SERVER duration
对于消息队列场景,PRODUCER 和 CONSUMER 之间可能有分钟到小时级别的延迟(消息在队列中等待被消费),这与同步 RPC 的毫秒级延迟完全不同。区分 PRODUCER/CONSUMER 可以让链路追踪系统正确处理这种异步调用关系。
2.4 Attributes:结构化的上下文信息
Attributes(属性) 是附加在 Span 上的键值对,用于提供操作的上下文信息。OpenTelemetry 定义了一套语义约定(Semantic Conventions),标准化了常见操作的 Attribute 名称:
HTTP 相关:
http.method:GET、POSThttp.url: 完整的请求 URLhttp.status_code: HTTP 响应状态码http.request_content_length: 请求体大小
数据库相关:
db.system:mysql、redis、postgresqldb.statement: SQL 语句(注意脱敏)db.operation:SELECT、INSERT、UPDATE
消息队列相关:
messaging.system:kafka、rabbitmq、rocketmqmessaging.destination: Topic 或 Queue 名称messaging.operation:publish、receive
生产避坑:Attribute 的大小控制
Attributes 是 Span 中数据量最大的部分。如果不加控制地将整个 SQL 语句、完整的请求/响应 Body 放入 Attributes,单个 Span 可能达到几十 KB,乘以海量请求,存储和传输压力巨大。最佳实践是:
- SQL 语句截断到 500 字符以内
- 请求 Body 只记录摘要或 hash,不记录完整内容
- 对敏感信息(密码、Token、身份证号)做脱敏处理
2.5 Events:Span 内部的时间线
Events(事件) 是附加在 Span 上的带时间戳的日志记录,用于标注 Span 执行过程中的关键时刻。
最典型的 Event 是异常事件(Exception Event):当 Span 内部发生异常时,将异常信息作为 Event 记录下来:
Span: "POST /api/orders"(Duration: 500ms, Status: Error)
├── Event: "exception" at T+200ms
│ ├── exception.type: "java.sql.SQLTimeoutException"
│ ├── exception.message: "Query timed out after 200ms"
│ └── exception.stacktrace: "at com.mysql.jdbc.MysqlIO..."
└── Event: "retry" at T+300ms
└── message: "Retrying database query, attempt 2"
Events 与 Attributes 的区别:Attributes 是 Span 整体的属性(贯穿 Span 的整个生命周期),Events 是 Span 内部某个时刻发生的事件。
2.6 Links:跨 Trace 的关联
Links(链接) 是一个相对少用但非常重要的概念:它允许一个 Span 引用其他 Trace 中的 Span,建立跨 Trace 的因果关系。
典型场景:批处理
假设有一个批处理任务,一次性处理 1000 条消息,每条消息对应一个独立的 Trace(由各自的生产者创建)。批处理任务本身创建一个新的 Trace,但需要引用这 1000 条消息各自的 Trace ID,以便在调试时能够追溯”这条消息是被哪个批处理任务处理的”。
这种情况下,Parent-Child 关系不适用(1000 个父 Span?),Links 才是正确的语义:
批处理 Trace (Trace ID = batch-001):
└── Span: "BatchProcess 1000 messages"
├── Link → Trace msg-001, Span producer-001
├── Link → Trace msg-002, Span producer-002
├── ...
└── Link → Trace msg-1000, Span producer-1000
第 3 章 SpanContext 与 Context Propagation
3.1 SpanContext:跨进程传递的最小信息集
SpanContext(Span 上下文) 是 Span 中需要跨进程传递的最小信息子集。它不包含 Span 的全部信息(如 Attributes、Events 等),只包含下游服务创建子 Span 所需的最小信息:
| 字段 | 说明 |
|---|---|
| Trace ID | 当前 Trace 的全局唯一 ID |
| Span ID | 当前 Span 的 ID(下游用它作为 Parent Span ID) |
| Trace Flags | 追踪标志位(最重要的是 sampled 标志,表示此 Trace 是否被采样) |
| Trace State | 厂商特定的扩展信息(如 SkyWalking 的服务实例信息) |
为什么不把整个 Span 传递过去?因为跨进程传递(通常通过 HTTP Header 或 gRPC Metadata)的带宽是有限的。一个完整的 Span 可能包含几百字节到几 KB 的 Attributes 和 Events,每个 HTTP 请求都携带这么多额外数据是不可接受的。SpanContext 通常只有几十字节。
3.2 W3C Trace Context 标准
W3C Trace Context 是目前链路追踪跨进程传播的标准格式(2020 年成为 W3C 推荐标准)。它定义了两个 HTTP Header:
traceparent:必需的 Header,包含核心追踪信息
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
── ──────────────────────────────── ──────────────── ──
版本 Trace ID (32 hex) Span ID (16 hex) Flags
01 = sampled
00 = not sampled
tracestate:可选的 Header,携带厂商特定信息
tracestate: sw=abc123,rojo=00f067aa0ba902b7
tracestate 是一个键值对列表,允许不同的链路追踪系统在同一条调用链中共存——每个系统只读写自己的键,忽略其他系统的键。
3.3 进程内传播:ThreadLocal 与 Context API
跨进程传播解决了”服务 A 到服务 B”的 SpanContext 传递问题。但在单个服务内部,也需要在不同的函数调用之间传递当前的 SpanContext——否则每个函数创建 Span 时都不知道自己的父 Span 是谁。
Java 生态:ThreadLocal
在 Java 中,最传统的进程内传播机制是 ThreadLocal:
// 伪代码:SkyWalking Agent 内部的 Context 管理
public class ContextManager {
// 每个线程有自己的 Trace Context
private static final ThreadLocal<TraceContext> CONTEXT = new ThreadLocal<>();
public static Span createEntrySpan(String operationName) {
TraceContext context = CONTEXT.get();
if (context == null) {
// 新 Trace 的入口:创建 Root Span
context = new TraceContext(generateTraceId());
CONTEXT.set(context);
}
Span span = new Span(operationName, context.currentSpanId());
context.push(span); // 将新 Span 压入栈顶
return span;
}
public static void stopSpan(Span span) {
TraceContext context = CONTEXT.get();
context.pop(span); // 弹出栈顶 Span
if (context.isEmpty()) {
// 所有 Span 都已结束,整个 Trace 完成
context.finish(); // 异步上报
CONTEXT.remove(); // 清理 ThreadLocal
}
}
}ThreadLocal 的问题:跨线程传播
ThreadLocal 只在当前线程内有效。如果业务代码使用了异步编程(线程池、CompletableFuture、Reactor/WebFlux),新线程无法访问原线程的 ThreadLocal,Trace Context 就会丢失,调用链”断裂”。
解决方案:
- 手动传递:在提交异步任务时,手动将 SpanContext 从当前线程复制到新线程。代码侵入性大
- 线程池包装:用自定义的
TracedExecutorService包装原始线程池,自动传递 Context。SkyWalking 的@TraceCrossThread注解就是这种方式 - Java Agent 字节码增强:SkyWalking Agent 可以自动增强
Runnable、Callable、CompletableFuture等异步入口类,在任务提交时捕获 Context,在任务执行时恢复 Context
Go 生态:context.Context
Go 语言天然通过 context.Context 参数传递上下文(Go 社区约定 Context 作为函数的第一个参数),链路追踪的 SpanContext 直接嵌入 context.Context,没有 ThreadLocal 的跨线程问题:
// Go 中的 Context 传播:天然支持跨 goroutine
func handleRequest(ctx context.Context, req *Request) {
// 从 ctx 中提取当前 Span
span, ctx := tracer.Start(ctx, "handleRequest")
defer span.End()
// 启动新的 goroutine 时传递 ctx
go processAsync(ctx, req.Data) // ctx 天然跨 goroutine 传播
}第 4 章 Baggage:跨服务的业务上下文传递
4.1 什么是 Baggage
Baggage(行李) 是附加在 SpanContext 上的键值对集合,它与 Span Attributes 不同——Baggage 会随着 SpanContext 一起跨进程传播到所有下游服务。
为什么需要 Baggage?考虑这个场景:
API 网关知道当前请求的用户 ID = "user-123" 和用户等级 = "VIP"
调用链:API Gateway → Order Service → Inventory Service → Warehouse Service
如果没有 Baggage:
Order Service 知道 user-123(从请求参数中获取)
Inventory Service 不知道 user-123(Order Service 没传给它)
Warehouse Service 也不知道 user-123
如果有 Baggage:
API Gateway 将 {user_id: "user-123", user_tier: "VIP"} 放入 Baggage
Baggage 随 SpanContext 自动传播到所有下游服务
每个服务都能读取 user_id 和 user_tier,用于日志记录、权限判断或个性化处理
4.2 Baggage 的代价与使用原则
Baggage 虽然强大,但有明显的代价:
带宽开销:Baggage 中的每个键值对都会被序列化到 HTTP Header 中,每个跨服务调用都会携带。如果 Baggage 中放了大量数据(如完整的用户画像 JSON),会显著增加网络传输量。
安全风险:Baggage 中的数据可以被调用链中的任何服务读取和修改。不应该将敏感信息(如密码、Token)放入 Baggage。
Baggage 使用原则
- 只放小量、非敏感的数据(如用户 ID、请求来源、A/B 测试分组标志)
- 单个 Baggage 键值对不超过 256 字节
- 总 Baggage 大小不超过 8KB
- 不要将 Baggage 作为”隐式参数传递”的通道——业务参数应该通过显式的 API 参数传递
第 5 章 采样:在信息完整性与资源开销之间权衡
5.1 为什么采样是必须的
一个中等规模的微服务系统,假设每秒处理 10,000 个请求,每个请求产生 20 个 Span,每个 Span 平均 500 字节:
数据量 = 10,000 req/s × 20 spans/req × 500 bytes/span = 100 MB/s = 8.6 TB/day
每天 8.6 TB 的链路数据,存储成本和查询性能都是巨大挑战。采样——只记录一部分请求的链路——是控制成本的必要手段。
5.2 三种采样策略
策略一:头部采样(Head-based Sampling)
在请求入口(Root Span 创建时)就决定是否采样。决策被编码到 SpanContext 的 Trace Flags 中,传播到整条调用链——要么整个 Trace 的所有 Span 都被记录,要么都不被记录。
优点:实现简单,所有 Span 的采样决策一致(不会出现半条链路的情况)
缺点:在入口处无法预知这个请求后续是否会出错或异常缓慢——可能刚好跳过了一个重要的错误请求
常见的头部采样方式:
- 固定概率采样:每个请求以固定概率(如 1%)被采样
- 速率限制采样:每秒最多采样 N 条 Trace(如 100 条/秒)
策略二:尾部采样(Tail-based Sampling)
所有 Span 先被完整收集(暂存在内存或临时存储中),等整个 Trace 完成后,根据 Trace 的完整信息(是否包含错误 Span、总耗时是否超过阈值等)做采样决策。
优点:可以 100% 保留异常请求和慢请求——这些恰恰是最需要分析的
缺点:
- 实现复杂:需要等待整个 Trace 的所有 Span 到齐(在分布式系统中,不同服务的 Span 上报时间不一致)
- 资源开销大:所有 Span 必须先被收集和暂存,然后再决定是否丢弃
- 需要集中式的采样决策组件(如 OpenTelemetry Collector 的 Tail Sampling Processor)
策略三:自适应采样(Adaptive Sampling)
根据实时流量和系统负载动态调整采样率。高流量时降低采样率(保护系统),低流量时提高采样率(保留更多信息)。
Jaeger 的自适应采样实现:Jaeger Collector 根据每个服务、每个接口的 QPS,动态计算采样率,确保每个接口每秒至少有 N 条被采样的 Trace。这样即使低频接口(QPS = 1)也能被完整记录。
5.3 SkyWalking 的采样机制
Apache SkyWalking 的采样在 Agent 端实现(头部采样),支持以下配置:
# agent.config
# 采样率:每 3 条 Trace 采样 1 条(约 33%)
agent.sample_n_per_3_secs=-1 # -1 表示全量采样
agent.sample_n_per_3_secs=100 # 每 3 秒最多采样 100 条 TraceSkyWalking 的采样有一个重要特性:采样决策只影响数据上报,不影响 Context Propagation。即使一个 Trace 没有被采样(不上报 Span 数据),它的 Trace ID 和 SpanContext 仍然会被传播到下游服务——下游服务可能有自己的采样策略,可能选择记录这个 Trace。
第 6 章 不同系统的数据模型对比
6.1 SkyWalking 的 Segment 模型
SkyWalking 的数据模型与 OpenTelemetry 有一个重要差异:SkyWalking 引入了 Segment(片段) 这个中间概念。
在 SkyWalking 中,一个 Segment 代表一个服务实例处理一个请求的所有 Span 的集合。一个 Trace 由多个 Segment 组成(每个服务实例贡献一个 Segment)。
Trace(全局)
├── Segment 1: API Gateway 实例
│ ├── EntrySpan: HTTP 接收
│ └── ExitSpan: 调用 Order Service
├── Segment 2: Order Service 实例
│ ├── EntrySpan: HTTP 接收
│ ├── LocalSpan: 业务处理
│ └── ExitSpan: 调用 Payment Service
└── Segment 3: Payment Service 实例
├── EntrySpan: HTTP 接收
└── LocalSpan: 支付处理
为什么引入 Segment?
Segment 是 Agent 端的上报单元——一个 Segment 内的所有 Span 是在同一个服务实例的同一个线程中串行创建的,可以在本地完整组装后一次性上报。这比逐个 Span 上报更高效:
- 减少网络请求数(一次 HTTP 请求上报一个 Segment,而不是 N 个 Span 对应 N 次请求)
- 保证同一 Segment 内 Span 的完整性(要么全部上报,要么全部不上报)
SkyWalking 还区分了三种 Span 类型(与 OTel 的 SpanKind 类似但命名不同):
- EntrySpan:服务入口(对应 SERVER / CONSUMER)
- ExitSpan:服务出口(对应 CLIENT / PRODUCER)
- LocalSpan:进程内部操作(对应 INTERNAL)
6.2 数据模型对比总结
| 维度 | OpenTelemetry | SkyWalking | Zipkin |
|---|---|---|---|
| 最小单元 | Span | Span(隶属于 Segment) | Span |
| 中间聚合 | 无 | Segment(服务实例粒度) | 无 |
| 上报单元 | 单个 Span 或 SpanBatch | Segment | Span |
| Span 类型 | SpanKind(5 种) | EntrySpan / ExitSpan / LocalSpan | cs/cr/sr/ss 注解 |
| ID 格式 | W3C 128-bit hex | 自定义变长格式 | 64/128-bit hex |
| 传播格式 | W3C traceparent | sw8 Header | B3 Header |
参考资料
- OpenTelemetry Specification – Tracing API:https://opentelemetry.io/docs/specs/otel/trace/api/
- W3C Trace Context:https://www.w3.org/TR/trace-context/
- OpenTelemetry Semantic Conventions:https://opentelemetry.io/docs/specs/semconv/
- Apache SkyWalking – Trace Data Protocol:https://skywalking.apache.org/docs/main/latest/en/api/trace-data-protocol-v3/
- Zipkin – Instrumenting a library:https://zipkin.io/pages/instrumenting.html
- Sigelman, B., et al. (2010). Dapper, a Large-Scale Distributed Systems Tracing Infrastructure.
- Shkuro, Y. (2019). Mastering Distributed Tracing. Packt Publishing. Chapter 4: Instrumentation.
思考题
- OTel 的自动埋点(Auto-Instrumentation)通过 Java Agent、Python 自动补丁等机制——无需修改代码即可采集 HTTP、gRPC、数据库调用的 Trace 和 Metrics。但自动埋点只能采集框架级别的数据——业务层面的自定义 Span(如’处理订单’、‘计算折扣’)需要手动埋点。在什么粒度下你需要手动埋点?过多的手动 Span 是否增加了代码侵入性和性能开销?
- OTel SDK 的采样策略(Sampler)控制了哪些 Trace 被采集。
AlwaysOn(100% 采集)在高 QPS 场景中数据量巨大且开销高。TraceIdRatioBased(0.1)(10% 采样)降低了数据量但可能丢失关键 Trace。ParentBased采样(子 Span 继承父 Span 的采样决策)确保同一 Trace 的所有 Span 要么全采要么全不采。在什么场景下你需要’尾部采样’(根据 Trace 完成后的结果决定是否采集,如只采集错误 Trace)?- OTel 的 Baggage 机制允许在 Context 中传递自定义的键值对——所有下游服务都能读取。例如在入口网关设置
tenant_id=abc,所有下游服务的 Span 都携带这个信息。Baggage 与 Span Attributes 的区别是什么?Baggage 的安全风险(如传播敏感信息)如何防范?