在 Go 中构建分布式追踪:跨服务请求跟踪完整指南
Source: Dev.to
📚 作者推广
作为畅销作者,我邀请您在 Amazon 上探索我的书籍。
别忘了在 Medium 上关注我并表达您的支持。谢谢!您的支持意义非凡!
1. Tracer – 系统的核心组件
实现围绕 Tracer 结构体展开。它管理整个追踪过程。创建 tracer 时需要提供:
- 服务名称 – 用于在追踪中标识服务。
- 采样率 – 实际想要记录的请求比例(在高流量系统中记录每个请求成本过高)。
tracer := NewTracer("order-service", 0.1) // Sample 10 % of traces
2. 跨度 – 工作单元
一个 跨度 代表一个工作单元(例如,数据库查询、HTTP 处理器)。StartSpan 方法是魔法的起点。它:
- 在提供的
context中查找父跨度。如果存在,新的跨度成为其子跨度,构建追踪层级。 - 向采样器询问是否应记录此跨度。
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结束。 - Propagation –
Extract提取传入的 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]
}
}
}
}