三个工程师查了两小时
宠物医疗 SaaS 拆成微服务后,排查一次挂号请求要跨 API 网关、clinic-rpc、payment-rpc、inventory-rpc 四个服务。最开始出了问题只能靠日志拼时间线:每个服务打印自己的 requestId,但请求一经过 gRPC 调用,requestId 就断了,根本串不起来。
有一次线上出问题,“挂号后扣费失败”,三个工程师对着日志查了两个小时,才定位到是 inventory-rpc 超时导致的回滚失败。小问题查成这样,必须上全链路追踪了。选型用了 Jaeger,因为它兼容 OpenTracing 标准,go-zero 也有内置支持。
traceId 怎么透传
核心思路一句话能说完:在入口层生成 traceId,通过 HTTP Header 和 gRPC metadata 一路传下去,每个服务处理请求时从 context 里取出 SpanContext,创建子 Span 上报给 Jaeger Agent。

go-zero 自带了 trace 包,在 API 层配置一个 Jaeger 上报地址即可自动注入:
1
2
3
4
5
6
7
8
9
| # api/etc/clinic-api.yaml
Name: clinic-api
Host: 0.0.0.0
Port: 8888
Telemetry:
Name: clinic-api
Endpoint: http://jaeger-agent:14268/api/traces
Sampler: 1.0
Batcher: jaeger
|
但 go-zero 内置的 trace 只覆盖它自己生成的 gRPC 客户端。我们有些连接是直接用 grpc.Dial 创建的,这种就得手动加拦截器。服务端拦截器负责从 incoming context 提取 SpanContext 并创建服务端 Span:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| func UnaryServerInterceptor(tracer opentracing.Tracer) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{},
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
md, ok := metadata.FromIncomingContext(ctx)
var spanCtx opentracing.SpanContext
if ok {
// 从 gRPC metadata 的 uber-trace-id 提取
if carriers, ok := md["uber-trace-id"]; ok && len(carriers) > 0 {
textMap := opentracing.TextMapCarrier{"uber-trace-id": carriers[0]}
spanCtx, _ = tracer.Extract(opentracing.TextMap, textMap)
}
}
span := tracer.StartSpan(
info.FullMethod,
ext.RPCServerOption(spanCtx),
)
defer span.Finish()
ctx = opentracing.ContextWithSpan(ctx, span)
resp, err := handler(ctx, req)
if err != nil {
ext.Error.Set(span, true)
span.LogKV("event", "error", "message", err.Error())
}
return resp, err
}
}
|
客户端拦截器反过来,把当前 SpanContext 注入 outgoing metadata:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| func UnaryClientInterceptor(tracer opentracing.Tracer) grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string,
req, reply interface{}, cc *grpc.ClientConn,
invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
span, ctx := opentracing.StartSpanFromContext(ctx, method)
defer span.Finish()
md, _ := metadata.FromOutgoingContext(ctx)
if md == nil {
md = metadata.New(nil)
}
carrier := opentracing.TextMapCarrier{}
_ = tracer.Inject(span.Context(), opentracing.TextMap, carrier)
for k, v := range carrier {
md.Set(k, v)
}
ctx = metadata.NewOutgoingContext(ctx, md)
return invoker(ctx, method, req, reply, cc, opts...)
}
}
|
HTTP 层我们在 Gin 网关加了一个中间件,从请求头取 Uber-Trace-Id,没有就新开根 Span:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| func TracingMiddleware(tracer opentracing.Tracer) gin.HandlerFunc {
return func(c *gin.Context) {
spanCtx, _ := tracer.Extract(
opentracing.HTTPHeaders,
opentracing.HTTPHeadersCarrier(c.Request.Header),
)
span := tracer.StartSpan(
c.Request.URL.Path,
ext.RPCServerOption(spanCtx),
)
defer span.Finish()
ctx := opentracing.ContextWithSpan(c.Request.Context(), span)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
|
链路是在哪里断的
第一个断点是异步 goroutine。有些逻辑起 goroutine 异步处理,直接用了 context.Background(),Span 链就断了。我们的规矩是异步任务必须从父 context 派生,但要 detach,不能直接用父 ctx,因为父 ctx 在 HTTP 返回后会被 cancel。我封装了一个 detachContext,只保留 trace 信息、不继承 cancel 信号。
第二个是采样率。生产环境 100% 采样,Jaeger 后端和网络的压力都不小。我们改成 10% 采样,错误请求强制 100%(在拦截器里判断 err != nil 时设置 sampling.priority=1)。
第三个是 B3 和 Jaeger 原生头的兼容。老版本 Istio sidecar 用的是 B3 头(X-B3-TraceId),我们应用层用的是 uber-trace-id,两边串不起来。统一改成 W3C TraceContext(traceparent 头)后解决了这个问题,也是未来的标准方向。
值不值
全链路追踪的价值不在平时,而在故障时:它把跨服务的黑盒变成可观测的调用链。关键是 context 透传不能有断点,HTTP 入口、gRPC 双向、异步 goroutine 都要覆盖到。踩完这些坑之后,宠物医疗 SaaS 排查一次跨服务故障的平均时间,从小时级降到了分钟级。
封面图:dolbinator1000 / Flickr · CC BY 2.0