02 链路追踪核心概念

摘要:

上一篇从”为什么”的角度论证了链路追踪的必要性。本篇进入”是什么”的精确定义:Trace、Span、SpanContext、Baggage、Sampling 这些概念各自的语义是什么?为什么要这样设计?不这样设计会怎样?本文以 OpenTelemetry 的数据模型为基准(它是当前的行业标准),同时对比 Apache SkyWalkingZipkin 在数据模型上的差异,帮助读者建立严谨的概念体系。


第 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 / W3C32 位十六进制字符串128 bit4bf92f3577b34da6a3ce929d0e0e4736
Zipkin(旧版)16 位十六进制字符串64 bit463ac35c9f6413ad
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 ID128-bit所属 Trace 的唯一标识
Span ID64-bit本 Span 的唯一标识
Parent Span ID64-bit(可选)父 Span 的 ID,根 Span 为空
Operation Namestring操作名称,如 GET /api/ordersMySQL SELECT
Start Timestampuint64(微秒)操作开始的绝对时间
Durationuint64(微秒)操作持续时间
Span KindenumSpan 类型(见下文)
Statusenum + string操作结果状态(OK / Error + 错误信息)

可选字段

字段类型说明
Attributeskey-value map标签/属性(如 http.method=GETdb.system=mysql
Eventstimestamped list时间线事件(如异常发生时刻、重试时刻)
Linkslist 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: GETPOST
  • http.url: 完整的请求 URL
  • http.status_code: HTTP 响应状态码
  • http.request_content_length: 请求体大小

数据库相关

  • db.system: mysqlredispostgresql
  • db.statement: SQL 语句(注意脱敏)
  • db.operation: SELECTINSERTUPDATE

消息队列相关

  • messaging.system: kafkarabbitmqrocketmq
  • messaging.destination: Topic 或 Queue 名称
  • messaging.operation: publishreceive

生产避坑: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 可以自动增强 RunnableCallableCompletableFuture 等异步入口类,在任务提交时捕获 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 条 Trace

SkyWalking 的采样有一个重要特性:采样决策只影响数据上报,不影响 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 数据模型对比总结

维度OpenTelemetrySkyWalkingZipkin
最小单元SpanSpan(隶属于 Segment)Span
中间聚合Segment(服务实例粒度)
上报单元单个 Span 或 SpanBatchSegmentSpan
Span 类型SpanKind(5 种)EntrySpan / ExitSpan / LocalSpancs/cr/sr/ss 注解
ID 格式W3C 128-bit hex自定义变长格式64/128-bit hex
传播格式W3C traceparentsw8 HeaderB3 Header

参考资料

  1. OpenTelemetry Specification – Tracing API:https://opentelemetry.io/docs/specs/otel/trace/api/
  2. W3C Trace Context:https://www.w3.org/TR/trace-context/
  3. OpenTelemetry Semantic Conventions:https://opentelemetry.io/docs/specs/semconv/
  4. Apache SkyWalking – Trace Data Protocol:https://skywalking.apache.org/docs/main/latest/en/api/trace-data-protocol-v3/
  5. Zipkin – Instrumenting a library:https://zipkin.io/pages/instrumenting.html
  6. Sigelman, B., et al. (2010). Dapper, a Large-Scale Distributed Systems Tracing Infrastructure.
  7. Shkuro, Y. (2019). Mastering Distributed Tracing. Packt Publishing. Chapter 4: Instrumentation.

思考题

  1. OTel 的自动埋点(Auto-Instrumentation)通过 Java Agent、Python 自动补丁等机制——无需修改代码即可采集 HTTP、gRPC、数据库调用的 Trace 和 Metrics。但自动埋点只能采集框架级别的数据——业务层面的自定义 Span(如’处理订单’、‘计算折扣’)需要手动埋点。在什么粒度下你需要手动埋点?过多的手动 Span 是否增加了代码侵入性和性能开销?
  2. OTel SDK 的采样策略(Sampler)控制了哪些 Trace 被采集。AlwaysOn(100% 采集)在高 QPS 场景中数据量巨大且开销高。TraceIdRatioBased(0.1)(10% 采样)降低了数据量但可能丢失关键 Trace。ParentBased 采样(子 Span 继承父 Span 的采样决策)确保同一 Trace 的所有 Span 要么全采要么全不采。在什么场景下你需要’尾部采样’(根据 Trace 完成后的结果决定是否采集,如只采集错误 Trace)?
  3. OTel 的 Baggage 机制允许在 Context 中传递自定义的键值对——所有下游服务都能读取。例如在入口网关设置 tenant_id=abc,所有下游服务的 Span 都携带这个信息。Baggage 与 Span Attributes 的区别是什么?Baggage 的安全风险(如传播敏感信息)如何防范?