在 Go 中构建分布式追踪:跨服务请求跟踪完整指南

发布: (2026年2月18日 GMT+8 08:09)
8 分钟阅读
原文: Dev.to

Source: Dev.to

📚 作者推广

作为畅销作者,我邀请您在 Amazon 上探索我的书籍。
别忘了在 Medium 上关注我并表达您的支持。谢谢!您的支持意义非凡!

1. Tracer – 系统的核心组件

实现围绕 Tracer 结构体展开。它管理整个追踪过程。创建 tracer 时需要提供:

  • 服务名称 – 用于在追踪中标识服务。
  • 采样率 – 实际想要记录的请求比例(在高流量系统中记录每个请求成本过高)。
tracer := NewTracer("order-service", 0.1) // Sample 10 % of traces

2. 跨度 – 工作单元

一个 跨度 代表一个工作单元(例如,数据库查询、HTTP 处理器)。StartSpan 方法是魔法的起点。它:

  1. 在提供的 context 中查找父跨度。如果存在,新的跨度成为其子跨度,构建追踪层级。
  2. 向采样器询问是否应记录此跨度。
func (t *Tracer) StartSpan(ctx context.Context, name string, opts ...SpanOption) (context.Context, *Span) {
    var parentSpanContext trace.SpanContext
    if parent := trace.SpanFromContext(ctx); parent != nil {
        parentSpanContext = parent.SpanContext()
    }
    samplingResult := t.sampler.ShouldSample(SamplingParameters{
        TraceID:        generateTraceID(),
        ParentContext: parentSpanContext,
        Name:           name,
        Attributes:    make(map[string]interface{}),
    })
    // ... create span based on the sampling decision
}

空操作跨度

如果采样器决定 记录,我们返回一个空操作跨度。它不执行任何操作,保持几乎为零的开销,同时允许相同的代码路径继续运行。

真正的跨度 & 对象池

如果采样器决定 ,我们会从 sync.Pool 中获取一个跨度。复用跨度对象可以减轻 Go 垃圾回收器的压力。

span := t.spanPool.Get().(*Span)
// ... configure the span
return ctx, span

跨度生命周期

一个跨度存储:

  • 唯一 ID
  • 父 ID
  • 开始和结束时间戳
  • 属性(键‑值对,例如 http.method="GET"db.query="SELECT * FROM users"

当工作完成时,调用 EndSpan

  • 计算持续时间
  • 设置最终状态(成功、错误等)
  • 将跨度发送到缓冲通道以供导出
  • 重置跨度并将其返回池中

3. 上下文传播 – 在服务之间携带追踪数据

传播将追踪信息从一个服务传递到另一个服务。对于 HTTP,trace ID 和 span ID 会被编码在请求头中。

提取传入的上下文

ctx := tracer.Extract(r.Context(), propagation.HeaderCarrier(r.Header))

注入传出的上下文

tracer.Inject(ctx, propagation.HeaderCarrier(r.Header))

相同的模式适用于 gRPC、消息队列或任何传输方式——只需使用相应的 carrier 类型。

4. 采样策略

4.1 概率采样

最简单的方法:对每个新 trace 掷一次骰子。以 0.1(10 %)的比例为例,随机数小于 0.1 时表示该 trace 被采样。

优点:易于理解,可预测。
缺点:在流量高峰期间,即使只采样 10 % 的大量请求,也可能压垮后端。

4.2 限流采样

一种更为复杂的方法,用于限制每秒的 span 数量。

  • 信用系统 – 每秒采样器获得固定数量的信用(例如 100)。
  • 当创建一个 span 时,消耗一个信用。如果没有剩余信用,则该 span 被丢弃。

即使在突发流量激增时,这也能使追踪后端的负载保持在可控范围内。

5. 综合示例

func handler(w http.ResponseWriter, r *http.Request) {
    // 1️⃣ Extract incoming trace context
    ctx := tracer.Extract(r.Context(), propagation.HeaderCarrier(r.Header))

    // 2️⃣ Start a new span for this handler
    ctx, span := tracer.StartSpan(ctx, "http.handler", trace.WithAttributes(
        attribute.String("http.method", r.Method),
        attribute.String("http.path", r.URL.Path),
    ))
    defer span.EndSpan()

    // 3️⃣ Do some work (e.g., DB query)
    doDBWork(ctx)

    // 4️⃣ Call downstream service, injecting trace context
    req, _ := http.NewRequestWithContext(ctx, "GET", "http://service-b/api", nil)
    tracer.Inject(ctx, propagation.HeaderCarrier(req.Header))
    http.DefaultClient.Do(req)

    // 5️⃣ Respond to the client
    fmt.Fprintln(w, "OK")
}

TL;DR

  • Tracer – 中央管理器,保存服务名称和采样率。
  • Span – 工作单元;通过 StartSpan 创建,EndSpan 结束。
  • PropagationExtract 提取传入的 Header,Inject 注入传出的 Header。
  • Sampling – 采用概率或限流方式控制数据量。

有了这些构件,你就可以为任何 Go 服务添加埋点,获得端到端的可观测性,同时将开销控制在可接受范围内。祝你追踪愉快!

速率限制采样器

如果没有剩余的信用额度,新产生的 span 将不会被采样,直到积累到更多的信用额度。这为数据量设定了一个严格的上限。

func (rls *RateLimitingSampler) ShouldSample(params SamplingParameters) SamplingResult {
    rls.mu.Lock()
    defer rls.mu.Unlock()
    // Update credits based on time passed
    now := time.Now()
    elapsed := now.Sub(rls.lastCreditUpdate).Seconds()
    rls.currentCredits += elapsed * rls.creditsPerSecond
    // Spend a credit if we have one
    if rls.currentCredits >= 1.0 {
        rls.currentCredits -= 1.0
        return SamplingResult{Decision: RecordAndSample}
    }
    return SamplingResult{Decision: Drop}
}

甚至更智能的系统可能使用自适应采样。如果检测到 HTTP 错误码上升,它可以自动提高采样率,在故障期间提供更多可见性。采样器接口使得插入这些不同策略变得容易。

Source:

导出 Span

收集 Span 是一件事,把它们发送到有用的地方又是另一件事。TraceExporter 负责这项工作。Span 会被发送到一个带缓冲的通道(exporterCh)。另一个 goroutine 从该通道读取并将 Span 分组成批次。批处理对效率至关重要——如果每个 HTTP 请求只发送一个 Span,将会非常浪费。通过把它们分组,可以显著降低网络开销。

批处理器要么等待批次填满(例如 100 个 Span),要么等待计时器触发(例如每 5 秒一次)。这样,在高流量时 Span 能快速导出,而在低流量时也不会让部分批次无限期等待。

func (te *TraceExporter) processBatches() {
    batch := make([]*SpanData, 0, te.batchSize)
    for {
        select {
        case span := <-te.batchCh:
            batch = append(batch, span)
            if len(batch) >= te.batchSize {
                te.sendBatch(batch)
                batch = batch[:0]
            }
        case <-time.After(te.flushInterval):
            if len(batch) > 0 {
                te.sendBatch(batch)
                batch = batch[:0]
            }
        }
    }
}
0 浏览
Back to Blog

相关文章

阅读更多 »

大规模支付系统设计

当 Maria 点击“Confirm Ride”时,实际上会发生什么?Maria 在 15 分钟后有一个重要会议。她没有现金。她打开 Uber,发起叫车请求,得到……