
tail-based sampling 实战OpenTelemetry Collector 关键请求保留 噪声降级上个月我拿到一张 OpenTelemetry 后端的账单差点从椅子上滑下来。“你们这边每天的 trace 量是 2.3 亿条。存到 Jaeger Loki 链路关联的成本一个月 28 万。”财务总监在旁边看着报表眼睛没眨但意思很明确要么降本要么关掉一部分可观测性。我看着这数字也很懵。我们业务量其实没涨那么多但 trace 量在悄悄涨——因为去年 6 月从 Jaeger 迁到 OpenTelemetry 时我把采样率从 10% 调到了 100%。当时想着反正现在存得下结果存得下不等于存得起。更扎心的是2.3 亿条 trace 里真正有人看的不到 1%。剩下 99% 全是GET /api/v1/products/123这种成功又没意义的请求。我花了两周改造 Collector 的采样链账单从 28 万/月压到 5.2 万/月故障定位的 trace 覆盖率反而从 10% 升到接近 100%。核心就一件事用 tail-based sampling 把信号和噪声分开。今天把整套方案讲清楚。head-based sampling 为什么会失效在讲 tail-based 之前先说清楚我们一开始错在哪。去年我们用的就是最经典的head-based sampling在 SDK 入口处直接决定采不采# otel-collector-config.yaml (老配置)processors:probabilistic_sampler:sampling_percentage:10service:pipelines:traces:processors:[probabilistic_sampler,batch]简单粗暴每个 span 在客户端就扔骰子10% 概率保留。问题一错过了的关键 trace 就再也找不到了。有一次线上支付超时 5 秒但用户只触发了一次。10% 采样下这条 trace 大概率被丢掉了。第二天客服找过来我已经没法复现——因为 trace 根本没存。问题二健康的请求被大量存储没价值的占用磁盘。我们 99% 的请求是 P99 50ms 的健康请求但每一条都被采样下来。一天的存储里99% 都是一切正常的故事。真正能用来排查问题的反而被采样率压成了残影。问题三成本随业务量线性增长。业务涨 1 倍trace 量涨 1 倍存储成本涨 1 倍。没办法做削峰——因为好坏都在入口处决定。tail-based sampling 怎么救场tail-based sampling 的核心思想先把 span 全部收下来做完决策再决定存不存。实现方式是部署一个loadbalancing exportertail_sampling processor的 Collector 集群SDK → Gateway Collector小集群负责接收、批处理、轻量路由Gateway → Tail Collector专用决策集群拿到完整 trace 后做决策Tail Collector → BackendJaeger/Tempo只存值得存的 trace整个链路里最关键的是tail_sampling processor 的 policy。它决定哪些 trace 留下、哪些丢掉。# tail-collector-config.yamlprocessors:tail_sampling:decision_wait:10s# 等待所有 span 到达num_traces:50000# 内存中维持的 trace 上下文expected_new_traces_per_sec:2000policies:# 策略1错误请求 100% 保留-name:errorstype:status_codestatus_code:status_codes:[ERROR]# 策略2慢请求 100% 保留P95 2s 或任意 span 5s-name:slow-tracestype:latencylatency:threshold_ms:2000# 策略3包含特定属性的请求 100% 保留如带 trace.flag.critical-name:critical-pathstype:string_attributestring_attribute:key:http.routevalues:[/api/v1/payment/*,/api/v1/refund/*,/api/v1/login/*]# 策略4随机采样作为兜底3%-name:baseline-sampletype:probabilisticprobabilistic:sampling_percentage:3# 兜底每条 trace 至少给个最上限-name:rate-limittype:rate_limitingrate_limiting:spans_per_second:800service:pipelines:traces:receivers:[otlp]processors:[tail_sampling,batch]exporters:[otlp/jaeger]这套 policy 的设计逻辑错误和慢请求全留——这是排查事故的核心证据100% 保留关键路径全留——支付、登录、退款这些业务命脉不能漏健康请求采 3%——既能保留统计样本又能压成本rate_limiting 兜底——防止突发流量把 Collector 内存打爆我把每条策略的命中率和存储量都做了一周的对比策略命中比例存储占比错误status_codeERROR0.4%5%慢请求2s1.2%12%关键路径payment/login/refund8%25%3% 基线89%58%算下来存储量从 100% 降到 18.6% 左右——这跟我们账单从 28 万降到 5.2 万的比例对得上剩下的差额来自 Trace 关联的日志和指标。关键改造一让 SDK 给关键请求贴标tail-based sampling 跑得起来的前提是所有 span 都要流到同一个决策点。但现实里OpenTelemetry SDK 嵌在 Java/Go/Python/Node 各种服务里部署在 K8s 多副本上。改造 1让 SDK 主动给关键路径打标。// Java 服务Spring BootSpan.current().setAttribute(http.route,request.getRequestURI());if(isPaymentRequest(request)){Span.current().setAttribute(trace.flag.critical,true);}Go 服务类似span:trace.SpanFromContext(ctx)span.SetAttributes(attribute.String(http.route,c.Request.URL.Path),attribute.Bool(trace.flag.critical,isCriticalPath),)这样 tail_sampling 的string_attributepolicy 就能基于http.route匹配关键路径让支付/退款/登录 100% 保留不用管其他服务有没有打标。改造 2给 K8s 自动注入环境信息。OpenTelemetry Operator 提供了自动注入 sidecar 的能力但默认配置里 k8s.* 属性不会自动加。我在 Collector 的 k8s_attributes processor 里补了一行processors:k8sattributes:extract:metadata:-k8s.pod.name-k8s.namespace.name-k8s.deployment.name-k8s.node.name这样 tail_sampling 也能基于 namespace / deployment 做策略。比如灰度发布时只保留canarytrue的 deployment 全部 trace。关键改造二loadbalancing exporter 分摊决策压力tail_sampling 是个有状态的 processor——它必须把同一 trace ID 的所有 span 攒齐才能决策。如果在 K8s 上起 5 个 Collector 副本同一个 trace 的 span 可能落在不同副本上决策就乱了。解决办法是用loadbalancing exporter做一致性哈希# gateway-collector-config.yamlexporters:loadbalancing:protocol:otlp:tls:insecure:trueresolver:dns:hostname:tail-collector.observability.svc.cluster.localrouting_key:traceID# 关键用 traceID 做路由service:pipelines:traces:receivers:[otlp]processors:[memory_limiter,batch]exporters:[loadbalancing]原理Gateway Collector 拿到 span 后按 traceID 哈希到固定的 Tail Collector 实例。这样同一 trace 的所有 span 一定在同一个决策节点tail_sampling 才能正常工作。部署结构大概是这样[SDK 8 副本] → [Gateway Collector 3 副本] → [Tail Collector 4 副本] → [Jaeger] ↑ loadbalancing ↑ ↑ tail_samplingGateway 不做决策只做收口和路由。Tail 才是有状态的副本数 3-5 足够多了反而增加一致性哈希的复杂度。关键改造三跟 head-based sampling 配合的兼容方案老项目里很多服务是去年写的 SDK已经在客户端做了 head-based sampling比如固定 10%。直接切到 tail-based 会导致这些服务的 trace 永远只有 10% 能进决策点——因为 SDK 入口已经丢掉了 90%。我做了两步兼容第一步把所有服务的 SDK 采样率暂时调到 100%。这一步需要逐个服务改 deployment 里的环境变量。我用 ConfigMap Reloader 做了批量推送# 各个服务的 deployment envenv:-name:OTEL_TRACES_SAMPLERvalue:always_on# 临时全采样-name:OTEL_TRACES_SAMPLER_ARGvalue:1第二步在 Collector 入口加一个兜底策略。怕有些服务忘改或者改错了在 Gateway Collector 加一道闸门processors:# 确保 trace 至少带 1 个 span 进 tail_samplinggroupbyattrs:keys:[traceID]# 强制要求 span 数 2 的 trace 才进入 tailfilter/health-traces-only:error_mode:ignoretraces:span:-attributes[span_count] ! nil这一步是防御性的——实际跑了一周后因为 100% 采样的 SDK 数据量太大Gateway 的 CPU 涨了 40%。我又改回 50% 客户端采样 tail_sampling 兜底效果差不多但成本低很多。踩坑记录这套东西不是一帆风顺几个值得说的坑。1. decision_wait 调太大内存爆炸。一开始我设decision_wait: 30s想等齐所有异步 span。结果一上生产Collector OOM 反复重启。改成 10s 后稳定。教训decision_wait 取决于你的服务调用链最长尾延迟。我们最长是异步 ES 写入5-8s 就够了。设 10s 留点 buffer。2. 异步 span 进不来 tail_sampling。我们有些服务用消息队列做异步处理trace context 通过 header 透传到消费者但消费者部署在独立 deployment 里。结果发现异步链路 50% 的 span 都没被 tail_sampling 决策——因为它们到达的时候traceID 对应的上下文已经被清理了。解决在 K8s 上用 OpenTelemetry Operator 给每个 namespace 自动注入OTEL_EXPORTER_OTLP_ENDPOINT环境变量指向集群内的 Gateway Collector不是外网 IP。这样异步服务也能把 span 发到 Gateway。3. 关键路径策略写错事故 trace 找不到。有次支付接口超时 8s告警群炸了但 Jaeger 里完全找不到这条 trace。一查配置发现我把/api/v1/payment/*写成了/api/v1/payments/*多了个 s。正则匹配不上policy 失效trace 进了基线 3% 采样池。教训policy 写完后必须用 tracegen 工具主动打几条样本验证# 主动打一条带特定属性的 tracetracegen -otlp-endpoint gateway:4317\-servicepayment\-attrshttp.route/api/v1/payment/refund\-duration3s然后在 Jaeger 里搜这个 traceID确认它被保留了。4. Tail Collector 实例数调整后老 trace 上下文失效。有一次扩容 Tail Collector 从 3 副本到 5 副本loadbalancing 的哈希环变了。结果当天有 12% 的 trace 出现半截——只有入口 span缺后续 spanJaeger 看到的是断链。解决办法扩容 Tail Collector 时同步滚动重启 Gateway让哈希环重新映射。我们后来用 Argo Rollouts 做这个联动# argo-rollouts 滚动策略strategy:canary:steps:-setWeight:25-pause:{duration:2m}-setWeight:50-pause:{duration:2m}-setWeight:100效果对比改造前后关键指标对比指标head-based 10%tail-based 多策略变化月度存储成本28 万5.2 万-81%错误请求 trace 覆盖率10%100%10x慢请求2strace 覆盖率10%100%10x健康请求 trace 覆盖率10%3%基线-7%可接受事故定位平均时间25 分钟4 分钟-84%Collector CPU 占用12%18%6%最让我满意的是事故定位时间。以前要找一条用户报障对应哪条 trace得在几亿条里翻现在直接根据订单号或者 user_id 搜100% 命中。on-call 同事说像换了一副眼镜。写在最后tail-based sampling 不是银弹它用决策延迟 计算资源换存储成本 信号质量。如果你业务量小、存储成本不是问题head-based 100% 采样反而简单直接。但如果你跟我一样每天 trace 量过亿存储成本开始被财务盯上关键请求需要 100% 保留用于合规审计那 tail-based 几乎是唯一解。最后给三个落地建议先用一个小服务试点把 policy 跑稳再铺开tracegen 工具要常备policy 改完必须验证扩容 Tail Collector 要谨慎配合 Gateway 滚动重启可观测性这件事不是采集越多越好而是在对的请求上保留完整的证据。花的钱花在刀刃上比什么都重要。—— 刚从 28 万账单里爬出来的运维人