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)
}
这个方法自动做了三件事:
- 提取 HTTP Header 中的 Trace 上下文(如果调用方传了
traceparent头) - 为每个请求创建根 Span
- 自动记录 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 里能看到:
- 服务间调用关系图(Service Graph):order-service → payment-service → inventory-service
- 每个 Span 的耗时明细,精确到毫秒
- 错误 Span 自动标红,点进去看到错误堆栈
我第一次看到完整的调用链时,还挺激动——以前只能靠猜的调用关系,现在看得清清楚楚。
踩坑总结
| 坑 | 原因 | 解决 |
|---|---|---|
| Jaeger 被 Span 冲垮 | 没用 Collector 缓冲 | 中间层加 Collector + batch processor |
| Collector OOM | 没配内存限制 | 加 memory_limiter processor |
| 跨服务链路断裂 | 忘记包装 HTTP 客户端 | otelhttp.NewTransport 包装 |
| MQ 丢失上下文 | 没手动注入 Trace Header | 用 Inject/Extract 手动传播 |
延伸思考
链路追踪搭好只是第一步。后面真正有价值的事:
- Trace 和 Log 关联:在 Span 里注入日志的 Trace ID,查问题时就有一条从日志到链路的直达路径
- Metrics + Tracing 联合分析:Prometheus 发现 CPU 飙升时,自动关联到当前时间段的 Trace 分布
- 自适应采样:低负载全量采样,高负载自动降级,这个逻辑本身就可以写成一个 Collector 扩展
\n

评论已关闭!