分布式追踪:因果链的精确重建
上一篇我们拥抱了宽事件——为每次请求保留高维度的原始数据,于是能在事后任意提问。但宽事件给你的,是一堆丰富的点:网关有一条、支付服务有一条、库存服务有一条。你能查到它们,可它们之间的因果关系——谁调用了谁、谁在等谁、慢到底卡在哪一跳——还需要被结构化地重建出来。
这就是分布式追踪(distributed tracing)要做的事。它把 04 结尾那个”手工用 trace_id 把日志串起来”的动作,升级成了一套系统化的、带父子结构的数据模型。它回答的,是指标和日志都答不出的那个分布式系统里最昂贵的问题:为什么慢?慢在哪一跳?
一、点与线:追踪在做什么
从一堆点到一棵树
设想一次结账请求,经过五个服务。宽事件让每个服务都为这次请求留下了一条丰富的记录——五个点。但现在你想知道:这五跳,是什么顺序、什么嵌套关系? 是网关串行调了订单、订单又串行调了库存和支付,还是库存和支付是并行的?那个 1.8 秒的总耗时,是哪一跳吃掉的?
光有五个点回答不了这些——你需要点之间的线,而且是带方向、带嵌套的线。把这些线连起来,你得到的不是一个列表,是一棵树:请求是树根,每一次下游调用是一个分支。追踪,就是把”一次请求”从一堆散点,重建成这样一棵有因果结构的树。
追踪的定义
收紧成定义:一次追踪(trace),是一个请求在整个分布式系统中传播的完整因果旅程——它由一系列有父子关系的操作(span)组成,记录了每一跳发生在哪、耗时多久、谁调用了谁。
关键词是”因果”和”完整”。日志是机会主义的片段,追踪是系统性的全程;日志是孤立的点,追踪是带结构的树。
它回答”为什么慢”
每一类信号都有它独占的问题域,前面几篇我们已经排过一次,追踪补上了最后、也最难的一块:
- 指标回答”多少”——P99 是 1.8 秒。
- 日志回答”发生了什么”——支付服务报了一条
declined。 - 追踪回答”为什么慢”——这 1.8 秒里,1.2 秒花在库存服务等一把数据库锁上,支付只占了 80 毫秒。
指标告诉你”变慢了”,但在一个几十个服务的系统里,“慢在哪一跳”是一个指标永远定位不到的问题——因为慢的是跨服务的某一段,而不是某个能被单独埋指标的点。追踪是分布式系统性能排查的唯一有效工具,没有之一。
二、数据模型:trace 与 span
追踪的全部能力,建立在两个数据结构上:trace 和 span。把它们解剖清楚,“因果链”就从一个比喻变成了具体的字段。
trace = 一次请求,span = 一跳
- Trace:一次完整的请求旅程,由一个全局唯一的
trace_id标识。前面那次结账,就是一个 trace。 - Span:旅程中的一个操作——可以是一次跨服务调用,也可以是一次数据库查询、一次缓存读取。一个 trace 由许多 span 组成。网关处理是一个 span,它调用订单服务是一个 span,订单服务查数据库又是一个 span……
trace 是整棵树,span 是树上的每一个节点。
span 的解剖
一个 span 长什么样,决定了追踪能告诉你什么。它的核心字段:
{
"trace_id": "abc-123", // 属于哪次请求(全程不变)
"span_id": "span-005", // 这个 span 自己的 id
"parent_span_id": "span-002", // 它的父——谁调用了我(因果结构的关键)
"name": "inventory.reserve", // 这一跳在做什么
"service": "inventory-service",
"start_time": "14:32:07.412",
"duration_ms": 1240, // 这一跳花了多久
"status": "ok",
"tags": { // 高维度上下文(就是宽事件的字段)
"db.statement": "SELECT ... FOR UPDATE",
"db.lock_wait_ms": 1190,
"user.tier": "premium"
}
}三个字段撑起了整个模型:trace_id 把所有 span 归到同一次请求,span_id 标识自己,parent_span_id 记录”谁调用了我”——正是这一个字段,让散落的 span 能被重建成有因果结构的树。duration_ms 则让你看清时间花在了哪。tags 携带的高维度上下文,本质上就是 宽事件的字段——一个带了 tags 的 span,就是一条挂在因果树上的宽事件。
父子关系重建调用树
有了 parent_span_id,重建就是一道简单的图算法:找到没有父的那个 span(树根,整个请求的入口),然后递归地把每个 span 挂到它父亲下面。结果是一棵树,通常画成甘特图的样子:
checkout (1840ms) ──────────────────────────────────────────
├─ order.create (1600ms) ───────────────────────────────
│ ├─ inventory.reserve (1240ms) ████████████████ ← 锁等待 1190ms,元凶在此
│ └─ payment.charge (80ms) ▌
└─ notify.send (40ms) ▌一眼就看出来:总耗时 1840ms,绝大部分被 inventory.reserve 的 1240ms 吃掉,而它的 tags 进一步告诉你,这 1240ms 里 1190ms 是在等一把数据库行锁。“慢在哪一跳、为什么慢”——这个指标和日志都答不出的问题,在这棵树上一目了然。 这就是”因果链的精确重建”最具体的样子。
和日志的区别:结构
回头看 04:当时我们也用 trace_id 把五个服务的日志串了起来。那和追踪差在哪?差在结构。
用 trace_id 串日志,你得到的是同一次请求的一堆日志,按时间排成一列——你知道它们属于同一次请求,但不知道谁调用了谁、谁嵌套在谁里面、谁和谁是并行的。而追踪因为有 parent_span_id,得到的是一棵树——因果、嵌套、并行关系全都在。日志的关联是”它们有关”,追踪的关联是”它们如何有关”。 多出来的这层结构,正是追踪的全部价值。
三、关键机制:context propagation
追踪的数据模型很优雅,但它有一个脆弱的前提:那个 trace_id,以及”我的父是谁”这个信息,必须能跨越进程、跨越服务、跨越网络地传递下去。这件事叫 context propagation(上下文传播),它是追踪的命脉,也是追踪之所以难的根源。
命脉:让 trace context 跨进程活下去
设想网关收到请求,生成了 trace_id=abc-123、span_id=span-001。现在它要去调用订单服务——这是一次跨进程的网络调用。订单服务怎么知道:自己属于 abc-123 这次 trace?自己的父 span 是 span-001?
这些信息(合称 trace context)必须跟着请求一起,被传到订单服务那边去。如果传丢了,订单服务就会以为自己是一次全新请求的开始,生成一个新的 trace_id——于是这次结账的因果链,在网关和订单之间,断了。
怎么传:W3C Trace Context
传递的方式,是把 trace context 塞进请求的 header。为了让不同语言、不同框架、不同厂商的系统能互相识别,业界统一到了 W3C Trace Context 标准——一个叫 traceparent 的 HTTP header:
traceparent: 00-abc123abc123abc123abc123abc12300-span001span001-01
│ └──────── trace_id ──────────┘ └─ parent_span_id ┘ └ flags
version (01=已采样)网关调订单服务时,在 HTTP 请求里带上这个 header;订单服务收到后,解析出 trace_id 和父 span_id,于是它知道自己属于哪次 trace、父亲是谁,然后生成自己的 span、继续往下游传。这个 header 像接力棒一样,在每一次跨服务调用时被交接,整条因果链才得以延续。 那个 flags 位还携带了”这条 trace 要不要被采样”的决定——第四章会用到。
断一次,就断一截
这套机制的脆弱之处,现在很清楚了:只要有一跳没有正确传递 traceparent,因果链就在那里断开。
回到 04 的故障剧场——当时我们说”漏传一次 context,追踪就在那一跳断裂”。现在你理解得更深了:某个服务用了一个没集成追踪的老 HTTP 客户端,调用下游时没带上 traceparent,于是下游所有 span 都脱离了这棵树,要么挂到一棵新的孤儿 trace 上,要么彻底丢失。你的甘特图会出现一段莫名其妙的空白,或者干脆缺了半棵树。
这就是为什么全链路插桩这么难:追踪要发挥价值,要求每一个服务、每一种通信方式(HTTP、gRPC、消息队列)、每一个框架都正确地传播 context。一个系统里只要有几个”哑巴”节点不传,整体的追踪质量就会千疮百孔。追踪的完整性,是一个木桶——取决于最短的那块板。
谁来传播
那么,谁来负责在每一跳传播 context?这是 仪器化负担这根标尺上的问题,有三个层次的答案,越往后人越省力:
- 手写 SDK:开发者在每次调用前后,手动取出、注入 context。精确,但繁琐到几乎不可能在大系统里贯彻。
- 自动插桩(auto-instrumentation):用 agent 或 monkey-patching,自动拦截框架层的调用(HTTP 客户端、gRPC stub),替你注入和提取
traceparent。覆盖大部分场景——这是 OpenTelemetry的主战场。 - 服务网格:连应用都不用碰,由 sidecar 代理在网络层注入和传播。零代码侵入——这是 10的主题。
传播的负担一路从应用代码,下沉到框架、再下沉到基础设施——这正是 那条"仪器化代价越来越低"的轨迹。
四、核心代价:采样
追踪的因果重建如此强大,但它有一笔躲不掉的账。这笔账怎么付,决定了整个追踪系统长什么样。
为什么必须采样
一个高 QPS 的系统,每秒几万次请求,每次请求几十个 span,每个 span 带一堆 tags。全量保留所有 trace,存储成本天文数字——而且大部分 trace 是健康的、长得几乎一样的,留着它们既贵又没用。
于是追踪几乎总要采样(sampling):只保留一部分 trace,丢弃其余。问题立刻来了——保留哪一部分? 这个看似简单的问题,分裂出两条根本不同的技术路线,而它们的差异,决定了系统架构。
头部采样:一进来就决定
头部采样(head-based sampling):在请求刚进入系统时(网关那一跳)就掷骰子决定——“这条 trace 采还是不采”,然后把这个决定写进 traceparent 的 flags 位,一路传下去。下游所有服务看到 flag,统一遵守。
- 优点:简单、低开销。每个服务不需要缓存任何东西,决定在源头一次做出、全程遵守。
- 致命缺点:决定做得太早——在请求出错或变慢之前就决定了。如果你采样率是 1%,那么 99% 的请求被丢弃,包括那些恰好出了错、最值得你看的请求。你最想要的那条 trace,很可能在它出错之前,就已经被源头的骰子判了”不采”。
尾部采样:等完了再决定
尾部采样(tail-based sampling):让请求先跑完,收集到整条 trace 的所有 span 之后,再决定留不留——而且可以基于结果来决定:“出错了?留。超过 1 秒?留。一切正常?按 1% 随机留一点做基线。”
- 优点:你能精准地”只留有价值的”——所有错误 trace、所有慢 trace 一条不漏,正常的只留个零头。这是排障真正想要的。
- 代价:架构复杂得多。系统必须先把所有 trace 的所有 span 都缓存下来(因为决定留不留之前,你不知道它会不会出错),等 trace 完成再判断、再丢弃。这需要一个专门的、有状态的采样层(比如一组 OTel Collector),扛住全量 span 的瞬时吞吐和内存。你为”精准采样”付出的,是一套额外的、不简单的基础设施。
采样策略是架构决策,不是配置项
这是这一章最想让你记住的:头部还是尾部,不是一个能随手改的配置开关,它是一个架构决策。
选头部,你的追踪系统可以很简单,但你接受”可能漏掉关键请求”。选尾部,你能抓住每一条有价值的 trace,但你得建设和运维一整套有状态的采样管道。这个选择牵动存储、网络、计算资源的分配方式——它是 人与资源校准在追踪系统里的一次具体落地:你愿意为”不漏掉关键 trace”,付多少架构复杂度?
★ 故障剧场:被采样漏掉的那条 trace
把这个代价亲手摸一遍。你的系统用 1% 头部采样,图个简单省钱。
某天,一个用户报告了一次诡异的结账失败。你打开追踪系统,按他的 user_id、按时间窗口搜索那条 trace——什么都没有。 因为这次请求在进入网关的那一刻,被 1% 的骰子判了”不采”,于是它经过的所有服务,一个 span 都没留下。
你陷入一个荒诞的处境:指标告诉你错误率确实有一个零点几的抬升(你知道有问题),但那条能告诉你”为什么”的 trace,被你自己的采样策略丢掉了。你拥有世界上最强大的因果重建工具,却恰恰没有那一次故障的数据。这就是头部采样的阿喀琉斯之踵——也是为什么对错误敏感的系统,往往不得不咬牙上尾部采样。
五、工具与边界
工具:Jaeger / Tempo / Zipkin
落到具体工具。追踪后端主要解决”海量 span 怎么存、怎么按 trace_id 查回来”:
- Zipkin:Twitter 开源的元老,模型简单,生态成熟。
- Jaeger:CNCF 项目,Uber 出品,功能完整,支持多种存储后端(Cassandra、Elasticsearch),是云原生追踪的主流选择。
- Grafana Tempo:思路和 Loki如出一辙——只按
trace_id建索引,trace 数据本身压缩进对象存储。它赌的是:“你通常是拿着一个trace_id(从日志或 Exemplar 跳过来)去查 trace,而不是全文搜 span。” 用查询灵活性换极低的存储成本——又一次 存储汇率的取舍,和 Loki 站在同一端。
边界一:追踪无法替代指标
追踪这么强,能不能干脆用它取代指标?不能,而且原因是根本性的,正好回扣 05 说的"分工":
- 采样让它算不准聚合:你只采了 1%(或只采了出错的),那这份数据在统计上是偏的。你没法用它算”今天总 QPS 是多少”——你手上根本不是全集。聚合统计要全量、无偏的数据,那是指标的活。
- 单请求视角看不到宏观趋势:一条 trace 讲的是一次请求的故事,它天生不擅长回答”过去一个月 P99 的变化趋势”这种跨海量请求的宏观问题。
所以追踪和指标是互补的:指标给你无偏的宏观趋势和告警(便宜、全量),追踪给你单次请求的因果真相(昂贵、采样)。排障的标准路径是”指标发现异常 → 追踪定位到哪一跳”,两者缺一不可。
边界二:看不进代码内部
追踪还有第二道边界。回到前面那棵调用树:它告诉你 inventory.reserve 这一跳花了 1240ms。但如果这一跳的慢,不是因为等数据库锁(那个 tag 帮了你),而是因为这个服务内部某个函数陷入了低效的循环、或者频繁触发 GC——追踪就止步了。
span 的粒度是”一个操作”,它看到的是服务的边界,看不进服务内部的代码。 某一跳慢,但它的子 span 都正常、tags 也看不出名堂——这时候追踪给你的答案是”就是这个服务内部慢”,至于内部哪个函数慢,它无能为力。这个盲区,正是 下一篇持续分析要补的。
它属于云原生范式
定位一下追踪的身份:它是系统性地记录因果(不是日志那种机会主义),它拥抱高基数(每个 span 带一个全局唯一的 trace_id,这在 指标里是绝对禁忌),它服务于”事后理解一次具体请求为什么这样”——这些都是 可观测性的特征。追踪是云原生范式里,把天平按在”提问端”的又一块重砝码。
六、小结:坐标与去向
追踪在五维标尺上的坐标
用 那把尺子给追踪定位:
| 维度 | 追踪的坐标 |
|---|---|
| ① 故障预判权 | 机器事后回答——不预判,事后重建任意请求的旅程 |
| ② 存储汇率 | 高基数 + 采样——付最高的存储税,再用采样把账压回来 |
| ③ 信号分工 | 回答”为什么慢”——指标/日志都缺的因果维度 |
| ④ 注意力阀门 | 不直接告警,是告警后的诊断工具 |
| ⑤ 仪器化负担 | 最重——要求全链路 context propagation,木桶效应 |
追踪是云原生范式里”看得最深”的信号,代价是它的仪器化负担也最重(全链路插桩)、存储税也最高(高基数 + 必须采样)。天平在它这里,重重压在提问端。
去向
追踪的两道边界,精确地指向接下来三篇:
- 某一跳慢在服务内部哪个函数?追踪看不进去的代码热点 → 07-持续分析-时间花在哪里的第四类信号。
- 全链路插桩这么难、context 传播标准这么关键、还想”一次插桩喂所有后端不被锁死” → 09-OpenTelemetry-三个信号的统一语言(W3C Trace Context 正是它的地基)。
- 连应用代码都不想碰,让基础设施替你传播 context、自动产出追踪 → 10-云原生可观测性-零侵入是如何做到的。
那个贯穿全系列的问题:追踪把天平推向了哪边?机器事后重建因果、拥抱高基数、付最高的仪器化与存储代价——它是为”理解一次具体请求为什么这样”而生的,是可观测性这座大厦里最精密、也最昂贵的那根梁。下一篇,我们钻进追踪看不进的地方——服务内部的代码,看持续分析如何回答”时间到底花在哪个函数上”。
返回 可观测性与运维工程 MOC | 上一篇 05-可观测性-从已知失败到任意提问 | 下一篇 07-持续分析-时间花在哪里的第四类信号