在分布式追踪系统中,采样策略直接影响着系统的性能和可观测性。OpenTelemetry 作为当前最流行的可观测性框架,其采样机制设计有着深刻的考量。本文将深入探讨 OpenTelemetry 的采样机制,特别是为什么它在 SDK 层面不支持尾采样。

前置采样 vs 尾采样

在讨论 OpenTelemetry 的采样机制前,我们需要理解两种主要的采样策略:

前置采样(Head-based Sampling)

  • 在链路开始时就决定是否采样
  • 决策一旦做出,整个链路都遵循这个决策
  • 不需要缓存完整的链路数据

尾采样(Tail-based Sampling)

  • 在链路结束后决定是否保留
  • 可以基于完整链路信息(如总耗时、是否有错误)做决策
  • 需要临时缓存所有链路数据

OpenTelemetry 的采样实现

通过分析 OpenTelemetry Go SDK 的源码,我们可以清晰地看到它采用的是前置采样策略。关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func (tr *tracer) newSpan(ctx context.Context, name string, config *trace.SpanConfig) trace.Span {
// ... 前面的代码 ...

// 执行采样决策
samplingResult := tr.provider.sampler.ShouldSample(SamplingParameters{
ParentContext: ctx,
TraceID: tid,
Name: name,
Kind: config.SpanKind(),
Attributes: config.Attributes(),
Links: config.Links(),
})

// 设置采样标志
if isSampled(samplingResult) {
scc.TraceFlags = psc.TraceFlags() | trace.FlagsSampled
} else {
scc.TraceFlags = psc.TraceFlags() &^ trace.FlagsSampled
}

// ... 后面的代码 ...
}

这段代码揭示了几个关键点:

  1. 采样决策在 span 创建时就已经做出
  2. 采样标志通过位操作设置在 TraceFlags 中
  3. 这个标志会随着 SpanContext 传播到整个分布式系统

采样标志的传播机制

特别值得注意的是设置采样标志的代码:

1
2
3
4
5
if isSampled(samplingResult) {
scc.TraceFlags = psc.TraceFlags() | trace.FlagsSampled
} else {
scc.TraceFlags = psc.TraceFlags() &^ trace.FlagsSampled
}

这段代码使用位操作来设置或清除采样标志:

  • | 操作用于设置采样标志,保留其他标志位不变
  • &^ 操作用于清除采样标志,同样保留其他标志位不变

这确保了采样决策能够一致地传播到整个分布式链路中。

为什么 OpenTelemetry 不支持尾采样?

最重要的原因是:在 SDK 中找不到尾巴!因为不知道链路什么时候结束!

在分布式系统中,一条链路可能跨越多个服务,所以你在某一个服务中,是不知道链路是否结束的,而 OpenTelemetry 也不是一次性上报一整条链路,而是每个 span 独立上报,最后再拼接到一起。

OpenTelemetry 上报原理

  1. 独立上报

    • 每个 span 在结束时(调用 span.End())会被传递给 SpanProcessor

    • SpanProcessor 决定如何处理这个 span(立即导出或批量导出)

    • 导出是独立的,不会等待整个 trace 完成

  2. 批处理机制

    • 默认使用 BatchSpanProcessor,它会收集一定数量的 spans 或等待一定时间然后批量导出

    • 但这个批处理与 trace 完整性无关,只是为了效率

Collector 如何实现尾采样

Collector 通过以下方式解决这些问题:

  1. 设置等待时间窗口

    • 为每个 trace 设置一个等待期(如 10 秒)

    • 在此期间收集该 trace 的所有 spans

    • 超过等待期后,基于已收集的 spans 做决策

  2. 集中式收集

    • 所有服务的 spans 都发送到 Collector

    • Collector 有更全面的视图来关联 spans

  3. 专门的资源分配:Collector 作为独立组件,有专门的资源处理这种复杂逻辑,不会影响应用性能。

如何在 OpenTelemetry 生态中实现尾采样?

虽然 SDK 不直接支持尾采样,但 OpenTelemetry 生态提供了其他方式实现类似功能:

1. 使用 OpenTelemetry Collector

Collector 提供了 Tail Sampling Processor,可以在数据聚合层实现尾采样:

1
2
3
4
5
6
7
8
9
processors:
tail_sampling:
decision_wait: 10s
num_traces: 100
expected_new_traces_per_sec: 10
policies:
- name: error-policy
type: status_code
status_code: ERROR

2. 结合前置采样和错误捕获

可以实现一个智能的前置采样器,对特定场景(如包含错误属性)强制采样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type SmartSampler struct {
baseSamplingRate float64
}

func (s *SmartSampler) ShouldSample(p trace.SamplingParameters) trace.SamplingResult {
// 错误请求必采样
for _, attr := range p.Attributes {
if attr.Key == "error" {
return trace.SamplingResult{Decision: trace.RecordAndSample}
}
}

// 其他请求使用基础采样率
if float64(p.TraceID[0])/255.0 < s.baseSamplingRate {
return trace.SamplingResult{Decision: trace.RecordAndSample}
}

return trace.SamplingResult{Decision: trace.Drop}
}

3. 使用专门的后端系统

一些专门的可观测性后端系统提供了尾采样功能:

  • Jaeger 的 Adaptive Sampling

  • SkyWalking 的 Trace Sampling

  • Grafana Tempo 的 Trace Sampling

结论

OpenTelemetry SDK 采用前置采样而非尾采样,是基于分布式系统一致性、性能优化和架构分层等多方面考虑的结果。虽然这意味着无法基于完整链路信息做采样决策,但 OpenTelemetry 生态提供了多种方式来弥补这一限制。

在实际应用中,我们可以:

  1. 在 SDK 层使用智能前置采样策略,确保关键链路被采样
  2. 在 Collector 层实现尾采样,进一步筛选有价值的链路
  3. 结合使用多种采样策略,平衡性能和可观测性

通过这种分层设计,OpenTelemetry 既保证了高效的数据收集,又为高级采样策略提供了可能性,满足了不同场景的需求。

实战案例

笔者实现一个 Go 语言的开源项目 goapm,对多个 Go 语言中常用的组件进行了 trace、log 和 metrics 的集成封装,用于快速在 Go 语言项目中实现可观测性,同时还提供了 goapm-example 实战案例,可供参考。