<p style="text-align:center"><img src="https://www.171host.com/wp-content/uploads/2026/10/20261022054912-6ad9a3d8ba8e9.png" alt="封面图" style="max-width:100%;height:auto" /></p>
凌晨排查一次无头案:TraceID在网关被吞了,OpenTelemetry+Jaeger链路断了
凌晨两点收到报警:订单服务P99飙到3秒,用户疯狂投诉。我打开Jaeger搜trace,结果发现订单服务有记录,下游的库存、支付服务却完全看不到——链路在中间断掉了。没有完整trace,你只能挨个服务翻日志,像查无头案。这篇文章就把这次排查过程和OpenTelemetry+Jaeger的落地经验一次讲透。
排查过程:链路为什么断了
我们的链路是:网关(Gateway) → 订单服务(Go) → 库存服务(Java)。订单服务的trace里根本没有库存的span。我先怀疑订单没发请求,看了nginx日志,请求确实发出去了。再抓包看请求头,发现了关键问题:**请求头里没有`traceparent`**。
原理很简单:分布式追踪的跨服务传递靠的是W3C Trace Context标准,也就是`traceparent: 00-<traceid>-<spanid>-01`这个HTTP头。上游的tracer生成它,下游的tracer解析它,才能把span挂在同一棵树上。我们的自研网关转发时把不认识的头全干掉了——链路就这么被拦腰截断。
干货一:服务端正确接入OTel SDK
以Go的订单服务为例,先初始化tracer并对接Jaeger(OTLP协议,老版本jaeger-agent那套别再用了,官方已废弃):
package main
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)
func initTracer(ctx context.Context) (*sdktrace.TracerProvider, error) {
exporter, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint("jaeger-collector:4317"),
otlptracegrpc.WithInsecure(), // 内网用,生产建议TLS
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName("order-service"), // 必须设,否则Jaeger里全是unknown_service
)),
)
otel.SetTracerProvider(tp)
return tp, nil
}
效果说明:启动后,该服务所有被中间件包裹的HTTP/gRPC请求会自动产出span,通过OTLP批量上报到Jaeger的4317端口。在Jaeger UI的Service下拉框里能看到`order-service`。注意`ServiceName`不设的话,Jaeger里会出现一堆`unknown_service:xxx`,根本没法按服务过滤。
干货二:中间件自动透传,别手写header
下一步是让服务自动接收和续传`traceparent`,用OTel的instrumentation库,别自己拼头:
import (
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
)
// 服务端:接收上游traceparent,延续同一trace
httpHandler := otelhttp.NewHandler(mux, "order-server")
// 客户端:发起下游调用时自动注入traceparent
client := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}
效果说明:服务端收请求时自动从`traceparent`提取TraceID和父span;客户端发请求时自动注入新的`traceparent`。跨进程的span就这样串成了一棵树,全程不用手写一个header。gRPC同理,用`otelgrpc.NewClientHandler()`做UnaryInterceptor。这一步做完,我在Jaeger里重放请求,订单→库存的父子span关系立刻出来了,P99慢在哪一跳一目了然。
干货三:网关补课 + 采样策略
自研网关必须显式透传追踪头。Nginx型网关加两行:
proxy_set_header traceparent $http_traceparent;
proxy_set_header b3 $http_b3; # 兼容B3格式,如果下游还在用
然后是采样。全量采样在QPS高的服务能把Jaeger的ES存储打爆,生产环境建议父级采样+头部采样:
tp := sdktrace.NewTracerProvider(
sdktrace.WithSampler(
sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1)), // 采样10%,父子强制一致
),
…
)
效果说明:每个trace只在入口做一次采样决策,整条链路要么全采要么全丢,避免出现"半个trace"——这是我见过最多的坑:下游各自随机采样,Jaeger里trace残缺不全。另外错误请求可以加`AlwaysSample`规则的尾采样(用Tail Sampling Processor在collector层做),保住排查问题的弹药。
避坑清单
– **网关/负载均衡丢`traceparent`头**:上线前用curl带该头全链路打一遍,确认每个服务都接到了
– **没设ServiceName或环境属性**:Jaeger里全混在一起,resource里加`deployment.environment`区分预发/生产
– **版本混乱**:OTel SDK、instrumentation、collector版本要保持大版本一致,混用经常出span丢失
– **Jaeger存储没规划**:ES默认7天保留,按QPS估算,span量大的话上采样和collector队列限流
– **只在入口埋点**:关键业务逻辑(下单、扣库存)手动加自定义span,定位才有粒度
链路追踪这事,工具不难,难的是把"头"透传对、把采样定合理。你们的链路里,网关这一跳透传做对了吗?