#DevOps

分布式链路追踪:OpenTelemetry

微服务下一个请求经过 N 个服务,出问题怎么定位是哪个?分布式链路追踪解决这个。本文讲清 trace/span/上下文传播、OpenTelemetry 标准、采样策略、以及怎么用 trace 定位慢调用与跨服务故障。

本文要点

  • 主题聚焦:DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年5月10日·更新于 2026年7月23日

微服务上线第一天,大家都很满意:订单服务、支付服务、库存服务、通知服务,各管一摊,清晰漂亮。第三天凌晨,值班被叫醒——用户下单卡住五秒才返回,客户端在转圈。你登订单服务看日志,正常;切支付服务,日志里没有报错;问库存,库存说自己很快。每个服务都"看起来没问题",但请求就是慢。

这是单体时代不存在的痛。单体里一个请求从头到尾在一个进程内,慢在哪一行,profile 一下就知道。微服务把一次请求拆成 N 段横跨 N 个进程,日志散落在 N 台机器上,每段单独看都正常,加起来就慢了——而你没有工具把它们串起来。分布式链路追踪(Distributed Tracing)就是来解决"串起来"这件事的:它给一次请求画一棵完整的调用树,告诉你时间花在哪一段、错在哪一段、跨了哪些服务。

一、核心概念:trace、span、trace context

理解链路追踪,三个词够了。

Trace(链路):一次完整请求从入口到返回的全过程。一笔订单从用户点"提交"到页面返回 200,这就是一个 trace。它有一个全局唯一的 trace_id,是这棵树的根。

Span(跨度):一次操作,是 trace 树上的一个节点。一次 HTTP 调用、一次 SQL 查询、一次 Redis GET,都可以是一个 span。每个 span 有自己的 span_id、起止时间、parent 指针、属性(attribute)和状态(成功/失败)。trace 就是一堆 span 用父子关系串起来的有向无环树。

Trace Context(上下文):trace_id 不是凭空在每个服务里出现的——它要在服务之间"传"过去。这个被传递的载体就是 trace context,通常包含 trace_id、当前 span 的 parent_span_id、采样标记。

看一棵真实的 span 树长什么样:

text
[order]      POST /api/order                          (520ms, trace root)
├── [order]    db.query  SELECT stock                 (12ms)
├── [payment]  POST /charge                           (480ms)
│   ├── [payment]   db.query  INSERT tx              (8ms)
│   └── [gateway]   HTTPS POST  /v1/charge           (460ms)   ← 慢点在这
└── [inventory] reserve                              (25ms)

一眼就能看到:520ms 总耗时里,460ms 花在了支付服务调外部网关那一段。不用猜,不用截图发群问,树自己说话。这就是追踪的价值——它把"分布式"重新变成了"能在一个屏幕上看完"。

二、上下文传播:trace id 怎么跨服务传

一棵跨服务的树,前提是所有 span 共享同一个 trace_id。这个 id 怎么从前一个服务传到后一个?答案是在出站请求里塞一个 HTTP header,后一个服务从 header 里读出来,作为自己 span 的 parent,再往下传。这个过程叫上下文传播(Context Propagation)。

业界事实标准是 W3C Trace Context 定义的 traceparent 头,格式就一行:

text
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
             ^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^ ^^
             版本  trace-id(32 hex)              span-id(16 hex)    采样标记

订单服务发请求给支付服务时,HTTP client 自动把这行头加进去;支付服务的框架在收到请求的最早时刻,把 traceparent 解出来,基于它创建一个子 span,然后继续往后传。除了 traceparent,W3C 还定义了一个可选的 tracestate 头,给厂商扩展用(比如携带上一次采样的轨迹),一般场景用不上。

这一步看着简单,却是链路追踪最容易出问题的环节。上下文断在某一段,后面所有 span 就成了孤儿,树就拼不起来了。常见的断点:

  • 用了非托管 HTTP client:框架默认注入的 instrument 只覆盖主流 client,你自己 fetch 一个裸请求又没手动 propagate,这段就断了。
  • 跨异步边界丢了上下文:进了 setTimeout、消息队列消费者、setImmediate,context 没跟着带过去。OTel 的 context API 要显式传递。
  • 跨进程走的是非 HTTP 协议:消息队列(Kafka/RabbitMQ)的 header 里也得注入和提取,这需要对应 instrument 的支持。

判断上下文通不通的方法很简单:看任意一个下游服务的 span,它的 parent_span_id 应该指向上游服务的某个 span。如果 parent 为空或 trace_id 在某一段变了,那就是断了——这是接入链路追踪第一周必踩的坑。

三、OpenTelemetry:统一标准,厂商无关

早些年做链路追踪,你得在 Jaeger client、Zipkin client、SkyWalking client 里挑一个,埋点代码绑死在后端选型上。换后端,SDK 全部重埋。这是典型的厂商锁定,业界受够了。

于是有了 OpenTelemetry(OTel)——CNCF 主推的可观测数据采集标准,由 OpenTracing 和 OpenCensus 两个老项目合并而来。它的核心定位是:采集归采集,后端归后端。你只用 OTel SDK 埋点,产出的数据按 OTLP 协议导出,后端是 Jaeger、Tempo、Datadog 还是阿里云 ARMS,都靠 OTLP 或其他 exporter 一键切换,业务代码一行不改。

OTel 的"全家桶"涵盖三支柱(traces / metrics / logs),但今天最成熟、用得最多的还是 traces。它的落地分三层:

  1. API + SDK:你的代码里调 tracer.startSpan() 这类 API;SDK 是 API 的实现,负责生成、聚合 span。
  2. Instrumentation(自动埋点):给 HTTP、gRPC、数据库 driver、消息队列这些主流库打补丁,自动产出 span。绝大多数业务代码不用改一行,就能拿到 80% 的链路覆盖——这是 OTel 最大的工程红利。
  3. Collector(采集器):一个独立进程,接收应用导出的数据,做批处理、重采样、脱敏、路由,再转发给后端。Collector 是生产部署的关键组件,把应用和后端解耦,也承载了 tail sampling(下一节讲)。

一套 OTel SDK 同时产出 trace、metrics、logs 三种信号,且它们共享同一个 trace_id——这点第七节会展开,它是三支柱能串联的根基。

四、采样:全量追踪太贵,怎么取舍

链路追踪最大的成本不是 CPU,是后端存储和写入。一个稍微像样的服务,每天产生几亿条 span 很正常,全量存下来,要么是钱烧光,要么是后端先挂。所以采样是必须的,问题不是"要不要采",是"怎么采得聪明"。

两种基本策略:

Head-based(头决策):在 trace 入口处就按概率决定要不要这条 trace,决定后通过 traceparent 的采样标记位一路传下去,所有下游 span 看到这个标记后跟着采或不采。简单、零额外协调、对延迟无损。缺点是它在 trace 开始时做决定,这时候还不知道这条请求最终会不会出错——所以一个 1% 采样的策略,会错过 99% 的错误请求,而错误恰恰是最该被采的。OTel 默认的 ParentBased(AlwaysOn) / TraceIDRatioBased 都属于这类。

Tail-based(尾决策):先在内存里把整条 trace 的所有 span 攒齐,等它结束后,根据"它出错没""它慢不慢""它命中了哪个业务规则"再决定要不要保留。这样能做到"正常的采 1%,错误的全采"——同样的存储预算下,排障价值高一个数量级。代价是要在 Collector 里缓存几十秒的 trace 数据,内存和延迟都有开销。

生产实践里的标配组合是:应用端用 head-based 100% 上报给 Collector,Collector 用 tail-based 做最终采样。这样应用无感,所有决策集中在 Collector,规则改了也不用重启业务。tail sampling processor 的典型配置:

yaml
processors:
  tail_sampling:
    decision_wait: 10s
    num_traces: 100000
    policies:
      - name: keep-errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: keep-slow
        type: latency
        latency: { threshold_ms: 500 }
      - name: baseline
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }

这三条策略合起来就是一句朴素的话:出错的留、慢的留、正常的留 5%。改成本可控,出问题时又有东西可查,这是采样设计上最值得抄的一条经验。

五、用 trace 定位问题:慢、错、调用关系

埋点和采样都搞定了,trace 真正发挥价值是在排障现场。三种典型用法:

定位慢调用。一个请求慢,看 trace 时间轴,找出最长的那个 span,基本就锁定了一半。进阶用法是看span 之间的"空隙"——两个子 span 之间有 200ms 的空白,意味着那段是当前服务自己在 CPU 上烧(序列化、GC、业务逻辑),不是在等下游。这部分时间在监控里完全看不见,只有 trace 的时间轴能暴露。

定位错误传播。trace 里每个 span 有 status 字段(ERROR / OK)和 exception 事件。一个 5xx 从网关冒出来,顺着 trace 反向看 parent span,能看到错误是从哪个下游冒上来、被哪一层 catch 后包了一层又抛出。错误根因往往是树里最深的那个 ERROR span,从叶子往根找最快。

理解调用关系。微服务跑久了,谁调谁、依赖多深、有没有循环调用,没人能说清。把一段时间的 trace 聚合成服务依赖图(Service Map),依赖关系一目了然,还能发现"订单服务竟然在同步调通知服务"这种本该异步化的坏味道。新接手一个系统的人,看一张服务依赖图胜过读十页架构文档。

六、与日志、指标关联:trace_id 串起三支柱

链路追踪单看,价值是"定位到哪一段"。要继续问"具体为什么慢/为什么错",得回到日志和指标。这时候 trace 的另一个角色登场:它用 trace_id 把日志和指标缝起来。

具体做法:应用打日志时,把当前 span 的 trace_id、span_id 一并写进日志行。OTel 的 instrument 通常自动给日志上下文注入这两个 id,你只要在日志 format 里把它们输出出来。

text
2026-07-17 03:14:22 [trace_id=0af7651916cd43dd8448eb211c80319c span_id=b7ad6b7169203331] payment charge failed: connection reset

有了这行,排障路径就是一条直线:

  1. 在 Grafana / Jaeger 上看到一条慢 trace,定位到 gateway span 报错。
  2. 复制 trace_id,粘到日志系统(Loki / ELK)搜框里。
  3. 这条 trace 跨的所有服务、所有 span 对应的日志行,全部按时间排好跳出来。

从"宏观指标报警" → "具体哪条 trace 异常" → "那条 trace 对应的日志行" → "栈和报错文本",这就是 Google SRE 说的可观测三支柱联动。三支柱各自存在不叫可观测,能用一个 id 来回跳,才叫可观测。Metrics 告诉你"有问题",trace 告诉你"在哪段",logs 告诉你"为什么"——这套链路在 Grafana 全家桶(Prometheus + Tempo + Loki)或 Elastic APM 里已经开箱即用,关键就是 trace_id 这个公共钥匙。

七、落地:接 OTel SDK + 选后端

最后讲落地路径,分两件事:应用接 OTel,后端选型。

应用侧:Node.js 项目用 @opentelemetry/sdk-node 起 SDK,必须在应用代码之前加载(用一个单独的 tracing.js 用 node --require ./tracing.js 启动,或在入口最顶部 import),否则 instrument 来不及 hook 主流库:

js
// tracing.js —— 必须在 app 之前加载
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';

const sdk = new NodeSDK({
  resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'order-service' }),
  traceExporter: new OTLPTraceExporter({ url: 'http://otel-collector:4318/v1/traces' }),
  instrumentations: [new HttpInstrumentation(), new ExpressInstrumentation()],
});
sdk.start();

这一段接进去,HTTP 进出、Express 路由、W3C traceparent 注入提取全自动搞定,业务代码不动。要再加业务语义,用 trace.getSpan() 拿到当前 span 加属性(order_id、user_id、amount),排障时能直接在 trace 里看见这些字段——这是把 trace 从"通用骨架"变成"业务可用"的关键一步。service.name 一定要通过 Resource 配对,别依赖主机名,否则下游依赖图会乱。

后端选型:三类选择。

  • 开源 / 自建:Jaeger(VendorNeutral、UI 老牌、社区成熟)、Tempo(Grafana 系,跟 Loki / Prometheus 联动最好,对象存储成本低)。中小团队自建首选这两个之一。
  • 商业 APM:Datadog、New Relic、Dynatrace、阿里云 ARMS、腾讯云 APM。开箱即用、运维成本几乎为零,但要付钱,且数据出了自家墙。
  • 云厂商托管 OTel:AWS X-Ray、GCP Cloud Trace、Azure Monitor。云内集成深,跨云就麻烦。

选型原则很朴素:预算够、要省事、要 SLA,选商业 APM;要数据自主、要做长期留存和自定义分析,选 Jaeger / Tempo 自建。不管选哪个,OTel SDK 那一层不变——这正是它作为标准最大的意义,后端可换,埋点不重做。

小结

  • 链路追踪解决的是"跨服务串不起来"的痛:trace 是一次请求的完整调用树,span 是树上的一个操作,trace context 让 trace_id 通过 W3C traceparent 头在服务间传递。上下文断在某一段,树就拼不起来,这是接入第一周必踩的坑。
  • OpenTelemetry 是厂商无关的统一标准:API + 自动 instrument + Collector 三层架构,业务代码几乎不改就能拿到 80% 链路覆盖。后端可换,埋点不重做。
  • 采样是工程取舍,不是开关题:head-based 简单但采不到错误,tail-based 在 Collector 攒齐再决策,能做到"正常的采 5%、错误的全采"——同样的存储预算,排障价值高一个数量级。
  • trace 的排障三用法:找最长的 span 定位慢调用,从最深的 ERROR span 反查根因,聚合服务依赖图看清调用关系。监控看不见的"span 之间的空隙",只有 trace 时间轴暴露。
  • trace_id 是三支柱的公共钥匙:日志里带上 trace_id,就能从指标报警一路下钻到具体报错行。Metrics 告诉你"有问题",trace 告诉你"在哪段",logs 告诉你"为什么"——能来回跳的才叫可观测。

一句话收尾:微服务时代,没有链路追踪,排障就是闭眼拆弹。一次请求横跨 N 个服务,你不可能在每个服务里同时按 ctrl+F,但 trace_id 可以——这就是分布式追踪这件事的全部价值。

Related

相关推荐