分布式追踪:因果链的精确重建

上一篇我们拥抱了宽事件——为每次请求保留高维度的原始数据,于是能在事后任意提问。但宽事件给你的,是一丰富的点:网关有一条、支付服务有一条、库存服务有一条。你能查到它们,可它们之间的因果关系——谁调用了谁、谁在等谁、慢到底卡在哪一跳——还需要被结构化地重建出来。

这就是分布式追踪(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-123span_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,木桶效应

追踪是云原生范式里”看得最深”的信号,代价是它的仪器化负担也最重(全链路插桩)、存储税也最高(高基数 + 必须采样)。天平在它这里,重重压在提问端。

去向

追踪的两道边界,精确地指向接下来三篇:

那个贯穿全系列的问题:追踪把天平推向了哪边?机器事后重建因果、拥抱高基数、付最高的仪器化与存储代价——它是为”理解一次具体请求为什么这样”而生的,是可观测性这座大厦里最精密、也最昂贵的那根梁。下一篇,我们钻进追踪看不进的地方——服务内部的代码,看持续分析如何回答”时间到底花在哪个函数上”。

返回 可观测性与运维工程 MOC | 上一篇 05-可观测性-从已知失败到任意提问 | 下一篇 07-持续分析-时间花在哪里的第四类信号