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 exporter+tail_sampling processor的 Collector 集群:
- SDK → Gateway Collector(小集群):负责接收、批处理、轻量路由
- Gateway → Tail Collector(专用决策集群):拿到完整 trace 后做决策
- Tail Collector → Backend(Jaeger/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_code=ERROR) | 0.4% | 5% |
| 慢请求(>2s) | 1.2% | 12% |
| 关键路径(payment/login/refund) | 8% | 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 Boot)Span.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 做策略。比如灰度发布时,只保留canary=true的 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,缺后续 span,Jaeger 看到的是断链。
解决办法:扩容 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 |
| 慢请求(>2s)trace 覆盖率 | 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 万账单里爬出来的运维人