# 第 7 部分导论 · 追踪的本质

> 一个请求穿过十几个服务，怎么把它串成一条完整的路径？这一章讲清 trace/span 的数据模型、SpanKind 为什么重要、以及那个让整件事得以成立的机制——上下文传播。

- 作者：David（道雾轩）
- 专栏：指标采集与观测（https://daiw.org/manual/observability.md）
- 最后更新：2026-07-15
- 原文：https://daiw.org/manual/observability/traces-basics
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# 第 7 部分导论 · 追踪的本质

[三大支柱](https://daiw.org/manual/observability/three-pillars)里，我们花了六个部分讲**指标**。这一部分补上另外两根——以及把它们**串起来**的那些桥。

先看**追踪（tracing）**：它回答一个指标和日志都答不了的问题——“**这一个请求，到底经历了什么？**”

## trace 和 span

<Callout type="info">
  **数据模型简单到只有两个概念：**

  - **span** —— **一段有起止时间的工作**。“在订单服务处理了 12ms”是一个 span。它有：名字、开始/结束时间、[属性](https://daiw.org/manual/observability/otel-overview)、状态（成功/失败）。
  - **trace** —— **一次请求涉及的所有 span**，靠 `trace_id` 关联，靠 `parent_span_id` 组成一棵**树**。

  ```
  trace_id = abc123

  [网关            5ms]                    ← root span (parent = 无)
    └─[鉴权         2ms]                   ← parent = 网关
    └─[订单服务         12ms]              ← parent = 网关
        └─[查库存             1800ms]  ★   ← parent = 订单服务(元凶!)
        └─[写数据库        8ms]
  ```

  **这棵树就是“火焰图”背后的数据。** 一眼看出 1800ms 花在哪——**这是[指标](https://daiw.org/manual/observability/three-pillars)（只有聚合数字）和[日志](https://daiw.org/manual/observability/three-pillars)（只有孤立事件）都给不了的**：它揭示**因果和结构**。
</Callout>

## SpanKind：比想象中重要

看源码 `pdata/ptrace/span_kind.go:17`（真实注释）：

```go
// SpanKindInternal indicates that the span represents an internal operation within an application
SpanKindInternal
// SpanKindServer indicates that the span covers server-side handling of an RPC or other remote request
SpanKindServer
// SpanKindClient indicates that the span describes a request to some remote service
SpanKindClient
SpanKindProducer   // 异步:生产消息
SpanKindConsumer   // 异步:消费消息
```

<Callout type="info">
  **`SpanKind` 不只是个标注，它决定了后端能不能正确理解你的系统：**

  - **`Client` + `Server` 是一对** —— 调用方发出请求（client span）、被调方处理（server span）。**两者之差就是网络耗时 + 排队时间**。后端靠这个算出“**这一跳在网络上花了多久**”——这是[纯粹的服务端指标永远看不到的](https://daiw.org/manual/observability/red-method)（呼应[第 2 部分说的“客户端超时服务端看不见”](https://daiw.org/manual/observability/red-method)）。
  - **`Producer` + `Consumer` 是异步的一对** —— 消息队列场景。**它们之间可能隔了几秒甚至几分钟**，而且**不是父子关系**（生产者不会等消费者）。后端要靠 kind 知道“别把这段等待算进请求延迟”。
  - **`Internal`** —— 服务内部的一段工作，不跨进程。

  **搞错 kind 的后果很实际**：比如把 client span 标成 internal，后端就**算不出网络耗时**；把 consumer 标成 server，**队列里的等待时间会被算成“处理延迟”**，你的 P99 看起来会离谱地高。

  **这也是[语义约定](https://daiw.org/manual/observability/otel-overview)的价值所在**——它让后端能**自动**理解你的拓扑，而不需要你手工配置“谁调用谁”。
</Callout>

## Span 之间不只有父子

`ptrace` 里还有两个容易被忽略的东西：

<Callout type="info">
  **`SpanEvent`(`generated_spanevent.go`)** —— **span 内部的一个时间点**。比如“在第 8ms 时发生了一次缓存未命中”、“在第 200ms 时抛了异常”。

  **它是“span 里的日志”**——[这正是 traces 和 logs 之间那座桥的一种形态](https://daiw.org/manual/observability/logs-pillar)：你不用另开一条日志，直接把事件挂在 span 上，**天然带着 trace 上下文**。异常堆栈通常就是这么记的。

  **`SpanLink`(`generated_spanlink.go`)** —— **指向另一条 trace 的 span**，但**不是父子关系**。

  这个东西是为了表达那些**树形结构表达不了**的关系：
  - **批处理** —— 一个批次任务处理了 100 条消息，它们来自 100 条不同的 trace。**它不能有 100 个 parent**（树只允许一个），但可以有 100 个 link。
  - **扇入** —— 多个上游触发了同一个下游动作。
  - **异步解耦** —— [生产者和消费者](https://daiw.org/manual/observability/traces-basics)相隔太远，做成父子会让 trace 变得畸形（一条 trace 持续几小时）。

  **`SpanLink` 的存在承认了一个事实：分布式系统的因果关系不总是一棵树。** 树是主干，link 是那些“跨树”的边——这让追踪能表达真实世界的复杂拓扑，而不是硬把一切塞进树形。
</Callout>

## 上下文传播：让这一切成立的机制

<Callout type="warn">
  **一个尖锐的问题：服务 A 调服务 B 时，B 怎么知道自己属于哪条 trace？**

  **答案：A 必须把 `trace_id` 和自己的 `span_id` **通过请求传给 B**。** 这就是**上下文传播（context propagation）**——它是整个追踪体系**最脆弱、也最关键**的一环。

  **W3C 标准化了这个格式（`traceparent` HTTP 头）：**
  ```
  traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
               │  │                                │                │
               │  └─ trace-id(16 字节)             └─ parent-span-id └─ flags(采样标志)
               └─ version
  ```

  **为什么说它脆弱？因为它必须**穿过每一跳**，只要有一环断了，trace 就断成两截：**

  - **进程内也要传** —— 从 HTTP handler 到你的业务逻辑到数据库调用，**context 必须一路透传**。Go 里靠 `context.Context`，Java 靠 ThreadLocal。**一个 `go func()` 忘了传 ctx，后面的 span 就全丢了。**
  - **跨异步边界最容易断** —— 扔进 channel、丢进线程池、发到消息队列——**这些地方 context 不会自动跟着走**，要手工传。
  - **第三方库不配合就断** —— 某个 HTTP 客户端库不支持注入 header，这一跳就断了。

  **“trace 断了”是追踪落地时最常见的问题**，而根因几乎总是传播：某处没传 context。**这也是为什么 OTel 花大力气做各语言的自动埋点（instrumentation）库**——它们的核心价值就是**替你把 context 在框架的各个缝隙里传好**。
</Callout>

## flags 里那个采样位

<Callout type="info">
  **注意 `traceparent` 最后那个 `01`——它是 **采样标志**。**

  **这个小小的 bit 有大意义：它让“要不要采样”这个决定**沿着调用链传播**。** 如果网关决定“这条 trace 要采”，这个位就是 1，下游所有服务看到它**都会采**——**于是你得到一条完整的 trace**。

  **如果没有它会怎样？** 每个服务各自随机决定采不采，那么一条 10 跳的 trace，**每跳都有概率不采** → 你得到的全是**残缺的碎片**，几乎没有一条完整的。**这类采样叫“头部采样”，[下一章细讲](https://daiw.org/manual/observability/sampling)**，而这个传播位是它能工作的前提。
</Callout>

## 小结与下一章

**追踪回答“[这一个请求经历了什么](https://daiw.org/manual/observability/three-pillars)”**——揭示**因果和结构**，这是[指标（聚合数字）和日志（孤立事件）都给不了的](https://daiw.org/manual/observability/three-pillars)。模型只有两个概念：**span**（一段有起止的工作）和 **trace**（靠 `trace_id` 关联、`parent_span_id` 组成**树**）。**`SpanKind` 比想象中重要**：[`Client`+`Server` 一对（两者之差 = 网络耗时，服务端指标看不到）、`Producer`+`Consumer` 异步一对（别把队列等待算进延迟）、`Internal`](https://daiw.org/manual/observability/red-method)——**搞错 kind 会让后端算错拓扑和延迟**。还有 **`SpanEvent`**（[span 里的时间点，相当于“带 trace 上下文的日志”](https://daiw.org/manual/observability/logs-pillar)）和 **`SpanLink`**（[非父子的跨 trace 引用，为批处理/扇入/异步而生](https://daiw.org/manual/observability/traces-basics)）——**它的存在承认了：分布式的因果不总是一棵树**。**上下文传播是最脆弱也最关键的一环**：[`traceparent` 头必须穿过每一跳，进程内要透传 context、跨异步边界最容易断、第三方库不配合就断](https://daiw.org/manual/observability/traces-basics)——**“trace 断了”的根因几乎总是传播**，这也是自动埋点库的核心价值。`traceparent` 末尾的**采样位让采样决定沿链传播**，[否则每跳各自随机会得到一堆残缺碎片](https://daiw.org/manual/observability/sampling)。

采样是追踪绕不开的话题——因为[它的成本随流量线性增长](https://daiw.org/manual/observability/three-pillars)。下一章。👉 [采样](https://daiw.org/manual/observability/sampling)。

## 参考资料

- [1] OTel Collector 源码 `pdata/ptrace/span_kind.go:17`、`generated_span.go`、`generated_spanevent.go`、`generated_spanlink.go`。
- [2] W3C Trace Context 规范（`traceparent` 格式）。
- [3] OpenTelemetry 官方文档：Traces、Context propagation、Semantic conventions。
