第 7 部分导论 · 追踪的本质
一个请求穿过十几个服务,怎么把它串成一条完整的路径?这一章讲清 trace/span 的数据模型、SpanKind 为什么重要、以及那个让整件事得以成立的机制——上下文传播。
第 7 部分导论 · 追踪的本质
三大支柱里,我们花了六个部分讲指标。这一部分补上另外两根——以及把它们串起来的那些桥。
先看追踪(tracing):它回答一个指标和日志都答不了的问题——“这一个请求,到底经历了什么?”
trace 和 span
数据模型简单到只有两个概念:
- span —— 一段有起止时间的工作。“在订单服务处理了 12ms”是一个 span。它有:名字、开始/结束时间、属性、状态(成功/失败)。
- trace —— 一次请求涉及的所有 span,靠
trace_id关联,靠parent_span_id组成一棵树。
trace_id = abc123
[网关 5ms] ← root span (parent = 无)
└─[鉴权 2ms] ← parent = 网关
└─[订单服务 12ms] ← parent = 网关
└─[查库存 1800ms] ★ ← parent = 订单服务(元凶!)
└─[写数据库 8ms]这棵树就是“火焰图”背后的数据。 一眼看出 1800ms 花在哪——这是指标(只有聚合数字)和日志(只有孤立事件)都给不了的:它揭示因果和结构。
SpanKind:比想象中重要
看源码 pdata/ptrace/span_kind.go:17(真实注释):
// 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 // 异步:消费消息SpanKind 不只是个标注,它决定了后端能不能正确理解你的系统:
Client+Server是一对 —— 调用方发出请求(client span)、被调方处理(server span)。两者之差就是网络耗时 + 排队时间。后端靠这个算出“这一跳在网络上花了多久”——这是纯粹的服务端指标永远看不到的(呼应第 2 部分说的“客户端超时服务端看不见”)。Producer+Consumer是异步的一对 —— 消息队列场景。它们之间可能隔了几秒甚至几分钟,而且不是父子关系(生产者不会等消费者)。后端要靠 kind 知道“别把这段等待算进请求延迟”。Internal—— 服务内部的一段工作,不跨进程。
搞错 kind 的后果很实际:比如把 client span 标成 internal,后端就算不出网络耗时;把 consumer 标成 server,队列里的等待时间会被算成“处理延迟”,你的 P99 看起来会离谱地高。
这也是语义约定的价值所在——它让后端能自动理解你的拓扑,而不需要你手工配置“谁调用谁”。
Span 之间不只有父子
ptrace 里还有两个容易被忽略的东西:
SpanEvent(generated_spanevent.go) —— span 内部的一个时间点。比如“在第 8ms 时发生了一次缓存未命中”、“在第 200ms 时抛了异常”。
它是“span 里的日志”——这正是 traces 和 logs 之间那座桥的一种形态:你不用另开一条日志,直接把事件挂在 span 上,天然带着 trace 上下文。异常堆栈通常就是这么记的。
SpanLink(generated_spanlink.go) —— 指向另一条 trace 的 span,但不是父子关系。
这个东西是为了表达那些树形结构表达不了的关系:
- 批处理 —— 一个批次任务处理了 100 条消息,它们来自 100 条不同的 trace。它不能有 100 个 parent(树只允许一个),但可以有 100 个 link。
- 扇入 —— 多个上游触发了同一个下游动作。
- 异步解耦 —— 生产者和消费者相隔太远,做成父子会让 trace 变得畸形(一条 trace 持续几小时)。
SpanLink 的存在承认了一个事实:分布式系统的因果关系不总是一棵树。 树是主干,link 是那些“跨树”的边——这让追踪能表达真实世界的复杂拓扑,而不是硬把一切塞进树形。
上下文传播:让这一切成立的机制
一个尖锐的问题:服务 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 在框架的各个缝隙里传好。
flags 里那个采样位
注意 traceparent 最后那个 01——它是 采样标志。
这个小小的 bit 有大意义:它让“要不要采样”这个决定沿着调用链传播**。** 如果网关决定“这条 trace 要采”,这个位就是 1,下游所有服务看到它都会采——于是你得到一条完整的 trace。
如果没有它会怎样? 每个服务各自随机决定采不采,那么一条 10 跳的 trace,每跳都有概率不采 → 你得到的全是残缺的碎片,几乎没有一条完整的。这类采样叫“头部采样”,下一章细讲,而这个传播位是它能工作的前提。
小结与下一章
追踪回答“这一个请求经历了什么”——揭示因果和结构,这是指标(聚合数字)和日志(孤立事件)都给不了的。模型只有两个概念:span(一段有起止的工作)和 trace(靠 trace_id 关联、parent_span_id 组成树)。SpanKind 比想象中重要:Client+Server 一对(两者之差 = 网络耗时,服务端指标看不到)、Producer+Consumer 异步一对(别把队列等待算进延迟)、Internal——搞错 kind 会让后端算错拓扑和延迟。还有 SpanEvent(span 里的时间点,相当于“带 trace 上下文的日志”)和 SpanLink(非父子的跨 trace 引用,为批处理/扇入/异步而生)——它的存在承认了:分布式的因果不总是一棵树。上下文传播是最脆弱也最关键的一环:traceparent 头必须穿过每一跳,进程内要透传 context、跨异步边界最容易断、第三方库不配合就断——“trace 断了”的根因几乎总是传播,这也是自动埋点库的核心价值。traceparent 末尾的采样位让采样决定沿链传播,否则每跳各自随机会得到一堆残缺碎片。
采样是追踪绕不开的话题——因为它的成本随流量线性增长。下一章。👉 采样。
参考资料
- [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。