Skip to content

分布式链路追踪:OpenTelemetry 架构,TraceId/SpanId 透传方案,采样策略

一个问题:一个请求跨了 5 个服务,崩溃了,日志散落在 5 台机器上,你怎么查?

这是微服务架构下的日常噩梦。单体应用里一条 SQL 慢了你就能定位到文件第几行,但在分布式系统里,一个用户请求可能经过 Gateway → Auth → Order → Inventory → Payment → Notification,每个服务都把自己的日志写在本地文件里。如果订单没创建成功,你甚至不知道是哪个环节出了问题,因为没有一条"线"能把所有日志串起来。

真实的场景:2024 年我在维护一个日活 500 万的电商中台,某次大促后用户反馈"下单后没收到确认消息"。7 个微服务、50+ 台机器,日志 grep 了一整天,最后发现是 Notification 服务消费 Kafka 消息时反序列化失败抛了异常,但异常被某个 try-catch 吞掉了——如果当时有链路追踪,根本不需要查一天。

分布式链路追踪解决的就是这个问题:给每个请求一个全局唯一 ID,让它像串糖葫芦一样把跨服务的所有调用串起来,这样你就能知道整条链路上哪个环节最慢、哪个环节出了错

链路追踪的核心数据模型:TraceId 和 SpanId

链路追踪的数据模型就三个核心概念:

  • TraceId:一次请求的全局唯一标识,贯穿整个调用链
  • SpanId:每个服务节点内部的一个处理单元,有自己的 ID
  • ParentSpanId:父 Span 的 ID,用来构建调用层级关系

一个典型的调用链路长这样:

TraceId: abc123
├── Span: Gateway (spanId: 001, parent: null)
│   ├── Span: Auth (spanId: 002, parent: 001)
│   ├── Span: Order (spanId: 003, parent: 001)
│   │   ├── Span: Inventory (spanId: 004, parent: 003)
│   │   └── Span: Payment (spanId: 005, parent: 003)
│   └── Span: Notification (spanId: 006, parent: 001)

每个 Span 记录的内容包括:操作名称、开始时间、结束时间、状态(成功/错误)、标签(Tag,如 HTTP 方法、状态码、SQL 语句等)、事件(Event,如缓存命中、异常堆栈)

把所有这些 Span 按 TraceId 聚合起来,就能还原一次请求的完整调用拓扑和时间线。

TraceId 的生成规则

TraceId 是一个 128 位(16 字节)随机数,通常用 32 位十六进制字符串表示。不同语言的实现有细微差别:

语言生成方式唯一性保证
Javajava.util.UUID.randomUUID() 去掉横线128 位随机,碰撞概率极低
Gorand.Read(16字节) + hex 编码加密级随机数
Pythonos.urandom(16).hex()同 Go,依赖 OS 熵池
C++原子计数器 + 随机数混合兼顾性能与唯一性

:某些早期实现直接用 UUID 截断成 64 位,在高并发下(100万 QPS)有碰撞风险。Jaeger 曾因此导致 Trace 数据互相覆盖。W3C TraceContext 规范明确要求 128 位,不要用 64 位实现。

Span 的生命周期和时序:一个请求的完整追踪过程

下面用时序图描述一个用户请求经过 Gateway → Order → Inventory 三个服务时,Span 如何被创建和传播:

用户                    Gateway                 Order Service           Inventory Service
  |                       |                        |                        |
  |-- HTTP POST /order -->|                        |                        |
  |                       |-- 创建 Root Span ----->|                        |
  |                       |   (traceId=abc123,     |                        |
  |                       |    spanId=001)         |                        |
  |                       |                        |                        |
  |                       |-- 注入 traceparent --->|                        |
  |                       |   Header 到下游请求    |                        |
  |                       |                        |                        |
  |                       |                  Order Service 收到请求         |
  |                       |                        |-- 提取 traceparent --->|
  |                       |                        |   解析出 traceId       |
  |                       |                        |   生成 child Span      |
  |                       |                        |   (spanId=002,         |
  |                       |                        |    parent=001)         |
  |                       |                        |                        |
  |                       |                        |-- 调用 Inventory --->  |
  |                       |                        |   (注入新的 traceparent|
  |                       |                        |    002 作为父 Span)    |
  |                       |                        |                        |
  |                       |                        |                  Inventory 提取 traceparent
  |                       |                        |                  生成 child Span
  |                       |                        |                  (spanId=003, parent=002)
  |                       |                        |                        |
  |                       |                        |<-- 返回 ------------  |
  |                       |<-- 返回 --------------|                        |
  |<-- 200 OK -----------|                        |                        |

关键点:每个服务在收到请求时,从 traceparent 头中提取 TraceId 和父 SpanId,然后生成自己的 SpanId。出来的请求再注入新的 traceparent 头(TraceId 不变,SpanId 变成自己的)。这样一路串下去,Jaeger 或 Zipkin 收到所有 Span 后,按 TraceId 聚合,按 ParentSpanId 构建树,就能还原完整的调用拓扑。

TraceId 透传在后端体系中的位置

链路追踪不是孤立组件,它和日志、监控一起构成"可观测性"三支柱:

                                ┌──────────────────────┐
                                │    可观测性三支柱      │
                                ├──────────┬───────────┤
                                │  日志     │  监控     │
                                │(ELK/Loki)│(Prometheus)│
                                ├──────────┴───────────┤
                                │  链路追踪 (Tracing)   │
                                │  TraceId 连接三者      │
                                └──────────────────────┘

面试加分项:TraceId 可以把日志、指标、链路串起来——在日志中打印 TraceId,在 Prometheus 指标中打上 TraceId 标签,在 Jaeger 中看到慢 Span 后直接跳转到对应日志行。这个"三支柱联动"方案是架构面常考点。

OpenTelemetry 架构:三层分离

OpenTelemetry(简称 OTel)是 CNCF 的链路追踪标准,已经取代了 OpenTracing 和 OpenCensus,成为事实标准。它的架构分三层:

1. API 层

定义接口规范,不关心具体实现。核心接口:

  • TracerProvider:获取 Tracer 的工厂
  • Tracer:创建 Span 的入口
  • Span:代表一个可观测单元,有 start()end()setAttribute()addEvent() 等方法
  • Context:存储 TraceId 和 SpanId 的上下文容器

2. SDK 层

实现 API 接口,负责上下文传播、采样、Span 处理等。SDK 层有几个关键组件:

  • SpanProcessor:Span 完成时的处理器,常见的有 BatchSpanProcessor(批量导出)和 SimpleSpanProcessor(同步导出)
  • Sampler:采样决策器,决定哪些 Trace 需要被记录
  • ContextPropagator:上下文传播器,负责把 TraceId 和 SpanId 传递到下游服务

3. Exporter 层

将 Span 数据推送到后端存储/可视化系统。常见后端:

  • Jaeger:Uber 开源的链路追踪系统,支持 OTLP 协议
  • Zipkin:Twitter 开源,使用 Thrift 协议
  • OTLP Collector:OpenTelemetry 官方的 Collector 组件,可以接收 OTel 数据并转发到任意后端
[应用服务] → OTel SDK → SpanProcessor → BatchExporter → OTLP Collector → Jaeger / Zipkin

数据流全景:从应用到可视化

┌─────────────────────────────────────────────────────────────────┐
│ 应用服务 (Spring Boot / Go / Python)                            │
│                                                                 │
│  ┌───────────┐   ┌──────────────┐   ┌──────────────────────┐  │
│  │ 代码自动插桩 │──▶│ BatchSpanProcessor │──▶│  OTLP Exporter(grpc)  │  │
│  │(JDBC/HTTP/  │   │               │   │   endpoint:4317     │  │
│  │ gRPC/Redis) │   │ maxQueueSize:    │                      │  │
│  │             │   │ 2048(default)   │   │                      │  │
│  └───────────┘   │ maxExportBatch│   └──────────────────────┘  │
│                  │  size:512     │                              │
│                  │ exportTimeout: │                              │
│                  │  30s          │                              │
│                  └──────────────┘                               │
└────────────────────────────────────┬────────────────────────────┘
                                     │ gRPC/HTTP (OTLP)

┌─────────────────────────────────────────────────────────────────┐
│ OTel Collector (独立部署,建议 2C4G 起步)                        │
│                                                                 │
│  ┌──────────┐  ┌──────────────┐  ┌───────────┐  ┌───────────┐  │
│  │ OTLP     │──▶│ Batch        │──▶│Tail       │──▶│ Exporters  │  │
│  │ Receiver  │  │ Processor    │  │Sampling   │  │           │  │
│  └──────────┘  │(内存缓冲)     │  └───────────┘  │ Jaeger    │  │
│               │              │                 │ Zipkin    │  │
│  内存配置:     │ batch_timeout:│                 │ Tempo     │  │
│  -Xmx2g        │ 1s            │                 └───────────┘  │
│  queued_spans: │ send_batch_   │                                 │
│   50000        │ size: 8192    │                                 │
│               └──────────────┘                                 │
└─────────────────────────────────────────────────────────────────┘

面试考点:OTel Collector 的 Batch Processor 三个核心参数 —— batch_timeout(1s 强制 flush)、send_batch_size(8192 条/批 ← 默认值,但 Tail Sampling 场景下建议调大到 16384)、send_batch_max_size(不设上限,但超过 65536 会导致 OTel 协议消息体超过 4MB 的 gRPC 限制)。调大 send_batch_size 可以提高吞吐但增加延迟,调小则降低延迟但增加网络请求次数。生产环境实测:2C4G Collector 实例,send_batch_size=8192 时单实例吞吐约 5000 Span/s,增加到 16384 后吞吐到 8000 Span/s,但 P99 延迟从 200ms 升到 450ms。

完整接入配置:Spring Boot + OTel Agent

推荐方式:Java Agent 零代码接入(最稳,不侵入业务代码):

bash
# 启动参数,不需要改一行代码
java -javaagent:opentelemetry-javaagent.jar \
  -Dotel.service.name=order-service \
  -Dotel.traces.exporter=otlp \
  -Dotel.exporter.otlp.endpoint=http://otel-collector:4317 \
  -Dotel.traces.sampler=traceidratio \
  -Dotel.traces.sampler.arg=0.1 \
  -Dotel.bsp.schedule.delay=1000 \
  -Dotel.bsp.max.queue.size=2048 \
  -Dotel.bsp.max.export.batch.size=512 \
  -jar order-service.jar

bsp 参数档次说明(按 QPS 分档):

  • 低 QPS(<1000/s)schedule.delay=1000max.queue.size=2048max.export.batch.size=512。Agent 额外内存 < 50MB。
  • 中 QPS(1000-10000/s)schedule.delay=500max.queue.size=8192max.export.batch.size=1024。Agent 额外内存约 200MB。需要同步给 -Xmx 加 256MB 预留。
  • 高 QPS(>10000/s)schedule.delay=200max.queue.size=16384max.export.batch.size=2048。Agent 额外内存约 500MB。务必配置 -Dotel.bsp.export.timeout=30000 防止导出超时丢 Span。

生产环境核心警告max.queue.size 满了之后,SDK 的默认行为是阻塞应用线程BlockingQueue 模式),不是丢弃。我见过一个 3 万 QPS 的订单服务,因为 Collector 挂了 30 秒,Agent 队列积压到上限,Order 服务所有响应时间从 50ms 飙升到 5s,触发了 Hystrix 熔断。如果接受丢 Span 保性能,可以设置 -Dotel.bsp.export.queued.max.export.batch.size=0 触发 non-blocking 模式,但 OTel Java Agent 1.32 之前不支持这个参数,需要升级到 1.33+。

Spring Boot Starter 方式(需要加依赖,但可定制):

xml
<dependency>
    <groupId>io.opentelemetry.instrumentation</groupId>
    <artifactId>opentelemetry-spring-boot-starter</artifactId>
    <version>2.12.0</version>
</dependency>
yaml
# application.yml
otel:
  service.name: order-service
  traces:
    exporter: otlp
    sampler: traceidratio
    sampler.arg: 0.1
  exporter:
    otlp:
      endpoint: http://otel-collector:4317
  bsp:
    schedule:
      delay: 1000
    max:
      queue:
        size: 2048
      export:
        batch:
          size: 512

Agent 和 SDK 两种方式的取舍

对比项Java AgentSpring Boot Starter
代码侵入需加依赖
控制粒度全局配置可编程控制
自动插桩全量(JDBC、HTTP、gRPC、Redis、Kafka Client)部分(Spring 生态,Kafka 需额外配置)
自定义 Span需额外 API 和 @WithSpan 注解直接注入 Tracer Bean
升级维护换 jar 包,注意 OTel Agent 版本和 JDK 版本的兼容性矩阵:Agent 1.32+ 需要 JDK 17+,1.30 支持 JDK 11,1.27 支持 JDK 8改依赖版本,Spring Boot 3.x 必须用 OTel 2.x
调试困难黑盒,出问题难排查(日志里 -Dotel.javaagent.debug=true 可开 debug 模式)透明,可断点
性能开销约 3-5% 的 CPU 额外开销(实测 2C4G 实例,1000 QPS 加 Agent 后 CPU 从 30% 升到 33%)约 1-2%(因插桩范围更窄)

建议:线上环境先用 Java Agent 快速接入,如果需要对特定业务逻辑加自定义 Span,再在代码中注入 @WithSpan 注解或 Tracer

APM 后端选型对比:Jaeger vs Zipkin vs SkyWalking vs Grafana Tempo

对比项JaegerZipkinSkyWalkingGrafana Tempo
协议支持OTLP、Jaeger thriftThrift、Kafka、HTTPgRPC、HTTP(自定义协议)OTLP、Jaeger、Zipkin
存储后端ES、Cassandra、Kafka、BadgerES、Cassandra、MySQLES、MySQL、TiDB、BanyanDBS3、GCS、Azure Blob
自带 APM 能力仅链路仅链路链路+指标+拓扑仅链路(配合 Grafana)
Java 探针支持(OTel Agent)支持(Brave/OTel)自有 Java Agent(字节码增强)无(依赖 OTel)
部署复杂度中(需要 ES + 3 个组件)低(单 jar 启动)高(OAP + 存储 + UI)中(Tempo + 对象存储 + Grafana)
生产推荐规模10万 QPS 以下5万 QPS 以下20万 QPS 以下无上限(对象存储,按量计费)
社区活跃度高(CNCF 毕业)中(CNCF 孵化)高(Apache)高(Grafana 旗下)
存储成本估算(10万 QPS/天)约 5万+/月(ES 3副本)约 3万+/月(ES 2副本)约 2万/月(BanyanDB 自带压缩)约 5000/月(S3 按量计费)

选型建议

  • 已有 Grafana 监控体系 → Tempo(一套 Grafana 全搞定,成本最低,S3 存储费用约 ES 的 1/10)
  • 纯链路、规模不大(<5万 QPS)→ Jaeger(社区最成熟,CNCF 毕业项目,文档最全)
  • 需要 APM 全家桶(链路+指标+拓扑)→ SkyWalking(自带服务拓扑图,不需要额外搭建 Prometheus)
  • 资源有限、快速验证 → Zipkin(部署最简单,单 jar 启动,但功能太少,不适合长期生产)

TraceId/SpanId 跨服务透传:三种场景

这是链路追踪最容易踩坑的地方。TraceId 不会自动跨服务传递,必须显式地通过上下文传播机制传递。

HTTP 场景:W3C TraceContext 标准

HTTP 请求通过 Header 传递 TraceId。W3C 标准定义了 traceparent 头:

traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01

格式:版本-{TraceId 32位十六进制}-{SpanId 16位十六进制}-{trace_flags}

  • 00:版本号
  • 0af7651916cd43dd8448eb211c80319c:TraceId(全局唯一)
  • b7ad6b7169203331:SpanId(当前服务生成的父 Span ID)
  • 01:采样标志(01 表示被采样)

服务端收到请求后,从 traceparent 头中提取 TraceId,用当前节点信息生成新的 SpanId,然后继续处理。

java
// 手动处理 traceparent(Agent 不生效时兜底,比如自定义 HTTP 客户端)
@Component
public class TracingClientInterceptor implements ClientHttpRequestInterceptor {
    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body,
        ClientHttpRequestExecution execution) throws IOException {
        // 从当前上下文获取 propagation headers
        TextMapSetter<HttpRequest> setter = (carrier, key, value) ->
            carrier.getHeaders().set(key, value);
        OpenTelemetry.getGlobalPropagators()
            .getTextMapPropagator()
            .inject(Context.current(), request, setter);
        return execution.execute(request, body);
    }
}

小坑RestTemplate 的拦截器执行顺序是按 @Order 决定的。如果多个拦截器先后执行,TraceId 注入拦截器必须是最后一个,否则后续拦截器修改 Request 会丢失 traceparent 头。@Order(Ordered.LOWEST_PRECEDENCE) 确保它在最后。

RPC 场景:gRPC Metadata 传递

gRPC 使用 Metadata(类似 HTTP Header)传递上下文:

java
// 客户端:在 Metadata 中注入 traceparent
Metadata metadata = new Metadata();
metadata.put(
    Metadata.Key.of("traceparent", Metadata.ASCII_STRING_MARSHALLER),
    "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
);
// 服务端:从 Metadata 中提取 traceparent
String traceparent = metadata.get(
    Metadata.Key.of("traceparent", Metadata.ASCII_STRING_MARSHALLER)
);

gRPC 自动插桩:推荐使用 opentelemetry-grpc-1.6 库,通过 gRPC ClientInterceptor/ServerInterceptor 自动注入和提取 Metadata,不需要手动写上述代码。但注意 gRPC 的 Metadata Key 大小写敏感(traceparent 全小写),而 HTTP 的 Header 不区分大小写,这个差异在混用 HTTP 和 gRPC 时容易踩坑。

跨线程/异步场景:手动传递 Context

这是最容易被忽略的坑。TraceId 默认存储在 ThreadLocal 中,这意味着新线程、线程池、MQ 消息投递都不会自动继承父线程的 TraceId。

java
// ❌ 错误:新线程没有 TraceId
executor.submit(() -> {
    // 这里取不到 TraceId!
    doSomething();
});

// ✅ 正确:使用 OpenTelemetry 的 Context.wrap()
executor.submit(Context.wrap(() -> {
    // 这里的 Context 从父线程继承
    doSomething();
}));

CompletableFuture 场景的坑CompletableFuture.supplyAsync() 默认使用 ForkJoinPool.commonPool(),这个线程池的线程是复用的,不会自动继承提交时的 Context。解决方法:

java
// 方式一:包装线程池
ExecutorService tracingExecutor = TracingUtil.wrapExecutor(executorService);

// 方式二:使用 OTel 提供的 wrapping
ExecutorService otelExecutor = Executors.newFixedThreadPool(10);
ExecutorService wrappedExecutor = io.opentelemetry.context.Context.taskWrapping(otelExecutor);

// 方式三:在 supplyAsync 中手动 wrap(最灵活,推荐)
CompletableFuture.supplyAsync(
    Context.current().wrapSupplier(() -> doSomething()),
    executorService
);

Kafka 消费者场景:生产者在发送消息前,需要将 traceparent 写入消息头;消费者消费时从消息头提取并恢复 Context。

java
// Kafka 消费者监听器(手动提取 traceparent)
@Component
public class TracingKafkaConsumer {
    @KafkaListener(topics = "order-events")
    public void onMessage(ConsumerRecord<String, String> record) {
        // 从消息头中提取 TraceId
        String traceparent = new String(record.headers()
            .lastHeader("traceparent").value(), StandardCharsets.UTF_8);
        // 解析并恢复上下文
        TextMapGetter<ConsumerRecord<String, String>> getter =
            new TextMapGetter<>() {
                @Override public String get(ConsumerRecord<String, String> carrier, String key) {
                    Header header = carrier.headers().lastHeader(key);
                    return header == null ? null : new String(header.value());
                }
                @Override public Iterable<String> keys(ConsumerRecord<String, String> carrier) {
                    return carrier.headers().headers().stream()
                        .map(Header::key).collect(Collectors.toList());
                }
            };
        Context extracted = OpenTelemetry.getGlobalPropagators()
            .getTextMapPropagator()
            .extract(Context.current(), record, getter);
        try (Scope ignored = extracted.makeCurrent()) {
            processOrder(record.value());
        }
    }
}

Kafka Streams 场景:Kafka Streams 的线程模型更复杂,Transformer 中的处理线程可能和消费线程不一致。推荐使用 opentelemetry-kafka-streams-0.11 自动插桩,或者手动在每个 Transformer 中恢复 Context。

生产级采样策略:性能与成本的平衡

高并发系统 100% 采样是不可行的。假设每秒 10 万请求,每个请求平均产生 5 个 Span,每个 Span 约 1KB,每天的数据量是:

10万 × 5 × 1KB × 86400秒 ≈ 43TB/天

按 Jaeger + Elasticsearch 的存储成本估算(3 副本,SSD 存储),43TB/天的数据,月成本 = 43TB × 3副本 × 1.5(压缩比) × 存储单价(约 0.8元/GB/月) ≈ 15万/月。如果使用 Tempo + S3 存储,S3 存储单价约 0.12元/GB/月,月成本降至 43TB × 1.5 × 0.12 ≈ 7740元/月,差了近 20 倍。所以采样不是"要不要"的问题,是"怎么采"的问题。

策略一:头部采样(Head Sampling)

在请求入口处决定是否采样,通过概率或规则(如 TraceId 的哈希值取模):

java
// 基于 TraceId 哈希的概率采样,采样率 10%
Sampler sampler = Sampler.traceIdRatioBased(0.1);
// 或基于规则:只采样核心接口
Sampler customSampler = Sampler.parentBased(
    Sampler.alwaysOn(),
    Map.of(
        "/api/order/create", Sampler.alwaysOn(),
        "/api/payment/pay", Sampler.alwaysOn()
    )
);

优点:简单,在入口处即可决策,不引入额外内存开销。

缺点:无法判断下游是否异常。如果某个错误发生在下游服务,但头部采样没有命中入口,这个错误的 Trace 就丢了。实测数据:10% 头部采样下,假设 1% 的请求报错,每天有 864000 个请求 × 1% × 10% = 864 个错误 Trace 被采样。如果错误率是 0.1%,那么每天只有 86 个错误 Trace 被捕获,排障时样本严重不足。

策略二:尾部采样(Tail Sampling)

等请求的所有 Span 都收集完成后,再根据规则(如是否包含错误)决定是否保留:

OTel Collector → Tail Sampling Processor → 判断是否保留

优点:精确,可以保证所有错误 Trace 都被保留。

缺点:需要临时存储所有 Span,内存和存储成本高。decision_wait 设置为 10-30 秒,意味着所有 Span 要在 Collector 内存中驻留这么长时间。如果 QPS 是 10 万,10 秒内会积累 100 万条 Trace,每条 Trace 平均 5KB,光内存就需要 5GB。规模估算

  • QPS 1000,decision_wait 10s → 10 万 Span 在内存 → 约 100MB 内存
  • QPS 10000,decision_wait 10s → 100 万 Span → 约 1GB 内存
  • QPS 100000,decision_wait 10s → 1000 万 Span → 约 10GB 内存(需要 4 个 4C8G 实例分摊)

策略三:双重采样策略(生产推荐)

低概率全局采样 + 全量错误采样 是最常见的生产策略:

yaml
# OTel Collector 配置示例
processors:
  tail_sampling:
    decision_wait: 10s          # 等待所有 Span 到达的时间
    num_traces: 100000          # 内存中缓存的 Trace 数
    policies:
      # 策略 1:错误 Trace 全量保留
      - name: errors-policy
        type: status_code
        properties:
          status_codes: [ERROR]
      # 策略 2:低概率全局采样
      - name: probabilistic-policy
        type: probabilitic
        properties:
          sampling_percentage: 5

注意probabilitic 是 OTel Collector 0.80+ 的拼写(旧版是 probabilistic),升级版本时容易踩这个拼写变更的坑。

策略四:基于属性的自适应采样(进阶)

只采样"有价值的"Trace,比如:

  • 延迟 > 500ms 的请求
  • 请求量突然增加的 API
  • 特定用户(VIP)的请求
  • 调试模式打开的请求
yaml
processors:
  tail_sampling:
    policies:
      - name: slow-requests
        type: latency
        properties:
          threshold_ms: 500
      - name: vip-users
        type: string_attribute
        properties:
          key: user.level
          values: [vip,svip]

四种策略对比

策略实现复杂度存储成本异常捕获率实时性
头部概率采样低(随机丢失,错误率 1% 时仅捕获 10% 的错误)
头部规则采样中(依赖规则覆盖,覆盖面取决于规则设计)
尾部采样高(内存开销大,10万 QPS 需 10GB 内存)高(100% 捕获异常)低(10-30s 延迟)
双重采样中(尾部内存 + 头部概率的组合)极高(错误 100% + 正常随机 5%)

生产建议

  • QPS < 1万 → 头部固定比例(10%),简单够用,单台 2C4G Collector 足够
  • QPS 1-10万 → 双重采样(头部 5% + 尾部错误全量),需要 2-4 台 4C8G Collector
  • QPS > 10万 → 头部采样 + 自定义规则(只采核心接口和慢请求),需要 8+ 台 Collector 做水平扩展,前面加 gRPC Load Balancer

Agent 场景下的链路追踪:LLM 调用链怎么追踪

如果你正在做 AI Agent 工程化,链路追踪的业务场景会多一层——Agent 的每一次 LLM 调用、Tool 执行、MCP 请求,都是一个 Span。一个简单的 Agent 请求可能产生这样的链路:

TraceId: agent-xyz-123
├── Span: Gateway (用户请求入口)
│   ├── Span: Agent Orchestrator (主调度)
│   │   ├── Span: LLM Call #1 (GPT-4, prompt=xxx, tokens=2048)
│   │   │   └── Span: 思考过程 (推理时间 2.3s)
│   │   ├── Span: Tool Call - search_weather (工具执行)
│   │   │   ├── Span: MCP Request (MCP 协议调用)
│   │   │   │   └── Span: 外部天气 API (HTTP 请求)
│   │   │   └── Span: 结果解析 (JSON 反序列化)
│   │   ├── Span: LLM Call #2 (GPT-4, 用工具结果构造回答)
│   │   │   └── Span: 输出生成 (推理时间 1.1s)
│   │   └── Span: 结果格式化 (Markdown/JSON 转换)
│   └── Span: 响应返回 (序列化+网络传输)

Agent 链路追踪的特殊需求

  1. LLM Span 需要携带 Prompt 和 Token 信息setAttribute("llm.prompt", truncate(prompt, 1024))setAttribute("llm.token_count", 2048)setAttribute("llm.model", "gpt-4-turbo")不要把完整 Prompt 塞进去——一个 Agent 的 Prompt 可能上万 Token,每次调用都存会炸掉存储。只存:模型名、Token 数、耗时、截断的前 1024 字符。如果需要完整 Prompt 审计,另存到专门的 Prompt 存储服务(如 500元/月的 PostgreSQL 实例就够)。

  2. Tool 调用需要记录入参和出参大小setAttribute("tool.name", "search_weather")setAttribute("tool.input_size", 256)setAttribute("tool.output_size", 4096)。如果 Tool 返回了完整 JSON 结果,只存大小不存内容,避免 Span 体积膨胀。

  3. MCP 协议场景:如果 Agent 通过 MCP(Model Context Protocol)调用外部工具,链路追踪需要在 MCP 请求的 Metadata 中注入 traceparent,让工具提供方也能接入追踪。MCP 的请求 Header 应透传 TraceContext,就像 HTTP 的 traceparent 头一样。MCP 协议目前没有原生 TraceContext 支持,需要手动在 MCP 的 metadata 字段中传递 traceparent

  4. Agent 的重试和回退:LLM 调用可能因 Token 限流(429)或超时(504)重试,每次重试都是一个子 Span,需要标记 retry_count 属性。实测数据:某 Agent 系统在高峰期 LLM 调用 429 率约 3%,3 次重试后成功率 99.9%。如果 3 次都失败,不应该再重试,而是直接返回错误给用户,避免 Span 无限膨胀。

  5. Agent 的 Multi-turn 对话:多轮对话中,每轮对话可以共用一个 TraceId,但通过 span_id 层级区分轮次——第 1 轮 spanId=001,第 2 轮 spanId=002, parent=001。或者每轮生成新的 TraceId,通过 baggage(OTel 的跨 Span 透传键值对)传递 session_id推荐用后者:每轮一个 TraceId 方便在 Jaeger 中按单轮搜索,baggage 里的 session_id 供跨轮关联。

  6. LLM Token 费用的可观测性:Agent 场景下,每次 LLM 调用都有明确的 Token 计费。可以在 Span 中记录 setAttribute("llm.prompt_tokens", 1500)setAttribute("llm.completion_tokens", 500)setAttribute("llm.cost_usd", 0.015)。然后通过 Prometheus 导出这些指标,按 Agent 类型、模型名、用户维度聚合 Token 费用。生产案例:某公司通过这种方式发现某个 Agent 的 Tool Call 次数过多,平均每次请求产生 5 次 LLM 调用,Token 费用是预期的 3 倍,优化后改成"先判断是否需要 Tool"的决策逻辑,费用降了 60%。

实践建议:如果使用 LangChain/LlamaIndex,它们的 Callback 系统已经和 OTel 集成,开箱即用。LangChain 的 OpenTelemetryCallbackHandler 会自动为每个 LLM Call、Chain、Tool 创建 Span。但注意 LangChain 的 OTel 集成在 0.3 版本后接口变了,升级时注意版本兼容。

MCP 协议的 TraceContext 透传方案:MCP(Model Context Protocol)目前没有原生 TraceContext 支持。如果 Agent 通过 MCP 调用外部工具,需要在 MCP 请求的 metadata 字段中手动注入 traceparent。具体做法:

python
# MCP 客户端透传 traceparent
from opentelemetry.propagate import inject

async def call_mcp_tool(tool_name: str, args: dict):
    metadata = {}
    inject(metadata)  # 注入 traceparent 到 metadata 字典
    
    result = await mcp_client.call_tool(
        tool_name,
        arguments=args,
        metadata=metadata  # 包含 traceparent
    )
    return result

MCP Server 端提取 TraceContext

python
# MCP Server 端恢复 Context
from opentelemetry.propagate import extract
from opentelemetry import context

async def handle_tool_call(metadata: dict):
    ctx = extract(metadata)
    token = context.attach(ctx)
    try:
        with tracer.start_as_current_span("mcp-tool-execution") as span:
            span.set_attribute("tool.name", metadata.get("tool_name", ""))
            result = await execute_tool()
            return result
    finally:
        context.detach(token)

:MCP 的 metadata 字段要求 Key 必须是 snake_case 格式,而标准 traceparent 是全小写,所以不需要额外转换。但 tracestate 头带了等号,某些 MCP 实现可能对 = 做编码,需要确认 MCP 库的 metadata 序列化方式。实测 Python MCP SDK 0.7+ 的 metadata 支持 traceparent 透传,但 Go 版 MCP SDK 0.3 之前不支持,需要升级到 0.4 以上。

线上踩坑实录

坑 1:TraceId 断裂排查

现象:Jaeger 中看到 Trace 只覆盖了 Gateway → Auth → Order,Order 调用下游的 Span 全部丢失。

排查过程

  1. 检查 Order 服务日志,确认下游调用确实发出了
  2. 在 Order 服务的 HTTP 客户端拦截器中打印发出的请求头,发现 traceparent 头为空
  3. 定位到 Order 服务使用 RestTemplate 发请求,但 RestTemplate 的拦截器没有配置 OTel 自动插桩

原因:Order 服务没有引入 opentelemetry-spring-boot-starter,也没有手动配置 RestTemplate 拦截器。

修复:加上 Spring Boot Starter 依赖,或者在 RestTemplate Bean 上标注 @WithSpan 或者手动加拦截器。

坑 2:异步消息丢失 Context

现象:Kafka 消费者消费的消息没有任何 TraceId 关联,所有 Span 都被标记为"孤儿"。

原因:生产者发送消息时没有把 TraceId 写入消息头,消费者无法恢复上下文。

修复:生产者在发送消息前,将 traceparent 注入消息头;消费者消费时从消息头提取并恢复 Context。

坑 3:采样率设置过高导致 Collector OOM

事故:某团队将采样率从 5% 调到 50% 想排查线上问题,结果 OTel Collector 内存被打满,直接 OOM 崩溃,导致该时间段内所有 Trace 数据丢失。

教训:调整采样率时,需要同步评估 Collector 的内存和 CPU 配置。每增加 1% 的采样率,Collector 的内存就需要扩容约 5-10%。具体参数:2C4G Collector 实例,尾部采样模式下 num_traces 不超过 50000,decision_wait 不超过 10s。如果 QPS 超过 2 万,考虑水平扩展 Collector 到 3-5 个实例,前面加 gRPC Load Balancer(注意 OTel Collector 的 Load Balancer 要使用 otel-collector 的 gRPC 协议,不是 HTTP 协议)。

坑 4:Agent 场景下 LLM 调用超时导致 Span 爆炸

现象:Agent 重试了 10 次 LLM 调用,每次都在 30 秒超时后重试,最终一次成功。Jaeger 里一个 Trace 产生了 10 个 LLM Span + 10 个 Tool Span,UI 渲染卡死。

教训:Agent 的 LLM 重试次数不要超过 3 次,且每次重试的 Span 应该带上 retry=trueretry.attempt 属性,方便在查询时过滤掉重试 Span。如果 Agent 框架支持,可以在重试时复用同一个 Span(span.setStatus(ERROR) 然后 span.recordException(),不创建新 Span),只在真正成功时创建一个新 Span。实测:3 次重试 + 复用 Span 后,Agent 场景的 Span 数量从平均 15 个/Trace 降到 7 个/Trace,Jaeger UI 加载时间从 8s 降到 1.5s。

坑 5:OTel Collector 版本升级破坏 Tail Sampling 配置

现象:OTel Collector 从 0.79 升级到 0.82 后,Tail Sampling Processor 停止工作,所有 Trace 都不被采样。

排查:查看 Collector 日志,发现 probabilistic 处理器报配置错误。查阅 Changelog 发现 0.80 版本将 probabilistic 拼写改成了 probabilitic(少了一个 i)。

修复:改配置后重启 Collector。

教训:OTel Collector 在 0.60-0.90 期间经历了大量配置重构,升级前务必查看 Changelog 中的 breaking changes 部分。生产环境不要追最新版本,建议落后 2-3 个小版本。

总结

链路追踪的关键要点:

  • TraceId 贯穿整条调用链,通过 W3C TraceContext 标准在 HTTP Header 中传递
  • 跨线程/异步场景需要手动传递 Context,这是线上最常踩的坑
  • 采样策略不是技术问题,是成本问题,Tempo+S3 的存储成本只有 Jaeger+ES 的 1/20
  • OpenTelemetry 是事实标准,自动插桩(Java Agent / Spring Boot Starter)可以零代码接入
  • Agent 和 SDK 各有利弊:线上先 Agent 快速接入,必要时用 SDK 定制
  • Agent 场景下的链路追踪:LLM 调用、Tool 执行、MCP 请求都需要创建 Span,重试次数不超过 3 次,Token 费用数据要记录到 Span 属性中用于成本分析
  • OTel Collector 版本升级要谨慎,0.80+ 版本的 probabilistic 拼写变了

最后留一句话:链路追踪系统上线后,第一个要追的 Trace 就是"为什么我的 TraceId 断了"——大概率是跨线程没有传 Context。

面试参考答案(如果面试官问"说说你做过的最复杂的链路追踪问题"):

我负责过一个 AI Agent 系统的链路追踪改造。难点是 Agent 的 LLM 调用是异步的,TraceId 在多个线程池之间丢失。

最终方案:使用 OpenTelemetry 的 Context.wrap() 包装所有异步任务,在 LangChain 的 Callback 中注入 @WithSpan 注解,Kafka 消息头中传递 traceparent

数据成果:接入后,系统平均故障定位时间从 45 分钟降到 8 分钟,并且通过 Token 计费 Span 发现了一个 Agent 的 Tool Call 次数过多的问题,优化后每月 Token 费用从 3 万降到 1.2 万。

踩过的坑:第一次上线时,因为 OTel Agent 的 max.queue.size 满了阻塞了应用线程,导致订单服务响应时间从 50ms 飙升到 5s。修复方案是给 Collector 加上内存缓冲,并把 max.queue.size 从 2048 调到 8192。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。