Skip to content
Published on

分布式追踪真正回答的问题 — 跨度、采样,以及时间到底花在哪里

分享
Authors

引言 — 反馈说慢,却不知道是哪个服务

有人反馈结算页面要花 2 秒。打开仪表盘一看,网关的 p99 已经涨到 1.8 秒。它后面挂着十个服务,于是逐个打开各自的 p99 看。全都正常。

这种局面很常见。而且在这种局面下,指标在原理上给不出答案。「每个服务单独看都很快」和「依次穿过它们的那一次请求很慢」这两个事实并不矛盾。你需要的不是分服务的统计,而是单次请求的完整路径。

本文要讲的就是如何构建并读懂这条路径。

追踪、跨度与上下文传播的结构

追踪是共享同一个追踪 ID 的一棵跨度树。每个跨度都有名称、开始时刻、结束时刻、父跨度 ID、属性、事件和状态。此外它还有种类(kind),而这一点在实务里比想象中更重要。

  • SERVER:接收并处理请求的一侧
  • CLIENT:调用其他服务的一侧
  • PRODUCER / CONSUMER:发送消息的一侧与接收消息的一侧
  • INTERNAL:进程内部的逻辑区间

CLIENT 跨度与 SERVER 跨度之间的时间差,就是消耗在网络和排队上的时间。只有两个跨度都在,才能得到这个值。如果只埋了一侧,你就只能知道「这次调用花了很久」,而分不清原因是网络还是对端服务。

一条完整的追踪看起来是这样的。

Trace 4bf92f3577b34da6a3ce929d0e0e4736                    共 1,847ms
├─ SERVER    api-gateway   POST /v1/orders                    1847ms
│  ├─ CLIENT    auth-svc     GET /verify                         38ms
│  ├─ CLIENT    checkout     POST /orders                      1782ms
│  │  ├─ SERVER   checkout    POST /orders                     1776ms
│  │  │  ├─ INTERNAL  cart.validate                               6ms
│  │  │  ├─ CLIENT    inventory  GET /stock                      41ms
│  │  │  ├─ CLIENT    pricing    POST /quote                     52ms
│  │  │  ├─ CLIENT    db.query   SELECT coupons WHERE ...      1612ms   <-- 就是这里
│  │  │  └─ CLIENT    payment    POST /charge                    64ms
│  │  └─ (网络 + 排队 6ms)
│  └─ INTERNAL  response.serialize                                9ms

上下文传播是构建这棵树的唯一机制。调用方把当前的追踪 ID 和自己的跨度 ID 放进请求头,接收方读出来,把它当作自己跨度的父级。标准请求头是 W3C 的 traceparent

# 网关调用 checkout 时实际发出去的请求头
curl -sD - -o /dev/null http://checkout.internal/v1/orders \
  -H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'

# 00                                 版本
# 4bf92f3577b34da6a3ce929d0e0e4736   追踪 ID — 整个请求中保持一致
# 00f067aa0ba902b7                   父跨度 ID — 每一跳调用都会变
# 01                                 采样标志 — 如果是 00,这条追踪不会被保存

传播断掉的位置基本上是固定的那几个。自动埋点包不住的自研 HTTP 客户端、把任务交给线程池或 worker 的代码、消息队列,以及第三方代理把请求头抹掉的情况。如果追踪短得反常,就从这四处开始看。

日志和指标回答不了的问题

把追踪的必要性解释成「因为它是第三种信号」,落地一定会失败。你得知道它究竟回答什么问题。

第一,单次请求把时间花在了哪里。指标是聚合值,无法还原单次请求的路径。服务 A 的 p99 和服务 B 的 p99 并不保证属于同一次请求。上面那条追踪里花掉 1,612ms 的优惠券查询,在那个数据库的平均查询时间仪表盘上绝对看不见。一天几百万次里这种请求只占 1 个百分点的话,平均值纹丝不动。

第二,重复调用问题。N+1 查询在指标上看不见,因为单条查询只要 4ms,很快。问题在于它在一次请求里执行了 340 次,而这只有数跨度才能看出来。

├─ SERVER  order-api  GET /v1/orders/A-99183                     1421ms
│  ├─ CLIENT  db.query  SELECT * FROM orders WHERE id = ?           5ms
│  ├─ CLIENT  db.query  SELECT * FROM order_items WHERE oid = ?     4ms
│  ├─ CLIENT  db.query  SELECT * FROM products WHERE id = ?         4ms
│  ├─ CLIENT  db.query  SELECT * FROM products WHERE id = ?         4ms
│  ├─ ... (同一条查询 340 次) ...

第三,条件路径。只在特定租户、特定功能开关、特定缓存未命中组合下才变慢的情况。日志只能给出每个服务的一个切面,而把这些切面按时间配对拼起来只是猜测。追踪把那个组合作为一个对象展示出来,并且能按属性过滤。

反过来,追踪回答不了什么也很清楚。从什么时候开始变差、有多少用户受影响,这些是指标的问题。那个跨度里收到了什么值、走了哪个分支,这是日志的问题。追踪回答「在哪里」,指标回答「什么时候、有多少」,日志回答「为什么」。

埋点 — 自动埋点的边界与必须补手动跨度的位置

起点是自动埋点。不改代码,I/O 边界上就会生出跨度。

npm i @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node

OTEL_SERVICE_NAME=checkout-api \
OTEL_RESOURCE_ATTRIBUTES=service.version=1.42.3,deployment.environment=prod \
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector.observability:4318 \
OTEL_TRACES_SAMPLER=parentbased_traceidratio \
OTEL_TRACES_SAMPLER_ARG=1.0 \
node --require @opentelemetry/auto-instrumentations-node/register server.js

parentbased_traceidratio 会原样沿用父跨度的决定,只有在没有父级时才按比例判断。这应该是默认值。如果每个服务各自独立做概率判定,追踪就会在中途被截断。

自动埋点给你的东西恰好只有一样。网络边界。对象是 HTTP 服务端与客户端、gRPC、数据库驱动、Redis 以及消息队列客户端。

自动埋点看不见的东西也只有一样。进程内部发生的一切。内存排序、模板渲染、加解密、序列化、锁等待、事件循环延迟,都不会体现为任何跨度。它们只会以父跨度的 self time,也就是子跨度没有占用的那段时间显现出来。

├─ SERVER  report-api  GET /v1/reports/monthly              3204ms
│  ├─ CLIENT  db.query   SELECT ... FROM ledger              412ms
│  └─ CLIENT  s3.upload  PUT report-2026-07.pdf              260ms
│     self time = 3204 - 412 - 260 = 2532ms  <-- 未埋点的区间

一旦发现 self time 很大的跨度,就往里面补手动跨度。需要补的位置有五处。

  1. 循环与批处理边界 — 把迭代次数作为属性留下。
  2. 缓存查询与未命中路径 — 把是否命中作为属性留下,缓存性能就能在追踪里直接看到。
  3. 没有自动埋点的第三方 SDK 调用
  4. 锁等待、队列等待、连接池等待
  5. 序列化、压缩、图像处理这类 CPU 区间
import { trace, SpanStatusCode } from '@opentelemetry/api'

const tracer = trace.getTracer('checkout', '1.42.3')

export async function applyPromotions(cart, tenantId) {
  return tracer.startActiveSpan('checkout.applyPromotions', async (span) => {
    span.setAttribute('cart.item_count', cart.items.length)
    span.setAttribute('promotion.engine', 'rules-v3')
    span.setAttribute('tenant.id', tenantId)

    try {
      const cached = await rules.fromCache(tenantId)
      span.setAttribute('promotion.cache_hit', Boolean(cached))

      const result = await (cached ?? rules.compile(tenantId)).evaluate(cart)
      span.setAttribute('promotion.rules_evaluated', result.evaluated)
      span.setAttribute('promotion.matched_count', result.matched.length)
      return result
    } catch (err) {
      span.recordException(err)
      span.setStatus({ code: SpanStatusCode.ERROR, message: err.message })
      throw err
    } finally {
      span.end()
    }
  })
}

跨度名必须是低基数的。不是 GET /v1/orders/A-99183,而是 GET /v1/orders/:id。后端会按跨度名分组,名字里一旦带上 ID,所有聚合视图就全塌了。具体的值要作为属性发送。

采样 — 头部采样的陷阱与尾部采样的价值

对大多数组织来说,全量保存在成本上说不通。问题在于丢掉什么。

头部采样在创建根跨度的那一刻,也就是什么都还没发生的时候就做决定。因为不知道结果,只能随机丢弃。而随机丢弃意味着:罕见事件会被按其罕见程度精确地丢掉。

python3 - <<'PY'
rate = 0.01              # 头部采样 1%
errors_per_hour = 5      # 想排查的错误的真实发生频率
kept = errors_per_hour * rate
print(f"被保留的错误追踪: 每小时 {kept:.2f} 条")
print(f"想看到 1 条平均要等 {1/kept:.0f} 小时")
PY
# 被保留的错误追踪: 每小时 0.05 条
# 想看到 1 条平均要等 20 小时

这就是头部采样的实际结局。需要排查的时候,那条追踪不在。而在的那些追踪全是正常请求,没有理由去看。

尾部采样会把跨度先缓存起来直到追踪完成,再看结果做决定。有错误就留,慢就留,其余只留一小部分。

# otel-collector-tailsampler.yaml
processors:
  tail_sampling:
    decision_wait: 10s
    num_traces: 100000
    expected_new_traces_per_sec: 2000
    policies:
      - name: keep-errors
        type: status_code
        status_code:
          status_codes: [ERROR]

      - name: keep-slow
        type: latency
        latency:
          threshold_ms: 800

      - name: keep-vip-tenants
        type: string_attribute
        string_attribute:
          key: tenant.tier
          values: [enterprise]

      - name: baseline
        type: probabilistic
        probabilistic:
          sampling_percentage: 2

尾部采样不是免费的,这一点必须说清楚。它有三项成本。

第一,从 agent 到采集器的传输量并不会减少。因为所有跨度都得先到达采集器才能做判定。省下来的只是后端的存储与索引成本。采集器自身的 CPU 和网络反而会增加。

第二是内存。在 decision_wait 期间必须把所有跨度都攥在手里。

python3 - <<'PY'
traces_per_sec, wait_sec, spans, span_kb = 2000, 10, 12, 1.2
mb = traces_per_sec * wait_sec * spans * span_kb / 1024
print(f"缓冲内存大约 {mb:.0f} MB (留两倍余量就是 {mb*2:.0f} MB)")
PY
# 缓冲内存大约 281 MB (留两倍余量就是 563 MB)

第三,也是最容易出错的部分。同一条追踪的所有跨度必须到达同一个采集器实例。如果把采集器横向扩成多台,前面放一个普通负载均衡器,那么一条追踪的跨度就会散落到多个实例上,各自看着残缺的追踪做判定。结果就是被随机截断的追踪。解法是加一层按 trace ID 路由的网关。

# otel-collector-gateway.yaml — 在前端按 trace ID 路由
exporters:
  loadbalancing:
    routing_key: traceID
    protocol:
      otlp:
        tls:
          insecure: true
    resolver:
      dns:
        hostname: otel-tailsampler.observability.svc.cluster.local
        port: 4317

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [loadbalancing]
项目头部采样尾部采样
决定时点创建根跨度时追踪完成并经过 decision_wait 后
慢请求的保留只能靠概率按规则 100%
错误追踪的保留只能靠概率按规则 100%
agent 与网络成本按采样比例下降没有下降
后端存储成本下降下降
采集器内存可忽略按每秒追踪数乘以等待时间缓冲
运维要求必须按 trace ID 路由
配置位置SDK 环境变量采集器处理器

实务上会把两者组合起来。流量特别大的服务先用头部采样压到 10~50 个百分点的水平,再在上面叠尾部采样,把错误和慢请求捞出来。在头部就已经丢掉的东西,尾部救不回来,所以原则上头部比例要在承受得起的前提下尽量调高。

跨度属性与异步边界 — 基数、队列、链接

「追踪里也要小心基数」这条建议只对了一半。跨度属性的高基数和指标那边不一样,不是爆炸问题。订单 ID、用户 ID、查询参数,本来就是为了放进追踪而存在的值。没有它们,追踪就变成一张没法过滤的图。

出问题的地方有两处。

第一,把跨度转换成指标的时候。用 spanmetrics 连接器从跨度生成 RED 指标时,你指定为维度的属性会直接变成指标标签。往里面塞用户 ID,时间序列就会爆炸。

connectors:
  spanmetrics:
    # 只有列在这里的才会变成指标标签 — 只放低基数的
    dimensions:
      - name: http.route
      - name: http.request.method
      - name: deployment.environment
    histogram:
      explicit:
        buckets: [10ms, 25ms, 50ms, 100ms, 250ms, 500ms, 1s, 2s, 5s]

第二,跨度本身的大小。OpenTelemetry SDK 默认会限制每个跨度的属性个数(默认 128),但对属性值的长度没有默认上限。把整个请求体塞进属性,一个跨度就能有几百 KB,传输和存储成本会原封不动地跟上来。有需要就显式设限。

OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT=64 \
OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT=2048 \
node --require @opentelemetry/auto-instrumentations-node/register server.js

异步边界更棘手。在消息队列里,生产者的跨度早就结束了,消费者才开始动。直接沿用父子关系的话,父级已经结束,追踪的时间轴就会变得很怪,而批量消费者根本就没法表达。一次处理 100 条消息就有 100 个父级,可跨度只能有一个父级。

解法是链接。

import { propagation, context, trace, SpanKind } from '@opentelemetry/api'

// 生产者: 把上下文注入到消息头里
export async function publish(order) {
  return tracer.startActiveSpan('orders.publish', { kind: SpanKind.PRODUCER }, async (span) => {
    const headers = {}
    propagation.inject(context.active(), headers)
    await producer.send({ topic: 'orders', messages: [{ value: JSON.stringify(order), headers }] })
    span.end()
  })
}

// 批量消费者: 把每条消息的上下文作为链接而不是父级挂上去
export async function consumeBatch(messages) {
  const links = messages
    .map((m) => trace.getSpan(propagation.extract(context.active(), m.headers))?.spanContext())
    .filter(Boolean)
    .map((spanContext) => ({ context: spanContext }))

  return tracer.startActiveSpan(
    'orders.processBatch',
    { kind: SpanKind.CONSUMER, links },
    async (span) => {
      span.setAttribute('messaging.batch.message_count', messages.length)
      await Promise.all(messages.map(handle))
      span.end()
    }
  )
}

如果消费者一次只处理一条消息,用父子关系接起来也可以。只是队列等待时间一长,单条追踪的持续时间就会变成好几个小时,后端处理起来会很吃力。等待时间长的管道更实际的做法是用链接切开,再把队列等待时间作为属性记录下来。

实战 — 在追踪界面上到底要找什么

工具开着却不用,最大的原因就是没有排查顺序。下面这个顺序在大多数情况下都管用。

  1. 从指标开始。先确定是哪条路由、哪个时间段变差了。一上来就漫无目的地翻追踪,只会白白耗时间。
  2. 在服务图里找延迟或错误率上升的那条边。调用关系本身是否发生了变化,也能在这里看出来。
  3. 看那条路由、那个时间段里持续时间排在前列的追踪列表。大多数后端都提供按持续时间和属性过滤的查询。
  4. 打开一条追踪,找 self time 最大的跨度。不是最长的跨度,是 self time 最大的跨度。最长的那个通常是根跨度,没有信息量。
  5. 看那个跨度的属性。是缓存未命中,还是特定租户,重试了几次。
  6. 用同一个 trace ID 去查日志。「为什么」就出在这里。

这里需要一条纪律。不要只看一条慢追踪就下结论。一条可能只是偶然。要打开多条相同条件下的追踪确认共同模式,并和正常的追踪并排比较。好的后端会为此提供按跨度的持续时间分布或追踪对比视图。

OpenTelemetry 已经标准化的部分与尚未标准化的部分

这会影响引入决策,所以要知道边界在哪里。

已经标准化、可以放心依赖的部分是 API 与 SDK 的结构、OTLP 传输协议、基于 W3C 的上下文传播,以及采集器。只要埋点代码是用 OpenTelemetry API 写的,换后端时基本不需要改代码。HTTP 这类核心领域的语义约定也已经稳定。

而仍在变动的部分也存在。若干领域的语义约定还处在实验阶段或正在改名,各语言 SDK 的成熟度差异也很大。从跨度生成指标的方式、服务图的生成逻辑各家后端都不一样,查询追踪的语言也没有标准。依赖这一领域的仪表盘和告警,设计时就要当成是被绑定在后端上的。

结语 — 追踪是回答「在哪里」的工具

要记住的只有一句话。追踪是回答「单次请求把时间花在了哪里」的工具,而这个答案只有在上下文没有断、并且那条追踪没有被丢弃时才存在。

于是引入的顺序也就定下来了。先确认传播。如果追踪在服务边界上就断了,其他所有投入都毫无意义。接着看采样。如果只用头部采样,那么在需要排查的那一刻追踪就不在,所以要把尾部采样和 trace ID 路由一起引入。最后在 self time 大的区间补上手动跨度,把自动埋点留下的空白填上。

最先该做的事,是现在就在生产环境里打开一条追踪看看。如果有十个服务却只看得到三个跨度,那就是你下周要做的全部工作。

值得继续深入的资料。