50.3 链路(Trace / Span / 上下文传播 / 采样)

一句话

Trace 是「一次请求的完整调用树」,Span 是树上的一个节点;这棵树能不能还原,只取决于两件事——上下文有没有跨进程传下去,以及采样是在看见全貌之前还是之后做的。

Trace 与 Span

  • Trace:从请求到达系统边界开始,流经的每个服务、每次调用,直到响应返回,构成一条完整的调用链。
  • Span:调用链上的一个节点,记录「调用谁、什么顺序、何时开始、耗时多久、结果如何」。埋点就是「每次开始调用前先记一条 span」。
  • Trace 是一棵树(span 之间是父子/兄弟关系,不是简单的一条线),通常每个请求一棵。
Span 字段 含义 为什么必须有
trace_id 全局唯一的链路 id(W3C 规定 16 字节,32 位 hex) 把同一个请求的所有 span 归到一棵树
span_id 本节点 id(8 字节,16 位 hex) 被下游当成 parent_span_id
parent_span_id 父节点 id 缺了就只剩一堆孤立记录,没有树
name 操作名,模板化(GET /orders/{id}) 聚合与检索;带变量会基数爆炸
kind server / client / producer / consumer / internal 区分「跨进程边」与「进程内步骤」
start / duration 开始时间与本机单调时钟测的耗时 还原时序、算 self time
status OK / ERROR + 错误码 按错误筛链路
attributes 低基数业务/协议属性(http.status_code、db.system) 过滤与分组;高基数列进来就是灾难
events / links 时间点事件、跨 trace 的因果链接 异常点、异步消息的上下游关联

耗时归属:别把 total 相加

一次 120 ms 的请求:

Span total self 说明
GET /checkout(server,入口) 120 ms 30 ms 入口的 total 包含下面所有
SELECT orders(client→DB) 80 ms 80 ms 叶子节点,total = self
GET cart(client→缓存) 10 ms 10 ms 叶子节点
—— 30+80+10 = 120 只有 self time 可以相加

⚠️ 常见错误:把一棵树的 total 加总,得出「请求耗时 320 ms」。父 span 的 total 已经包含子 span。找瓶颈看两类:叶子节点的 total,或同层兄弟的 total 排序。

上下文传播:链路成不成立全在这一步

跨进程只传一个字符串,W3C Trace Context 规定的格式:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             │  └─ trace_id(32 hex)          └─ parent_span_id  └─ flags(01 = sampled)
             └─ version

四段缺一不可:version 决定解析方式,trace_id 决定归到哪棵树,parent_id 决定挂在哪个节点,flags 决定下游要不要记录。

package main

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"regexp"
)

type ctxKey struct{}

type SpanContext struct {
	TraceID string // 32 hex
	SpanID  string // 16 hex
	Sampled bool
}

var tpRe = regexp.MustCompile(`^00-([0-9a-f]{32})-([0-9a-f]{16})-(0[01])$`)

// Inject:把当前 span 的上下文写进 HTTP header
func Inject(ctx context.Context, h http.Header) {
	sc, ok := ctx.Value(ctxKey{}).(SpanContext)
	if !ok {
		return
	}
	flag := "00"
	if sc.Sampled {
		flag = "01"
	}
	h.Set("traceparent", fmt.Sprintf("00-%s-%s-%s", sc.TraceID, sc.SpanID, flag))
}

// Extract:解析失败就当没有——绝不自己造一个 trace_id 续上去
func Extract(h http.Header) (SpanContext, bool) {
	m := tpRe.FindStringSubmatch(h.Get("traceparent"))
	if m == nil {
		return SpanContext{}, false
	}
	return SpanContext{TraceID: m[1], SpanID: m[2], Sampled: m[3] == "01"}, true
}

func main() {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		sc, ok := Extract(r.Header)
		fmt.Printf("服务端收到 traceparent=%q\n  解析: ok=%v trace=%s parent=%s sampled=%v\n",
			r.Header.Get("traceparent"), ok, sc.TraceID, sc.SpanID, sc.Sampled)
	}))
	defer srv.Close()

	// 客户端:此处手工构造入口 span(真实场景由 otelhttp 等库生成)
	ctx := context.WithValue(context.Background(), ctxKey{}, SpanContext{
		TraceID: "4bf92f3577b34da6a3ce929d0e0e4736",
		SpanID:  "00f067aa0ba902b7",
		Sampled: true,
	})
	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
	Inject(ctx, req.Header)                                  // 关键:出站前注入
	req2, _ := http.NewRequest(http.MethodGet, srv.URL, nil) // 反例:上游没注入

	if _, err := http.DefaultClient.Do(req2); err != nil {
		panic(err)
	}
	if _, err := http.DefaultClient.Do(req); err != nil {
		panic(err)
	}
}

输出(本机实测):

服务端收到 traceparent=""
  解析: ok=false trace= parent= sampled=false
服务端收到 traceparent="00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
  解析: ok=true trace=4bf92f3577b34da6a3ce929d0e0e4736 parent=00f067aa0ba902b7 sampled=true

第二次请求断链的原因不是代码错,而是没有注入:下游只能起一条新 trace。断链的四种典型原因:

断链原因 现象 修法
异步/线程池丢 context 主线程有 span,异步任务里没有 显式把 context 传进任务;Go 里禁止 context.Background() 续命
MQ 生产端没注入 header 生产者树完整,消费者另起一棵 发消息时把 traceparent 写进消息头,消费时 extract 并建 span link
网关/代理重写或剥离 header 跨网关后 trace 断成两截 网关配置保留 traceparent/tracestate;自研网关要显式透传
sampled 标志不一致 上游不采、下游强采(或反之),出现半棵树 严格遵守上游 flags;下游要采样必须改 flags 并让上游也知道

🔑 只在入口注入一次 trace_id 是不够的:每一次跨进程调用都要 inject/extract。链路的完整性等于最弱那一跳。

三种采集方式

方式 原理 侵入性 精度/完整性 资源成本 能看到应用内 span 代表 适用
基于日志 trace/span 写进日志,事后从日志重建 极低(只加日志字段) 低:日志系统不保证完整、连续、实时 低 取决于日志打得多细 ELK 自建 小项目、已有 ELK
基于服务(探针/SDK) 应用内埋点或注入 agent,用独立通道上报 高(改代码或装 agent) 高:底层采集,实时、完整、精确 中–高 能 Jaeger + OTel SDK 大型分布式系统
基于边车(服务网格) Sidecar 拦截流量,生成/透传 span 低(应用无感) 服务级高,但看不到应用内部调用 中(每 Pod 一个代理) 不能 Istio + Jaeger/Tempo 已有 mesh、多语言、不想改代码

基于日志的方案为什么「便宜但不可靠」:日志系统本身允许丢弃、允许延迟,用它重建拓扑相当于用「采样后的旁证」还原「必须完整才能成立的树」。适合小系统,不适合作为主链路方案。

为什么链路难做:四个约束决定方案选型

约束 具体要求 不满足会怎样 哪类方案能过
低损耗 埋点开销占请求耗时的比例要小(通常追求 < 1%) 加个链路追踪,P99 涨 10 ms,业务方直接要求下线 采样 + 异步批量上报;避免同步写日志/写网络
对应用透明 不为埋点改业务逻辑 需要业务方配合改造,几十个团队排期半年 边车代理、语言级 agent、框架层中间件
自动扩容 追踪后端随应用一起伸缩 大促扩容了 10 倍服务,Collector 先挂,链路全丢 无状态 Collector + 队列削峰;Agent 随节点部署
持续监控 7×24 不中断,且自身不能成为故障源 追踪系统故障反过来拖垮业务(同进程写阻塞) 独立通道上报、失败丢弃不重试、限流保护

四条约束彼此冲突:要透明就会看不到应用内细节(边车),要细节就得侵入(SDK),要低损耗就得采样(丢证据),要 7×24 就得接受「上报失败时丢数据」而不是阻塞业务。方案选型的本质是给这四条排优先级——没有一种方案能四条全赢,能说清「我们放弃了哪一条」的选型才是成立的。

Jaeger 的四段架构

应用/Sidecar ──UDP/HTTP──▶ Agent(每节点 DaemonSet)
                              │ 批量、压缩
                              ▼
                          Collector ──校验/采样/写存储──▶ Storage(ES / Cassandra)
                              ▲                              │
                              └──────── 查询 ────────▶ Query + UI(看 span 树/甘特图)

为什么 Istio 要接 Jaeger:Envoy Sidecar 只能生成「服务之间」的 span,且分散在每个节点上;需要后端按 trace_id 把分散的 span 聚成完整链路图,并由 UI 呈现耗时分布。Istio 负责注入与传播 header(traceparent 或旧版 B3),Jaeger 负责聚合、存储、还原。

⚠️ 别把「剖析」当「链路」:pprof 采的是 CPU/内存/阻塞的调用栈,产出火焰图,不产出 span 树;画 span 树与甘特图的是 Jaeger/Tempo/SkyWalking 的 UI。两者互补——链路告诉你「哪一跳慢」,火焰图告诉你「那一跳的 CPU 花在哪个函数」(见 34.3 性能分析方法 (火焰图 / pprof / perf))。

采样:head 与 tail

维度 Head-based(头部采样) Tail-based(尾部采样)
决策时机 请求入口,按 trace_id 哈希或概率 收齐 span(或等 N 秒)后按规则决定
能保「错误/慢请求」吗 不能,决策时还不知道结果 能:status=ERROR 或超阈值 100% 保留
额外成本 客户端一次哈希,可忽略 Collector 要缓冲整条 trace,内存 ×(1/采样率)
多实例要求 无 同一 trace_id 的所有 span 必须到同一个 Collector(按 trace 路由 / 负载均衡导出器)
配置典型值 probabilistic: 0.01 latency > P99 或 status=ERROR 全留,其余 0.1%

尾采样的容量账:1000 QPS × 10 span × 500 B = 5 MB/s 的原始流量;若决策窗口 30 s,单个 Collector 至少需要 150 MB 缓冲(还要乘突发系数)。而采样 1% 后落地只有 50 KB/s。所以尾采样是「用 Collector 的内存换证据密度」,不是免费的。

采样怎么污染统计

问题 head 1% tail 保错 1%
估 P99 延迟 可以(1 万样本足够) 可以,但需要按采样策略加权
发现 0.01% 的罕见错误 几乎不能:100 万请求才 100 条,采完剩 1 条 能,错误 100% 保留
计算「错误率」 近似无偏,但样本太少 不可用:分母被采样改变,错误被 100% 保留,算出来远高于真实值

🔑 一句话:指标回答「有多少次慢」,链路回答「这一次为什么慢」。把 trace 当错误率的数据源,就会把采样偏差当故障。

采样与传播的验收

要验的 指标 / 方法 通过标准
上下文没断 一次跨 3 个服务的请求,trace 里应有 3 段以上 span 任一服务没有父 span = 传播断
采样比例 otelcol_processor_tail_sampling_count_traces_sampled(按 sampled 标签分组;具体指标名以你的 Collector 版本为准) ≈ 配置值;错误 trace 必须 100% 保留
采集没丢 otelcol_exporter_send_failed_spans、otelcol_receiver_refused_spans 持续为 0;突增 = 后端限流或队列满
时钟没漂 每个 span 的 start < end,子 span 落在父 span 区间内 出现负 duration = 机器时钟漂移
# 造一条链路:带上游传入的 traceparent,检查下游是否沿用同一个 trace_id
curl -s -H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' \
  https://api.example.com/api/order

# Collector 侧:采样与丢弃
curl -s localhost:8888/metrics | grep -E 'tail_sampling_count_traces|send_failed_spans|receiver_refused_spans'

回滚采样策略要算成本:tail_sampling 关掉 → 全量写后端,写入量按 QPS × 平均 span 数上涨;反过来 100% → 1% 会让排障少掉大部分长尾 trace。先确认后端容量,再动采样开关。

时钟与因果

  • span 的时间戳来自各自机器的本地时钟,跨主机可能有毫秒级偏差(NTP 同步下通常 < 10 ms,但会跳变)。
  • 因此还原因果要用 parent_span_id 的父子关系,不要用绝对时间戳排序去推调用顺序。
  • duration 用本机单调时钟计算,比「结束时间戳 − 开始时间戳」可靠(不受时钟回拨影响)。时钟与因果的理论背景见 23.1 物理时钟与漂移。

与日志、指标的分工

你要回答 用链路 不要用链路
这次请求的 120 ms 花在哪 ✅ span 树 + self time ——
跨服务是谁拖慢了谁 ✅ client span 与 server span 成对比较 用日志时间戳对齐(时钟偏差会骗人)
错误率是多少、有没有越过 SLO ❌ 用指标(采样会污染分母)
这个用户这次报错的参数是什么 ❌(属性要高基数) 用日志(带 trace_id)
03:12 那批请求为什么都超时 ✅ 先找一条慢 trace,再跳日志 翻全量日志(成本高几个数量级)

反模式

反模式 后果
把 trace 当审计日志 采样后不完整、保留期短,审计无法取证
生产环境 100% 采样 1000 QPS 就是 432 GB/天,写爆后端与网络
span 名带变量(/orders/8812) 基数爆炸,聚合查询失效;用模板名 + 属性
span 属性放 user_id、完整 SQL、请求体 高基数 + 泄漏敏感数据 + 体积失控
只埋入口不埋出口 只看到「这个服务 120 ms」,看不到「其中 80 ms 在 DB」
异步/消息不建 link 生产者与消费者变成两棵独立树,因果丢失
不遵守上游 sampled 标志 半棵树:有的 span 在、有的不在,链路图缺枝
用 context.Background() 起 goroutine context 断链,异步段的 span 全部丢失

自检

Q1 traceparent 里哪一段决定「下游要不要采样」?这个标志为什么必须传播?

▸ 答案:最后一段 flags,01 表示 sampled。必须传播是因为它决定「这棵树还剩几枝」:上游采、下游不采 → 链路缺尾巴;上游不采、下游强采 → 只有下游片段,父节点不存在,树拼不起来。采样决策必须全链路一致,这也是 tail sampling 要等整条 trace 收齐才能决策的原因。

Q2 为什么两个服务的 span 耗时相加会超过入口请求的耗时?

▸ 答案:父 span 的 duration 包含子 span,本身就重叠。入口 120 ms 里 DB 占 80 ms、缓存 10 ms、自身逻辑 30 ms,相加是 120 ms;如果把父与子一起加,就是 240 ms。正确做法是只看 self time(total − 子 span total 之和),或比较同层兄弟。

Q3 head 采样 1%,能不能判断「错误率从 0.1% 涨到 0.2%」?

▸ 答案:不能。100 万请求里 0.1% 是 1000 条错误,1% 采样后只剩 10 条,统计噪声远大于 0.1 个百分点的差异。错误率必须来自指标的全量计数;trace 只用来挑一条具体错例看细节。若用 tail 采样把错误 100% 保留,trace 里的错误率又会远高于真实值——同样不能当指标用。

Q4 已经用 Istio + Jaeger,为什么还要在应用里埋 span?

▸ 答案:Sidecar 只能看到「进出 Pod 的流量」,能给出服务级调用与耗时,但看不到应用内部的步骤:DB 查询、缓存访问、循环里的 N 次 RPC、业务分支。要定位「这 120 ms 里 80 ms 花在哪条 SQL」,必须在应用内埋 span;Sidecar 的 span 会成为这些内部 span 的父节点。两者配合才是完整链路。

Q5 为什么 tail sampling 要求同一 trace 的 span 到同一个 Collector?

▸ 答案:tail sampling 需要先看完整条 trace 再决定保留与否。如果 span 被哈希分散到多个 Collector,每个实例只看到片段,就无法判断「这条 trace 有没有错误」,也无法输出完整树。所以要么按 trace_id 路由(同一 trace 落到同一实例),要么先做一层负载均衡再统一决策。

关联