Go 分布式追踪实战——OpenTelemetry 全链路接入,不只是加几行代码
Go 分布式追踪实战——OpenTelemetry 全链路接入,不只是加几行代码
适读人群:维护分布式 Go 服务、想搞清楚请求链路的工程师 | 阅读时长:约17分钟 | 核心价值:OpenTelemetry 在 Go 生产项目中的完整接入方案,包括自动采样、上下文传播和 Jaeger 对接
一个排查了3小时的 bug
大概5个月前,我们收到一个用户投诉:他们调用我们的 API 创建项目,有时候会在3秒左右超时,但重试之后又正常了。
我拉了一下日志,问题不在我们的 API 服务本身——响应时间正常,大概 47ms。但用户反馈的是 3 秒超时……这中间差了将近 3000ms 去哪了?
于是开始排查。我们的调用链是:客户端 → API Gateway → Go 服务 → PostgreSQL + Redis + 一个第三方 OCR 服务。
没有链路追踪的情况下,我只能在每个服务上分别捞日志,然后用时间戳对齐,手动拼出调用链。花了整整 3 个多小时,才发现是 OCR 服务在某些情况下响应慢,但我们没有设置合理的超时,导致整个请求被阻塞了。
那天下午我就决定:必须上 OpenTelemetry。
为什么是 OpenTelemetry,不是 Jaeger SDK 或其他
Jaeger、Zipkin、SkyWalking,这几个追踪系统的 SDK 都用过(Java 时代)。每次换追踪后端都要重写接入代码,这种绑定关系很烦。
OpenTelemetry(简称 OTEL)解决的正是这个问题:它是一个厂商中立的可观测性标准,定义了 API、SDK 和数据格式。你写一套接入代码,可以把数据发到 Jaeger、Zipkin、Tempo、Datadog 或者任何支持 OTLP 的后端。
Go 的 OTEL SDK 目前很成熟,主要包:
go.opentelemetry.io/otel— 核心 APIgo.opentelemetry.io/otel/sdk— SDK 实现go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc— OTLP gRPC 导出器
踩坑实录
坑一:上下文传播断了
现象: 追踪数据里看到的都是孤立的 span,看不到调用链,每个请求都是一个独立的根 span。
原因: 我在各个 goroutine 之间传递 context 的时候,用了 context.Background() 而不是从上一层传下来的 ctx。OTEL 的 span 信息存在 context 里,用 context.Background() 就切断了传播链。
解法: 建立团队规范,所有函数签名第一个参数必须是 ctx context.Context,绝不能凭空创建 context。我在 CI 里加了一个 go vet 的自定义规则来检测这个。
坑二:采样率设错了,数据量把 Jaeger 打崩了
现象: 接入 OTEL 的第二天,Jaeger 的存储磁盘用量从每天3GB涨到了 67GB,运维找到我说磁盘要满了。
原因: 我用了 AlwaysSample(100%采样),生产环境每天大概 240 万个请求,每个请求产生平均 8 个 span,这数据量 Jaeger 吃不消。
解法: 改成基于头部的概率采样(Probabilistic Sampling),正常请求采 1%,错误请求和慢请求 100% 采:
// 自定义采样器:错误和慢请求必采,其他 1% 采样
type CustomSampler struct {
probabilistic sdktrace.Sampler
}
func (s *CustomSampler) ShouldSample(p sdktrace.SamplingParameters) sdktrace.SamplingResult {
// 如果父 span 已经决定采样,子 span 跟随
if p.ParentContext.IsValid() {
return sdktrace.ParentBased(sdktrace.NeverSample()).ShouldSample(p)
}
// 从 baggage 或 attribute 中判断是否强制采样
for _, attr := range p.Attributes {
if attr.Key == "force.sample" && attr.Value.AsBool() {
return sdktrace.SamplingResult{Decision: sdktrace.RecordAndSample}
}
}
// 否则 1% 概率采样
return s.probabilistic.ShouldSample(p)
}坑三:gRPC 和 HTTP 的传播格式不一样导致链路断掉
现象: 内部服务间调用用了 gRPC,从 HTTP 入口进来的请求,到了 gRPC 调用那一环链路就断了。
原因: HTTP 用的是 W3C TraceContext 格式(traceparent header),我的 gRPC 客户端没有注入 metadata propagator,导致 trace context 没有被携带过去。
解法: gRPC 需要用 OTEL 的 gRPC 拦截器:
import "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
conn, err := grpc.Dial(
addr,
grpc.WithUnaryInterceptor(otelgrpc.UnaryClientInterceptor()),
grpc.WithStreamInterceptor(otelgrpc.StreamClientInterceptor()),
)完整接入方案
初始化 TracerProvider
package telemetry
import (
"context"
"fmt"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// Config 追踪配置
type Config struct {
ServiceName string
ServiceVersion string
OTLPEndpoint string // e.g. "otel-collector:4317"
SampleRate float64 // 0.0 ~ 1.0,正常请求采样率
}
// Init 初始化全局 TracerProvider,返回 shutdown 函数
func Init(ctx context.Context, cfg Config) (shutdown func(context.Context) error, err error) {
// 建立 gRPC 连接到 OTEL Collector
conn, err := grpc.DialContext(ctx, cfg.OTLPEndpoint,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
if err != nil {
return nil, fmt.Errorf("failed to connect to OTEL collector: %w", err)
}
// 创建 OTLP exporter
exporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn))
if err != nil {
return nil, fmt.Errorf("failed to create OTLP exporter: %w", err)
}
// 定义服务资源信息(这些会出现在每个 span 上)
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName(cfg.ServiceName),
semconv.ServiceVersion(cfg.ServiceVersion),
),
resource.WithOS(),
resource.WithHost(),
)
if err != nil {
return nil, fmt.Errorf("failed to create resource: %w", err)
}
// 创建 TracerProvider
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter,
sdktrace.WithBatchTimeout(5*time.Second),
sdktrace.WithMaxExportBatchSize(512),
),
sdktrace.WithSampler(sdktrace.ParentBased(
// 根 span 使用概率采样
sdktrace.TraceIDRatioBased(cfg.SampleRate),
)),
sdktrace.WithResource(res),
)
// 设置全局 TracerProvider 和 Propagator
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{}, // W3C TraceContext
propagation.Baggage{}, // W3C Baggage
))
return func(ctx context.Context) error {
return tp.Shutdown(ctx)
}, nil
}HTTP 中间件
package middleware
import (
"net/http"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
// OTELMiddleware 为每个 HTTP 请求创建 span
func OTELMiddleware(serviceName string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
// otelhttp 自动处理 context 传播
return otelhttp.NewHandler(next, serviceName,
otelhttp.WithFilter(func(r *http.Request) bool {
// 跳过健康检查的追踪(避免噪音)
return r.URL.Path != "/healthz/live" && r.URL.Path != "/healthz/ready"
}),
otelhttp.WithSpanNameFormatter(func(operation string, r *http.Request) string {
// 自定义 span 名,用 method + path pattern,不用实际 path(避免高基数问题)
return r.Method + " " + getRoutePattern(r)
}),
)
}
}
// getRoutePattern 从路由匹配中获取模式(避免 /users/123 产生高基数 span 名)
func getRoutePattern(r *http.Request) string {
// 如果使用了 chi/gin 等框架,从框架获取路由模式
// 这里简化处理
if pattern := r.Pattern; pattern != "" {
return pattern
}
return r.URL.Path
}
// InstrumentHandler 手动给某个处理函数添加 span(适合关键业务逻辑)
func InstrumentHandler(tracer trace.Tracer, spanName string, f func(ctx context.Context) error) func(context.Context) error {
return func(ctx context.Context) error {
ctx, span := tracer.Start(ctx, spanName)
defer span.End()
start := time.Now()
err := f(ctx)
duration := time.Since(start)
span.SetAttributes(attribute.Int64("duration_ms", duration.Milliseconds()))
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
// 错误请求:通过 baggage 标记需要强制采样
// 注意:这个标记只对新请求有效,无法回溯已做的采样决定
}
return err
}
}在业务代码中使用
package service
import (
"context"
"fmt"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
var tracer = otel.Tracer("apimanager/service")
type APIService struct {
repo APIRepository
ocr OCRClient
}
func (s *APIService) CreateProject(ctx context.Context, req CreateProjectRequest) (*Project, error) {
// 创建 span,记录关键属性
ctx, span := tracer.Start(ctx, "APIService.CreateProject",
trace.WithAttributes(
attribute.String("project.name", req.Name),
attribute.String("team.id", req.TeamID),
),
)
defer span.End()
// 数据库操作会自动继承这个 span(如果用了 OTEL 的 database driver)
project, err := s.repo.Create(ctx, req)
if err != nil {
span.RecordError(err)
return nil, fmt.Errorf("create project: %w", err)
}
// 调用第三方服务,context 会自动传播 trace 信息
if req.HasIcon {
if err := s.processIcon(ctx, project.ID, req.IconURL); err != nil {
// 这里用 span.AddEvent 而不是 RecordError,因为 icon 处理失败不是致命错误
span.AddEvent("icon_processing_failed",
trace.WithAttributes(attribute.String("error", err.Error())),
)
}
}
span.SetAttributes(attribute.String("project.id", project.ID))
return project, nil
}
func (s *APIService) processIcon(ctx context.Context, projectID, iconURL string) error {
ctx, span := tracer.Start(ctx, "APIService.processIcon")
defer span.End()
// 调用 OCR 服务,这里 context 里已经有 trace 信息
// OCR 客户端需要用 otelhttp 的 transport 来自动传播
result, err := s.ocr.Process(ctx, iconURL)
if err != nil {
span.RecordError(err)
return err
}
span.SetAttributes(
attribute.String("ocr.result_type", result.Type),
attribute.Int("ocr.confidence", result.Confidence),
)
return nil
}Go vs Java:这块差距到底在哪
Java 生态的 SkyWalking Agent 或者 Pinpoint Agent,是字节码注入方案——你什么代码都不用写,Agent 自动给所有 Spring MVC 请求、JDBC 调用、RPC 调用加上追踪。
Go 没有这种运行时字节码注入(Go 不是 JVM,没有字节码),所以追踪必须侵入代码。但 Go 的 OTEL SDK 提供了很多官方 instrumentation 库(otelhttp、otelgrpc、otelsql 等),常见的基础设施基本都有现成的。
业务代码里的追踪还是要手动加。我的经验是:不要每个函数都加 span,会产生噪音。只在以下场景加:
- 对外的 HTTP/gRPC 调用
- 数据库事务
- 耗时超过 50ms 的业务操作
- 关键业务路径(创建订单、支付等)
加完这些之后,85% 的排查场景都能在追踪里找到答案。
效果对比
接入 OTEL 之后,之前那种3小时排查问题的情况再没出现过。现在同样的问题,打开 Jaeger,搜索对应的 traceID(从日志里关联),整个调用链一目了然,平均排查时间从3小时降到了11分钟。
