一个慢请求背后的黑盒:为什么需要追踪
单体时代查慢请求很简单:入口打日志,看一眼时间戳就能知道卡在哪。微服务把这种确定性问题变成了黑盒——一次下单请求要经过网关、订单、库存、支付四个服务,指标(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 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 地址,未来换后端、加指标管道,都不用再改业务代码。
采样策略:成本与完整性的平衡
高并发下全量追踪是存储灾难,采样是必须做的第一件事。最常用的是头部采样(Head Sampling):在请求入口按比例决定「这条链路要不要采」,比如 10% 采样率,95% 的存储成本直接省掉。头部采样实现简单、开销极小,但缺点是它发生在请求开始,无法预知这条链路会不会慢、会不会错。
进阶是尾部采样(Tail Sampling):等链路结束后再决定是否保留,可以做到「错误链路全保留、慢链路按阈值保留、正常链路按比例保留」。代价是需要在 Collector 里缓存链路片段,内存占用高、配置复杂。实践上多数团队从头部采样起步,只对重点服务或错误率高的时段调高比例。
# 尾部采样:错误全采 + 慢链路全采 + 正常 10%
processors:
tail_sampling:
decision_wait: 10s
policies:
- name: errors
type: status_code
status_code: {status_codes: [ERROR]}
- name: slow
type: latency
latency: {threshold_ms: 2000}
- name: random-10
type: probabilistic
probabilistic: {sampling_percentage: 10}
采样率不是拍脑袋定的,要按「每秒链路数 × 平均 span 数 × 单 span 大小」估算存储增长,再回推采样率;同时别忘了对采样结果做监控——采样率波动本身可能意味着流量异常。低频但重要的链路(支付回调、告警触发路径)建议直接配置 100% 采样。
后端选型与查询:Jaeger 与 Tempo
后端负责存储和检索 Trace,两个主流选择是 Jaeger 和 Grafana Tempo。Jaeger 是 CNCF 项目,功能完整、自带 UI,适合独立部署一套追踪系统;Tempo 与 Prometheus、Loki、Grafana 同生态,查询体验统一,适合已经在用 Grafana 全家桶的团队,而且 Tempo 支持对象存储(S3/MinIO),存储成本低。
查询追踪最核心的能力是按条件检索和跨数据关联:按服务名、操作名、耗时、错误状态找链路;再靠日志里打印的 traceId 直接从日志跳进追踪,或从追踪跳回日志。Tempo 的 trace-to-log 就是把 trace 里的 span 信息和日志系统按 trace_id 字段对齐。
# Tempo 对象存储后端示例(MinIO)
storage:
trace:
backend: s3
s3:
endpoint: minio:9000
bucket: tempo-traces
access_key: minioadmin
secret_key: minioadmin
# 应用日志统一带 trace_id 字段(结构化日志)
{"time":"2026-10-27T10:00:00Z","level":"ERROR",
"service":"order-svc","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736",
"msg":"库存扣减失败"}
选型建议:团队小、链路量不大,Jaeger 单实例就能顶;已经在 Grafana 上做监控告警,选 Tempo 让「指标—日志—追踪」三张图在一个界面里切换。无论选哪个,把「按 traceId 从日志直达追踪」配置好,这是排障效率提升最明显的一步。
落地检查清单与常见坑
追踪落地失败,绝大多数不是技术问题,而是细节没对齐。整理一份可对照的检查清单,按顺序过一遍:
追踪落地检查清单
1. 服务名统一 所有服务用同一个命名规范,别一个叫 order 一个叫 order-svc
2. 先自动后手动 自动埋点跑通后再补关键业务 span
3. 消息队列手动传播 生产/消费端都带 traceparent,避免链路断裂
4. 日志带 trace_id 每个服务日志都打印 traceId,关联查询才成立
5. 采样率先算再配 按存储估算定采样率,低频关键链路 100%
6. Collector 批处理 内存限制 + batch 必须配,防 OOM 防写爆后端
7. 时钟同步 所有节点 NTP 对齐,否则 span 时序错乱
8. 定期复盘采样 每季度看一次采样质量与存储成本
最后提醒两个最常见的坑:一是只接 HTTP 不接消息队列,异步链路全断,排查半天发现 trace 永远只到队列这一跳;二是环境变量与服务名不一致,容器里覆盖了 OTEL_SERVICE_NAME 导致数据归属错乱。追踪是「一次接入、长期受益」的投入,把基础规范定好,后面排障、容量评估、SLO 分析都会用上它,和全链路可观测、监控告警是一套体系。