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 落到同一实例),要么先做一层负载均衡再统一决策。