Go 语言 链路追踪 实战:从原理到落地
前阵子线上一个下单接口突然变慢,用户反馈要等七八秒才有响应。这个接口一路串了订单、库存、支付三个服务,日志翻了半天,只知道确实慢,就是不知道慢在哪一段。最后我干了一件很蠢但有效的事:在三个服务里手动打时间戳日志,一条一条对出来,是库存服务里一条没走索引的 SQL。问题解决了,但这个过程实在太难受,于是决定认真搞一搞链路追踪。
结果一查资料就被劝退了一次:OpenTelemetry 的 Go 官方文档写得是真跳跃,大部分中文教程还停留在两三年前的老 API,跟着敲不是包路径对不上就是刷一屏 deprecated 警告,没准儿等你搜到某篇教程的时候,里面用的老上报方式已经废了。下面记录一下我实测跑通的完整过程,从原理到代码到部署,一次说清楚。
原理其实就三句话
链路追踪这玩意儿拆开看就三个概念,十分钟就能讲明白:
- Trace:一次请求从头到尾的完整链路,用一个全局唯一的 trace ID 标识。用户请求进来那一刻生成,之后不管经过多少个服务,这个 ID 都不变。
- Span:链路上的一段操作,比如"调用库存服务"、"执行一条 SQL",有开始时间、结束时间,还能互相嵌套,拼起来就是一棵树。
- 传播(Propagation):分布式场景下最关键的一环。trace ID 怎么从服务 A 传到服务 B?答案是塞进请求头里,业界标准叫 W3C Trace Context,就是 HTTP 头里
traceparent那一串。
巧的是,Go 天生就有现成的载体,context.Context,trace 信息跟着 context 在函数之间传就行,这点比 Java 那些靠线程变量硬塞的方案舒服太多了。
选型:OpenTelemetry + Jaeger
没什么好纠结的,直接上 OpenTelemetry(下称 OTel)。它是 CNCF 的标准,SDK 统一,以后想换后端(Jaeger、Tempo、SkyWalking 随便换)只需要改 exporter 配置,业务代码不用动。后端我选 Jaeger,够轻量,UI 也够用,开发调试阶段用 all-in-one 镜像一行命令起:
docker run -d --name jaeger -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one:1.57
16686 是 UI 页面端口,4318 是 OTLP over HTTP 的接收端口。注意老教程里常见的那种往 14268 上报 Thrift 格式的方式已经不推荐了,Jaeger 现在的官方口径就是让你用 OTLP。
写代码:初始化 TracerProvider
先把依赖拉下来:
go get go.opentelemetry.io/otel go.opentelemetry.io/otel/sdk go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp
然后写个初始化函数:
package trace
import (
"context"
"go.opentelemetry.io/otel"
"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.26.0"
)
func Init(ctx context.Context) (func(context.Context) error, error) {
exporter, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpoint("localhost:4318"),
otlptracehttp.WithInsecure(), // 本地没配证书,必须加这个,不加连不上
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName("order-service"), // Jaeger 里显示的服务名
)),
)
otel.SetTracerProvider(tp)
return tp.Shutdown, nil
}
main 里启动时调一下,退出时记得 shutdown,不然内存里攒着的 span 可能还没发出去进程就没了,你会看到链路莫名其妙缺一段:
shutdown, err := trace.Init(context.Background())
if err != nil {
log.Fatal(err)
}
defer shutdown(context.Background())
中间件:把 trace 织进 HTTP 服务
以 gin 为例,写一个中间件干两件事:从请求头里 Extract 上游传来的 trace 上下文,再为当前请求开一个根 span。
func Middleware() gin.HandlerFunc {
propagator := propagation.TraceContext{}
tracer := otel.Tracer("order-service")
return func(c *gin.Context) {
ctx := propagator.Extract(c.Request.Context(), propagation.HeaderCarrier(c.Request.Header))
ctx, span := tracer.Start(ctx, c.Request.Method+" "+c.FullPath())
defer span.End()
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
这里最容易犯的错就是 Extract 完忘了 c.Request = c.Request.WithContext(ctx),上下文没塞回去,后面所有业务代码拿到的都是干净的 context,span 全断。
然后是往下游传。发起 HTTP 调用时把当前上下文 Inject 进请求头:
req, _ := http.NewRequestWithContext(ctx, "POST", "http://stock-service/api/deduct", body)
propagation.TraceContext{}.Inject(ctx, propagation.HeaderCarrier(req.Header))
gRPC 服务更省事,社区有现成的拦截器:
import "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
// 服务端
grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()))
// 客户端
grpc.NewClient(addr, grpc.WithStatsHandler(otelgrpc.NewClientHandler()))
顺带一提,网上还能搜到大量 otelgrpc.UnaryServerInterceptor() 的写法,旧版本能用,但新版推荐 StatsHandler 这种方式,性能和流式调用支持都更好。数据库同理,GORM 有 gorm.io/plugin/opentelemetry 插件,一行挂上就行,SQL 耗时会以子 span 的形式挂在链路上。
看效果
不出问题的话,访问几次接口,打开 http://localhost:16686,选中 order-service,Find Traces,就能看到瀑布图了:订单服务里嵌着库存服务和支付服务的 span,库存服务里又嵌着那条 SQL,慢在哪一眼就看出来。之前手动对日志的活儿,以后是真不用再干了。
踩过的几个坑
最后记一下我踩过的坑,没准儿能帮你省点时间:
WithInsecure()忘了加。本地起的 Jaeger 没有证书,OTLP exporter 默认走 TLS,表现就是一直连接超时,报错还不怎么直白。- 链路断成两截。Jaeger 里看到两条互不相干的 trace,八成是某个服务的中间件忘了 Extract,或者 Extract 了忘了写回 context。
- goroutine 里 trace 丢了。开协程前记得把 ctx 传进去;协程里要调下游的话,用
trace.ContextWithRemoteSpanContext把 span 上下文带过去。 - 生产全量采样把 Jaeger 打爆了。开发环境全量没问题,生产建议
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1))),采样率 10%,并且 ParentBased 能保证同一条链路要么全采要么全丢,不然拼出来的链路是残的。 - 退出没 Shutdown。表现为部分请求的 trace 随机丢失,排查半天以为网络问题,其实是进程退出时 batcher 里的数据没刷出去。
写在最后
链路追踪不算什么黑科技,原理十分钟讲完,真正花时间的是落地:每个服务的框架、每个中间件、每个下游调用都得接一遍。我的建议是别贪多,先挑一条最核心的调用链打通,在 Jaeger 里看到完整瀑布图的那一下还是很爽的,剩下的服务照葫芦画瓢,一个下午基本都能铺完。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。