OpenTelemetry 可观测性实战:从零搭建全链路追踪系统

2026-05-08 22:04 OpenTelemetry 可观测性实战:从零搭建全链路追踪系统已关闭评论

OpenTelemetry 可观测性实战:从零搭建全链路追踪系统

我用 OpenTelemetry 花了两周给一个 20 个微服务的项目搭了一套全链路追踪,踩了 4 个坑才跑通——这篇文章就是你想踩的那些坑的提前预演。

为什么要自己搭 Tracing?

我们团队维护着一个电商后台,拆了 20 来个微服务,Go 和 Java 混部。每次线上出问题,排查链路就像在迷宫里找路——A 服务调用 B 服务,B 又调 C 和 D,其中一个慢了 3 秒,但监控只知道"B 服务响应时间上升",根本不知道是 B 自身的问题还是下游拖慢的。

市面上有商业 APM 方案,但 leader 预算砍了。SkyWalking 是个选择,但我们已经在用 Prometheus + Grafana 做指标监控,不想再维护一套单独的 Agent。

OpenTelemetry 的好处是:一个标准解决 Tracing、Metrics、Logging 三件事,厂商中立,以后想迁到 Datadog 也不锁死。

架构设计——三个组件,缺一不可

OpenTelemetry 的架构分三层:

应用进程 → OTel Collector → 后端存储 (Jaeger/ClickHouse)
  • 应用进程:通过 SDK 植入代码,生成 Span(追踪的最小单位)
  • Collector:接收 Span 数据,做采样、过滤、转发,核心中间层
  • 后端存储:我选了 Jaeger,部署简单,社区成熟

我当时犯的第一个错误:直接用 Jaeger 的 All-in-One 模式,跳过了 Collector。小流量还好,一压测 Jaeger 直接被 Span 冲垮了。

一定要在中间加 Collector,它能做缓冲和采样。

部署 Collector——这也是第一个坑

用 Docker Compose 部署是最快的:

# docker-compose.yml
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.97.0
    command: ["--config=/etc/otel-collector-config.yaml"]
    ports:
      - "4318:4318"    # OTLP HTTP
      - "4317:4317"    # OTLP gRPC
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml

  jaeger:
    image: jaegertracing/all-in-one:1.57
    ports:
      - "16686:16686"  # Jaeger UI
      - "14250:14250"  # Jaeger gRPC receiver

Collector 的配置文件:

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

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

exporters:
  otlp:
    endpoint: "jaeger:4317"
    tls:
      insecure: true

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

注意: memory_limiter 一定要加。我第一次没配,Collector 跑了 6 小时 OOM 了。

Go 服务接入——比我想的简单

我用 Go 写了个订单服务做示例。OpenTelemetry Go SDK 的用法比我想象的直观:

package main

import (
    "context"
    "log"
    "net/http"
    "time"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
    "go.opentelemetry.io/otel/trace"
)

func initTracer() {
    exporter, _ := otlptracehttp.New(context.Background(),
        otlptracehttp.WithEndpoint("localhost:4318"),
        otlptracehttp.WithInsecure(),
    )

    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceNameKey.String("order-service"),
            semconv.ServiceVersionKey.String("1.0.0"),
        )),
    )
    otel.SetTracerProvider(tp)
}

func main() {
    initTracer()
    http.HandleFunc("/api/orders", handleCreateOrder)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

func handleCreateOrder(w http.ResponseWriter, r *http.Request) {
    tracer := otel.Tracer("order-service")
    ctx, span := tracer.Start(r.Context(), "createOrder")
    defer span.End()

    // 给 span 加业务标签
    span.SetAttributes(
        attribute.String("order.user_id", "u12345"),
        attribute.Int("order.item_count", 3),
    )

    // 模拟调用支付服务
    paymentID, err := callPaymentService(ctx, 99.90)
    if err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
        http.Error(w, err.Error(), 500)
        return
    }

    span.SetAttributes(
        attribute.String("order.payment_id", paymentID),
    )
    w.Write([]byte("order created"))
}

关键点otel.Tracer("order-service") 里的参数是 tracer 的名称,通常和服务名一致,方便在 Jaeger 里过滤。

HTTP 拦截器——自动生成 Span

如果每个接口都手动 tracer.Start(),代码就毁了。OpenTelemetry 提供了现成的 HTTP 拦截器:

import (
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

func main() {
    handler := http.HandlerFunc(handleCreateOrder)
    wrapped := otelhttp.NewHandler(handler, "createOrder")

    http.Handle("/api/orders", wrapped)
}

这个方法自动做了三件事:

  1. 提取 HTTP Header 中的 Trace 上下文(如果调用方传了 traceparent 头)
  2. 为每个请求创建根 Span
  3. 自动记录 HTTP 方法、URL、状态码等属性

同理,HTTP 客户端也要包装,这样才能把 Trace 上下文传给下游:

client := &http.Client{
    Transport: otelhttp.NewTransport(http.DefaultTransport),
}
resp, err := client.Get("http://payment-service/api/pay")

注意: 忘记包装客户端是第二个常见坑。如果不包装,下游服务就接不到 Trace 上下文,链路在跨服务调用处断掉。

跨服务上下文传播——第三个大坑

上下文传播(Context Propagation)是 OpenTelemetry 最核心也最容易出错的机制。

简单说:服务 A 在调用服务 B 时,必须把 Trace ID 和 Span ID 通过 HTTP Header 传给 B。OpenTelemetry 用 W3C 的 traceparent 标准头:

traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01

用 HTTP 拦截器包装客户端后,SDK 会自动注入这个头。但如果你用消息队列(Kafka/RabbitMQ),就得手动传递:

type KafkaProducer struct {
    tracer trace.Tracer
}

func (p *KafkaProducer) Publish(ctx context.Context, topic string, msg []byte) error {
    // 从上下文中提取 Span 信息,注入到消息头
    carrier := propagation.MapCarrier{}
    otel.GetTextMapPropagator().Inject(ctx, carrier)

    headers := make([]kafka.Header, 0)
    for k, v := range carrier {
        headers = append(headers, kafka.Header{Key: k, Value: []byte(v)})
    }

    // message.Headers = headers 然后发消息
    // 消费者端反向 Extract,重建 Context
}

消息队列的上下文传播,是我调得最久的一个坑。如果你项目里有 MQ,务必在测试环境先验证 Trace 链路的完整性。

Java 服务接入——Java Agent 真香

如果说 Go SDK 是"手动挡",Java 的 OpenTelemetry Agent 就是"自动挡"。

java -javaagent:opentelemetry-javaagent.jar \
     -Dotel.service.name=payment-service \
     -Dotel.traces.exporter=otlp \
     -Dotel.exporter.otlp.endpoint=http://localhost:4317 \
     -jar payment-service.jar

一个 JVM 参数,所有框架(Spring Boot、gRPC、Servlet、Kafka Client)的自动埋点全有了。没有任何代码侵入,这是 Java 生态相比 Go 最大的优势

但注意:自动埋点生成的 Span 名称可能和业务语义对不上。比如所有 HTTP 请求都叫 GET /api/{path},你分不清是哪个具体用户的请求。这时需要手动自定义 Span:

@WithSpan("processPayment")
public PaymentResult pay(@SpanAttribute("user.id") String userId, 
                          @SpanAttribute("amount") double amount) {
    // 业务逻辑
}

采样策略——省钱的关键

全量采集在小项目无所谓,但一旦 QPS 上千,存储成本直接起飞。

我的策略是头部采样 + 尾部采样混合

processors:
  probabilistic_sampler:
    sampling_percentage: 10  # 默认采 10%
  
  tail_sampling:
    policies:
      - name: error-policy
        type: status_code
        value: ERROR
        sampling_percentage: 100  # 错误链路全采
      - name: slow-policy
        type: latency
        threshold_ms: 2000
        sampling_percentage: 100  # 慢请求全采

坑四: 尾部采样很耗内存。我一开始给所有服务配了尾部采样,Collector 内存直接飙到 2GB。最后只在生产和预发环境的核心服务上开,开发环境只用 10% 头部采样。

验证——怎么看链路?

启动所有组件后,发几个请求,打开 Jaeger UI(http://localhost:16686)。

在 Jaeger 里能看到:

  1. 服务间调用关系图(Service Graph):order-service → payment-service → inventory-service
  2. 每个 Span 的耗时明细,精确到毫秒
  3. 错误 Span 自动标红,点进去看到错误堆栈

我第一次看到完整的调用链时,还挺激动——以前只能靠猜的调用关系,现在看得清清楚楚。

踩坑总结

原因 解决
Jaeger 被 Span 冲垮 没用 Collector 缓冲 中间层加 Collector + batch processor
Collector OOM 没配内存限制 memory_limiter processor
跨服务链路断裂 忘记包装 HTTP 客户端 otelhttp.NewTransport 包装
MQ 丢失上下文 没手动注入 Trace Header Inject/Extract 手动传播

延伸思考

链路追踪搭好只是第一步。后面真正有价值的事:

  1. Trace 和 Log 关联:在 Span 里注入日志的 Trace ID,查问题时就有一条从日志到链路的直达路径
  2. Metrics + Tracing 联合分析:Prometheus 发现 CPU 飙升时,自动关联到当前时间段的 Trace 分布
  3. 自适应采样:低负载全量采样,高负载自动降级,这个逻辑本身就可以写成一个 Collector 扩展

\n

你可能感兴趣的文章

来源:每日教程每日一例,深入学习实用技术教程,关注公众号TeachCourse
转载请注明出处: https://teachcourse.cn/4120.html ,谢谢支持!

资源分享

分类:Android 标签:
011-ubuntu sudo ufw查看现有防火墙规则 011-ubuntu sudo ufw查看现有防火
第1篇-日常开发流-从打开项目到提交代码 第1篇-日常开发流-从打开项目到提
012-ubuntu系统,如何执行install_ss.sh脚本 012-ubuntu系统,如何执行instal
Collections操作List集合升序、降序sort方法使用 Collections操作List集合升序、

评论已关闭!