Go 链路追踪采样:AI 推理服务高频调用下,全量采样反而拖慢推理延迟

Go 链路追踪采样:AI 推理服务高频调用下,全量采样反而拖慢推理延迟

Go 链路追踪采样:AI 推理服务高频调用下,全量采样反而拖慢推理延迟

一、开了全量 Tracing 之后,P99 延迟涨了 40%

为了让每条推理请求都可追踪,团队开启了 OpenTelemetry 的全量采样(AlwaysOnSampler)。结果监控面板上 P99 延迟从 200ms 跳到了 280ms——40% 的涨幅全来自 Tracing 开销。更糟的是,Jaeger 的存储被每秒 3000 条 Span 打爆,查询一条 Trace 要等 30 秒。

基础设施不需要漂亮话,全量采样在 AI 推理场景是个反模式。推理请求天然高频(单卡每秒上千次)、低延迟(P50 在 100ms 以内),每个请求产出的 Span 数量又不少(入口 → 预处理 → 模型推理 → 后处理 → 返回)。全量采样意味着每个请求都要做 Context 注入/提取、Span 创建/结束、属性序列化、批量导出——这些都不是免费的操作。

链路追踪的正确姿势是按需采样,精准保留——正常请求以 1%-5% 的概率采样,异常请求(失败、超时、长尾延迟)100% 保留。这样 Tracing 开销降到可忽略级别,同时保证了排障场景下的 Trace 覆盖率。

二、智能采样的决策模型

采样决策不应该是一个全局的固定比例,而应该根据请求特征动态决策:

flowchart TD A[推理请求到达] --> B{采样决策引擎} B --> C{请求是否错误?<br/>status_code >= 500} C -->|是| Z[100% 采样<br/>priority=HIGH] C -->|否| D{延迟是否超 SLO?<br/>duration > 200ms} D -->|是| Z D -->|否| E{是否强制采样?<br/>Header: X-Force-Trace=true} E -->|是| Z E -->|否| F{随机采样<br/>概率 1%-5%} F -->|命中| G[采样<br/>priority=LOW] F -->|未命中| H[不采样<br/>仅记录 Metrics] Z --> I[创建 Span<br/>记录完整属性] G --> J[创建 Span<br/>精简属性集] H --> K[仅更新 Metrics<br/>延迟 / 错误计数]

这个决策模型的核心逻辑是"正常请求靠统计,异常请求靠 Trace"。抽样率由baseSamplingRate控制,但在以下情况强制 100% 采样:

  • 推理返回 4xx/5xx 错误
  • P99 延迟超过 SLO 阈值
  • Header 携带X-Force-Trace: true
  • 上游 Span 已被采样(保持 Trace 完整性)

同时,采样后的 Span 应区分优先级。高优先级 Span(错误/慢请求)保留完整属性(模型名、版本、batch size、GPU UUID、输入 token 数),低优先级 Span(随机采样)只保留核心属性(service name、operation name、duration),减少存储和序列化开销。

三、生产级智能采样器实现

以下代码实现 OpenTelemetry SDK 的自定义 Sampler,融合错误强制采样、延迟驱动采样和概率采样:

package tracing import ( "math/rand" "sync" "sync/atomic" "time" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/trace/noop" ) // InferenceSampler AI 推理服务智能采样器 type InferenceSampler struct { // 基础采样率:正常请求的采样概率 (0.0 - 1.0) baseSampleRate float64 // SLO 延迟阈值:超过此值的请求强制采样 sloThreshold time.Duration // 当前时间函数(用于测试注入) nowFunc func() time.Time // 随机源(线程安全) rng *rand.Rand rngMu sync.Mutex // 采样统计(用于自适应调整) totalRequests int64 sampledRequests int64 forcedSamples int64 // 下游 Sampler:当不采样时,使用 noop.NewTracerProvider 返回 noop span } // NewInferenceSampler 创建推理服务采样器 func NewInferenceSampler(baseRate float64, sloThreshold time.Duration) *InferenceSampler { if baseRate < 0 { baseRate = 0 } if baseRate > 1 { baseRate = 1 } if sloThreshold <= 0 { sloThreshold = 200 * time.Millisecond } return &InferenceSampler{ baseSampleRate: baseRate, sloThreshold: sloThreshold, nowFunc: time.Now, rng: rand.New(rand.NewSource(time.Now().UnixNano())), } } // ShouldSample 实现 trace.Sampler 接口 // 这是 OpenTelemetry SDK 调用的核心入口 func (s *InferenceSampler) ShouldSample(params trace.SamplingParameters) trace.SamplingResult { atomic.AddInt64(&s.totalRequests, 1) // 1. 强制采样:父 Span 已采样 → 保持 Trace 完整性 if params.ParentContext.IsSampled() { atomic.AddInt64(&s.forcedSamples, 1) atomic.AddInt64(&s.sampledRequests, 1) return trace.SamplingResult{ Decision: trace.RecordAndSample, Attributes: s.standardAttributes(params), } } // 2. 强制采样:从 Span 属性中提取状态码和延迟 for _, attr := range params.Attributes { // 检查 HTTP 状态码 if attr.Key == "http.status_code" { statusCode := attr.Value.AsInt64() if statusCode >= 400 { atomic.AddInt64(&s.forcedSamples, 1) atomic.AddInt64(&s.sampledRequests, 1) return trace.SamplingResult{ Decision: trace.RecordAndSample, Attributes: s.errorAttributes(params), } } } // 检查推理延迟(由中间件在 Span 开始前设置) if attr.Key == "inference.latency_ms" { latencyMs := attr.Value.AsFloat64() if time.Duration(latencyMs)*time.Millisecond > s.sloThreshold { atomic.AddInt64(&s.forcedSamples, 1) atomic.AddInt64(&s.sampledRequests, 1) return trace.SamplingResult{ Decision: trace.RecordAndSample, Attributes: s.slowRequestAttributes(params), } } } } // 3. 强制采样:Header 标记 for _, attr := range params.Attributes { if attr.Key == "trace.force" && attr.Value.AsBool() { atomic.AddInt64(&s.forcedSamples, 1) atomic.AddInt64(&s.sampledRequests, 1) return trace.SamplingResult{ Decision: trace.RecordAndSample, Attributes: s.standardAttributes(params), } } } // 4. 概率采样 s.rngMu.Lock() sample := s.rng.Float64() < s.baseSampleRate s.rngMu.Unlock() if sample { atomic.AddInt64(&s.sampledRequests, 1) return trace.SamplingResult{ Decision: trace.RecordAndSample, Attributes: s.lightweightAttributes(params), } } // 5. 不采样 return trace.SamplingResult{ Decision: trace.Drop, } } // GetDescription 返回采样器描述 func (s *InferenceSampler) GetDescription() string { return "InferenceSampler: error/slow forced, base rate probabilistic" } // standardAttributes 标准采样属性(错误/慢请求等强采样场景) func (s *InferenceSampler) standardAttributes(params trace.SamplingParameters) []attribute.KeyValue { return []attribute.KeyValue{ attribute.String("sampler.name", "inference"), attribute.String("sampler.reason", "forced"), } } // errorAttributes 错误请求的采样属性(附加状态码信息) func (s *InferenceSampler) errorAttributes(params trace.SamplingParameters) []attribute.KeyValue { attrs := s.standardAttributes(params) attrs = append(attrs, attribute.String("sampler.trigger", "error")) return attrs } // slowRequestAttributes 慢请求的采样属性(附加延迟信息) func (s *InferenceSampler) slowRequestAttributes(params trace.SamplingParameters) []attribute.KeyValue { attrs := s.standardAttributes(params) attrs = append(attrs, attribute.String("sampler.trigger", "slow_request")) return attrs } // lightweightAttributes 概率采样的轻量属性 // 不携带 sampler.trigger 信息,降低存储成本 func (s *InferenceSampler) lightweightAttributes(params trace.SamplingParameters) []attribute.KeyValue { return []attribute.KeyValue{ attribute.String("sampler.name", "inference"), attribute.String("sampler.reason", "probabilistic"), attribute.Float64("sampler.rate", s.baseSampleRate), } } // Stats 返回采样统计信息 func (s *InferenceSampler) Stats() SamplerStats { return SamplerStats{ TotalRequests: atomic.LoadInt64(&s.totalRequests), SampledRequests: atomic.LoadInt64(&s.sampledRequests), ForcedSamples: atomic.LoadInt64(&s.forcedSamples), EffectiveRate: float64(atomic.LoadInt64(&s.sampledRequests)) / float64(atomic.LoadInt64(&s.totalRequests)), } } // SamplerStats 采样统计快照 type SamplerStats struct { TotalRequests int64 SampledRequests int64 ForcedSamples int64 EffectiveRate float64 }

服务初始化时配置 TracerProvider:

package main import ( "context" "time" "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.26.0" ) func initTracer(ctx context.Context, serviceName string) (*sdktrace.TracerProvider, error) { // OTLP gRPC 导出器 exporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithEndpoint("otel-collector:4317"), otlptracegrpc.WithInsecure(), ) if err != nil { return nil, err } // 创建智能采样器 sampler := NewInferenceSampler( 0.05, // 5% 基础采样率 200*time.Millisecond, // 超过 200ms 强制采样 ) // 资源标识 res, err := resource.New(ctx, resource.WithAttributes( semconv.ServiceName(serviceName), semconv.ServiceVersion("1.0.0"), semconv.DeploymentEnvironment("production"), ), ) if err != nil { return nil, err } // 构建 TracerProvider tp := sdktrace.NewTracerProvider( sdktrace.WithSampler(sampler), sdktrace.WithBatcher(exporter, sdktrace.WithMaxExportBatchSize(512), // 批量导出,降低网络开销 sdktrace.WithBatchTimeout(5*time.Second), ), sdktrace.WithResource(res), ) otel.SetTracerProvider(tp) return tp, nil }

四、智能采样的边界与局限

样本偏差:概率采样天然存在选择偏差——你永远不知道没被采样的那 95% 请求里发生了什么。如果某个奇怪 Bug 只在不采样的请求中出现,那 Trace 里就完全没有记录。这是概率采样的本质代价。应对:保留 Metrics 监控所有请求(延迟分布、错误率),Metrics 没有采样偏差。Trace 只在告警触发时用来看细节,不是替代 Metrics。

强制采样的滞后性:延迟驱动的强制采样有个悖论——你必须在 Span 结束之后才知道延迟,但采样决策是在 Span 创建时做的。上述实现中,我们把推理延迟作为 Span 属性在创建时预估(通过上一批次的历史 P99),但这有滞后。更好的做法是使用尾部采样(Tail Sampling)——在 OpenTelemetry Collector 层面做采样决策,Collector 持有完整 Span 数据后再判断是否需要保留。代价是 Collector 需要缓冲所有 Span 一段时间,内存压力大。

基础采样率的自适应:生产流量是波动的。白天 QPS 5000 时 5% 采样率产出 250 条 Trace/秒,Jaeger 能承受。但如果"双十一"流量翻 10 倍到 QPS 50000,5% 采样就是 2500 条 Trace/秒——存储又爆了。自适应的做法是根据流量动态调整采样率,保证 Trace 产出量恒定。比如目标产出不超过 200 条/秒,当前 QPS 是 10000,采样率自动调整为 200/10000 = 2%。

Span 属性筛选:全量属性(模型参数、输入 token、GPU 温度)只应该在强制采样的 Trace 中保留。概率采样的 Trace 只保留 service name + operation name + duration,存储成本可以降低 5-10 倍。但要注意:如果在故障排查时发现 Trace 里有 Span 但属性不全,不要惊讶——这是设计行为,不是 Bug。

五、总结

AI 推理服务的 Tracing 必须从全量采样切换到智能采样:

  1. 基础采样率设 1%-5%:正常情况下 95% 的 Trace 没有排障价值。概率采样把 Tracing 开销从 40% 延迟增长降到 5% 以下。
  2. 异常请求 100% 采样:错误、超时、长尾延迟的请求全部保留 Trace。正常请求概率采样 + 异常请求全量 = 排障覆盖率 100%。
  3. Span 属性按优先级分层:强制采样的 Trace 保留全量属性,概率采样的只保留核心信息,存储成本节约 5-10 倍。
  4. Metrics + Traces 互补:Metrics 做全量监控无采样偏差,Traces 做异常请求的根因定位。各司其职。

Tracing 不是越多越好,而是要精确捕获"值得排查的请求"。全量 Tracing 只是在用 40% 的性能换一堆没人看的 Trace。