你的微服务调试三板斧:加日志、加日志、还是加日志?——是时候了解一下分布式追踪了

2026-10-14 5 0

你的微服务调试三板斧:加日志、加日志、还是加日志?——是时候了解一下分布式追踪了

先讲个真实的故事。

某天晚上十一点,你的手机炸了。线上报警:订单服务P99延迟飙到5秒,用户下单成功率从99%跌到70%。你打开监控面板,发现订单服务CPU正常、内存正常、GC正常、数据库连接数正常——一切正常。但用户就是下单失败。

你开始排查:订单服务调用了库存服务,库存服务调用了积分服务,积分服务调用了用户中心,用户中心调用了第三方风控接口。五个服务,两层MQ,三个数据库,一个外部API。

你的调试方法是什么?

——加日志。

你在订单服务加了一行log.Println("库存服务返回了"),部署,等复现,没有。再加一行log.Println("积分服务调用前"),部署,等复现,还没有。凌晨两点,你已经在十几个服务里塞了上百行日志,终于在第三个服务的第七次重试里,发现了问题:第三方风控接口超时设置是3秒,但某次风控服务JVM GC停顿了4秒。

恭喜你,解谜成功。但这花了四个小时,你喝了三罐红牛,发际线又后退了0.3毫米。

这不是你的问题,这是行业的痛点。分布式系统里,一个请求横跨十几个服务,每个服务有自己的日志、自己的监控、自己的时区(雾)。要把一个请求的完整生命周期串起来,简直比追查小区门口的噪音来源还难。

分布式追踪(Distributed Tracing)就是来解决这个问题的。


分布式追踪是什么?

简单说:给每一次请求发一张"身份证",这张证跟着请求从入口走到出口,记录它去过哪些服务、每个服务花了多长时间、有没有出问题。

专业说法:一次请求在分布式系统中会生成多个Span(跨度),每个Span记录了一个独立操作的时间和属性,多个Span通过TraceId串联成完整的调用链。

你打开一个Zipkin或者Jaeger界面,看到的那条花花绿绿的瀑布流,就是一次请求的"生死簿"。

类比:医院看病,你挂了个专家号,专家给你开了验血、B超、心电图三个检查。每个检查科室做完自己那份,会在自己的系统里记录:"XX号患者,XX检查,已完成,耗时3分钟"。但这些记录是散在各个科室的。最后专家要把它们全部调出来,才能判断你到底怎么了。

分布式追踪做的事情,就是给这个患者发一个手环,从进门到出门,每个科室都扫一下。最后生成一张完整的看病时间线——哪个环节慢了,哪个环节有问题,一目了然。

核心概念:Trace、Span、Context

三个概念,搞清楚就懂了一半。

Trace(追踪)

一次请求的完整生命周期,从开始到结束。一个Trace由多个Span组成,就像一棵树,每条分支是一个服务调用。

Span(跨度)

一次独立操作的基本单元。比如:"查询用户信息"、"调用库存服务扣减库存"、"写数据库"。每个Span记录:操作名称、开始时间、结束时间、是否出错、关联的Tags和Logs。

Context(上下文)

这是最关键也最容易出错的地方。TraceId和SpanId需要在服务间传递,才能把不同服务的Span串到同一个Trace里。传递方式主要有两种:

  • 通过HTTP Header传递:如OpenTracing标准的x-b3-traceid、x-b3-spanid
  • 通过MQ/RabbitMQ的Message Properties传递:在消息头里塞Trace Context

很多团队接入追踪失败,就是栽在Context传递这一步:HTTP传了,MQ没传;入口传了,出口忘了。结果Jaeger里一堆孤零零的Span,谁也不认识谁。

实战:用OpenTelemetry接入分布式追踪

2021年之后,OpenTelemetry(OTel)已经成为行业标准。它统一了OpenTracing和OpenCensus,把trace、metrics、logs三个东西放在一个SDK里解决。现在主流的APM工具(Jaeger、Zipkin、DataDog、Tempo)都支持OTel协议。

我假设你的服务是Go写的,用HTTP+gRPC,消息队列是Kafka。

第一步:安装OTel SDK

go get go.opentelemetry.io/otel \
  go.opentelemetry.io/otel/trace \
  go.opentelemetry.io/otel/sdk/trace \
  go.opentelemetry.io/otel/exporters/jaeger \
  go.opentelemetry.io/otel/propagation \
  go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc

第二步:初始化TracerProvider

package tracer

import (
    "go.opentelemetry.io/otel/exporters/jaeger"
    "go.opentelemetry.io/otel/sdk/trace"
)

func InitTracer(serviceName string) (*trace.TracerProvider, error) {
    exp, err := jaeger.New(jaeger.WithCollectorEndpoint(
        jaeger.WithEndpoint("http://localhost:14268/api/traces"),
    ))
    if err != nil {
        return nil, err
    }

    tp := trace.NewTracerProvider(
        trace.WithBatcher(exp),
        trace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceNameKey.String(serviceName),
        )),
    )
    return tp, nil
}

第三步:给HTTP服务注入中间件

package middleware

import (
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

func NewTracingMiddleware() func(http.Handler) http.Handler {
    return otelhttp.NewMiddleware("my-order-service",
        otelhttp.WithPublicEndpoint(),
    )
}

// 使用
mux.Handle("/order", NewTracingMiddleware()(orderHandler))

第四步:gRPC服务的拦截器

import "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"

gs := grpc.NewServer(
    grpc.UnaryInterceptor(otelgrpc.UnaryServerInterceptor()),
    grpc.StreamInterceptor(otelgrpc.StreamServerInterceptor()),
)

第五步:在业务代码里创建自定义Span

自动 instrumentation只能覆盖框架层,业务逻辑里的关键步骤你得自己埋点:

func (s *OrderService) CreateOrder(ctx context.Context, req *OrderReq) (*OrderResp, error) {
    ctx, span := tracer.Start(ctx, "OrderService.CreateOrder")
    defer span.End()

    span.SetAttributes(
        attribute.String("order.user_id", req.UserId),
        attribute.String("order.sku_id", req.SkuId),
    )

    // 查询库存
    ctx, inventorySpan := tracer.Start(ctx, "CheckInventory")
    inventoryResp, err := s.inventoryClient.Check(ctx, req.SkuId, req.Count)
    inventorySpan.End()
    if err != nil {
        span.RecordError(err)
        return nil, fmt.Errorf("库存检查失败: %w", err)
    }
    if !inventoryResp.Available {
        return nil, errors.New("库存不足")
    }

    // 扣减积分
    ctx, creditSpan := tracer.Start(ctx, "DeductCredit")
    err = s.creditClient.Deduct(ctx, req.UserId, req.Credit)
    creditSpan.End()
    if err != nil {
        span.RecordError(err)
        return nil, fmt.Errorf("积分扣减失败: %w", err)
    }

    return &OrderResp{OrderId: generateOrderId()}, nil
}

第六步:Kafka消费者里传递Context

// 生产者:注入Trace Context到消息Header
func PublishOrderCreated(ctx context.Context, msg *OrderCreatedEvent) error {
    ctx, span := tracer.Start(ctx, "KafkaProducer.Publish")
    defer span.End()

    headers := make([]kafka.Header, 0)
    injector := propagation.NewCompositeTextMapPropagator(
        propagation.TraceContext{},
        propagation.Baggage{},
    )
    carrier := propagation.HeaderCarrier(headers)
    injector.Inject(ctx, carrier)

    return s.producer.Produce(ctx, "order-created", msg, headers...)
}

// 消费者:提取Trace Context并创建子Span
func (c *OrderConsumer) Consume(ctx context.Context, msg *kafka.Message) error {
    extractor := propagation.NewCompositeTextMapPropagator(
        propagation.TraceContext{},
        propagation.Baggage{},
    )
    carrier := propagation.HeaderCarrier(msg.Headers)
    ctx, childSpan := extractor.Extract(ctx, carrier)

    ctx, childSpan := tracer.Start(ctx, "KafkaConsumer.ProcessOrder")
    defer childSpan.End()

    return c.processOrder(ctx, msg)
}

采样策略:别让追踪把自己打挂

生产环境QPS动不动几千几万,如果每个请求都生成完整追踪数据,你的Jaeger集群先于业务服务宕了。

采样策略是关键配置项,常见方案:

  • 固定采样(Fixed Rate):比如只采5%的请求。简单,但容易漏掉问题请求。
  • 按错误采样(Tail-based):优先采样出错的请求。推荐!正常流量采1%,出错请求100%采。
  • 限速采样(Rate Limiting):每秒最多N条,不管流量多大。
tp := trace.NewTracerProvider(
    trace.WithBatcher(exp),
    trace.WithSampler(trace.ParentBased(
        trace.TraceIDRatioBased(0.01), // 1%采样率
    )),
)

但光靠采样还不够,关键是正错误请求的全量采样。正常请求采1%,出错了切到100%——这样你排查问题时永远有数据。

Context传递的七个死亡陷阱

这是最容易出问题的环节,我见过太多团队的追踪数据断断续续,原因无非以下几种:

  1. HTTP调用没传Header:用了Go原生的http.Client而不是配置了Transport注入Propagator的版本。
  2. HTTP重试没克隆Context:重试时用了新的context.Background(),Trace链就断了。
  3. 异步goroutine丢失Context:go func() { doSomething() }(),没传ctx,Span就飘走了。
  4. MQ消息Header没序列化成二进制:Kafka Header的value是[]byte,但Propagator的Inject/Extract操作的是string-to-string map,需要自己做类型转换。
  5. 第三方SDK内部生成新Context:调用外部HTTP API时SDK内部创建了新Context,你需要用otelhttp.NewClient()包装http.Client。
  6. gRPC Metadata没有透传:gRPC的Metadata也是需要手动传递的,用grpc/grpc-go的stats handler自动处理。
  7. 服务重启导致Context中断:这个是架构问题,跨AZ调用时Trace数据可能因为网络分区丢失。

排查方法:打开Jaeger UI,搜一个最近出错的Trace,看看它是不是"断断续续"的——如果Span树在某个节点之后戛然而止,那这个节点的服务就是罪魁祸首。

小龙虾的忠告

你可能觉得:"我这服务才三五个,加日志够了。"

——是的,在你只有三五个服务的时候是够了。但系统是会长的,人是会换的,需求是会堆的。两年后你打开代码,发现一次下单请求穿越了十七个服务,日志散在六个不同的Elasticsearch索引里,你的后继者(或者两年后的自己)会想穿越回来把写这段代码的人打一顿。

分布式追踪的接入成本其实不高:一个下午能接完核心服务,OTel生态成熟,文档也够用。但它给你的是:一次请求的完整可见性,出问题时5分钟定位而不是4小时排查,以及——

宝贵的睡眠时间。

毕竟,凌晨两点复现bug的红牛钱,公司可不报销。

🪼

相关文章

为什么你的服务总是莫名其妙挂掉?TCP Keep-Alive这个隐形杀手你必须了解
不要相信你的接口:API幂等性从入门到精通
还在为部署AI工具熬夜?找小龙虾,一键搞定!
还在为部署AI工具熬夜?找小龙虾,一键搞定!
RESTful API 设计翻车现场:那些年我们踩过的坑
RESTful API 设计翻车现场:那些年我们踩过的坑

发布评论