一个慢请求背后的黑盒:为什么需要追踪

单体时代查慢请求很简单:入口打日志,看一眼时间戳就能知道卡在哪。微服务把这种确定性问题变成了黑盒——一次下单请求要经过网关、订单、库存、支付四个服务,指标(Metrics)只能告诉你哪个服务平均慢了,日志(Logs)只能告诉你某个服务自己发生了什么,唯独链路追踪(Tracing)能把这次请求跨服务走过的完整路径拼出来。

追踪的基本模型就两个概念:Trace(一次请求的全链路)和Span(链路中的一个操作片段)。一次下单是一个 Trace,它由「网关转发」「订单创建」「库存扣减」「支付回调」等多个 Span 组成,每个 Span 记录服务名、操作名、耗时、状态和父级关系,串起来就是一棵树。OpenTelemetry(简称 OTel)是 CNCF 的事实标准:SDK 负责埋点,OTLP 协议负责传输,Collector 负责中转,后端负责存储展示,各环节解耦。

# 追踪里的两个核心概念,一句话理解
Trace = 一次请求的全链路(一条流水线)
Span  = 链路里的一个操作(流水线上的一个工位)

# 一条链路的最小字段
traceId   一次请求唯一
spanId    每个操作唯一
parentId  谁调用了我(把 Span 串成树)
duration  这个操作花了多久
本文核心知识点
  • 模型Trace 由 Span 组成,spanId 靠 parentId 串成树。
  • 埋点优先自动埋点,别一上来手写 span。
  • 传播traceId 靠 W3C traceparent 跨服务传递。
  • 采样采样控制成本,错误与慢请求要保采样。
OpenTelemetry 分布式追踪配图:发光光点依次穿过一排方形服务节点、路径分支、上方条形瀑布图与右下角漏斗的蓝色扁平科技插画
把一次请求走过的服务串成一条可回放的链路,黑盒就打开了。

接入 OpenTelemetry SDK:先从自动埋点开始

接 OTel 最大的误区是「先设计手动埋点规范」。生产环境真实的路径是:先用官方提供的自动埋点能力把数据跑起来,再在关键业务点补手动 span。自动埋点能覆盖 HTTP 服务端/客户端、数据库驱动、消息队列、Redis 客户端等最常见调用,业务代码几乎零侵入。

以 Python 为例,只要安装自动埋点依赖并设置导出地址,框架(Flask/FastAPI/Django)、requests、SQLAlchemy 等调用就会自动生成 span。Java 更简单,直接挂 -javaagent 即可。埋点后先把数据打到本地 Collector,验证链路通了再改生产配置。

# Python:自动埋点最小配置
pip install opentelemetry-distro \
  opentelemetry-instrumentation-flask \
  opentelemetry-instrumentation-requests \
  opentelemetry-exporter-otlp

# 启动时注入自动埋点
opentelemetry-instrument \
  --traces_exporter otlp \
  --metrics_exporter none \
  --service_name order-svc \
  python app.py

# 等价的环境变量(容器里更常用)
export OTEL_SERVICE_NAME=order-svc
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
export OTEL_TRACES_EXPORTER=otlp

自动埋点先跑起来还有一个好处:它能暴露「哪些调用完全没被观测到」——比如定时任务、消息消费者、异步线程,这些场景没有入口 HTTP 请求,自动埋点覆盖不到,才是真正需要手写 span 的地方。

上下文传播:把 traceId 串成一条线

自动埋点只解决「每个服务内部记录什么」,真正把整条链路串起来的是上下文传播(Context Propagation)。标准做法是 W3C 的 traceparent 请求头:上游服务把 traceId 塞进请求头,下游服务读到后继续沿用,这样每个 Span 才能属于同一条 Trace。

对普通 HTTP 同步调用,SDK 会自动注入和读取 traceparent,你不用管。但消息队列、异步线程、定时任务这些「跳过了 HTTP 请求头」的场景,必须手动传播:生产消息时把当前上下文序列化进消息头,消费时恢复上下文,否则 traceId 会在这里断掉,一条链路被劈成两截。

# traceparent 请求头长这样(版本-父traceId-父spanId-标志)
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

# Python 手动传播:生产消息时带上当前上下文
from opentelemetry import context, propagate

carrier = {}
propagate.inject(carrier)        # 把 traceparent 写进消息头
kafka_producer.send(topic, value=msg, headers=carrier)

# 消费端恢复上下文后再处理,让子 span 挂到同一条链路
ctx = propagate.extract(message.headers)
token = context.attach(ctx)
with tracer.start_as_current_span("consume-order"):
    process(msg)

上下文传播还有一个连带收益:日志关联。把 traceId 打印进应用日志(比如 Python 的 logging filter 读取当前 span 的 trace_id),日志系统就能按 traceId 检索一整条调用链的日志,和追踪面板互相印证。这一步做不做,决定了追踪落地后能不能真正「追」到问题。

数据出口:OTLP 与 Collector

SDK 埋点后数据往哪送?直接送后端(Jaeger/Tempo)也能跑,但生产几乎都会加一层OpenTelemetry Collector:它是独立的代理服务,按「接收—处理—导出」三段管线工作,负责统一接入协议、批处理、加标签、过滤采样、再分发给一个或多个后端,SDK 只需指向 Collector,后端随便换。

Collector 的配置是 YAML 声明式的,最常用的是 otlp 接收、memory_limiter 与 batch 处理器、otlp/jaeger 导出。批处理是必须的——它能显著降低写后端的次数,直接关系到采样之外的第二层成本控制。

# otel-collector.yaml 最小可用配置
receivers:
  otlp:
    protocols:
      grpc:
      http:

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
  batch:
    send_batch_size: 1024
    timeout: 5s

exporters:
  otlp/tempo:
    endpoint: tempo:4317
  logging:
    verbosity: basic

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlp/tempo]

Collector 在集群里以 Deployment 或 DaemonSet 部署:中心化 Collector 好管理但单点,DaemonSet 每节点一个更稳,生产建议每节点一个收集器再加一个集中层做聚合导出。SDK 的导出端只写 Collector 地址,未来换后端、加指标管道,都不用再改业务代码。