采样降本省了钱,也顺手把最有价值的那部分数据一起丢掉了。
先聊一个很常见的场景。某个系统的 Trace 全量上报,账单一个月涨到六位数。于是把 SDK 的采样率从 100% 调到 5%,成本立刻下来了,团队松了口气。三个月后一次 P1 故障,需要拉一条超时链路的完整调用栈,翻遍 trace 存储也找不到。出问题的那次请求,正好落在被丢弃的 95% 里。
问题的根因不在采样率高低,而在采样决策发生的时机。OpenTelemetry 官方文档把这件事讲得很清楚:采样是降低可观测性成本最有效的手段之一,但采样方式选错,会让你在最需要数据的时候恰好没有数据。
一、先分清两种采样,再谈降本
| 维度 |
头部采样 Head Sampling |
尾部采样 Tail Sampling |
| 决策时机 |
trace 开始时 |
trace 内 span 收齐后 |
| 决策依据 |
trace ID + 采样百分比 |
完整 trace 内容(状态码、耗时、属性) |
| 能否保证错误链路留存 |
不能 |
能 |
| 实现成本 |
低,SDK 环境变量即可 |
高,需要 Collector processor |
| 状态 |
无状态 |
有状态,span 驻留内存 |
| 横向扩展 |
直接扩 |
需要额外一层路由 |
头部采样最常见的实现是确定性概率采样:用 trace ID 加目标百分比做决策。好处是整条 trace 要么全留要么全丢,不会出现半条 trace 落进存储;配置也简单:
export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.05
它的硬伤在于决策发生在你能看到任何数据之前。在 trace 刚开始的那一刻,没有人知道这次请求最后会不会报错、会不会超时。
尾部采样反过来做:先把 span 攒在内存里,等 trace 走完再判断。于是这种策略组合才成立:
- trace 内出现 error,100% 保留
- 耗时超过阈值的,100% 保留
- 剩下的健康流量,只留 5%
对于体量足够大的系统,这基本是唯一能同时兼顾「成本可控」和「故障可查」的做法。官方文档的措辞更直接:对于必须采样的较大系统,几乎总是需要尾部采样,才能在数据量和数据价值之间取得平衡。
什么时候根本不需要采样?官方给的判据也很实在:每秒 trace 数只有几十条或更低、数据只用于聚合统计可以预先聚合、或者受监管约束不允许丢数据且无法把未采样数据路由到低成本存储。满足任一条,就别折腾采样了,把资源直接花在存储上更划算。
二、尾部采样的三笔隐藏成本
尾部采样并非没有成本。如果只盯着省下的存储费,落地时很容易踩坑。官方文档列出三个主要缺点,值得逐条对照自己的环境。
内存驻留。 Collector 需要把 span 留在内存里等决策,内存占用大致由 decision_wait × 每秒新 trace 数 决定。处理器用环形缓冲区兜底:num_traces 限制内存中同时保留的 trace 数量,超出后最旧的 trace 会被移除,指标 sampling_trace_dropped_too_early 会上涨。这个指标一旦出现,意味着你可能在还没做决策的时候就把 trace 丢了。
有状态。 同一 trace 的所有 span 必须落到同一个 Collector 实例。这一个要求,直接决定了单机纵向扩容救不了你,也决定了尾部采样和「多副本 Collector」天然冲突。
工程成本。 采样规则会随系统变化而变化。业务加了新服务、新接口,原来的判断条件可能就不再覆盖关键路径。官方把它列为三大缺点之一,意思是不该指望「配好一次就不用管」。
三、水平扩展为什么直接失效
这一点在 Grafana Labs 的工程博客里说得最清楚。Collector 横向扩容后,同一条 trace 的 span 可能散落到不同实例上,各实例之间无法通信,于是每个实例只能拿着自己看到的那部分 span 做采样决策。结果是:一条明明包含错误、应该被完整保留的 trace,最后只有零散几个 span 被写进后端。数据的价值被悄悄削掉了一半,而账单上完全看不出来。
他们的解决办法是在流水线里加一层聚合,让同一 traceID 的 span 全部转发到同一个实例,再交给尾部采样处理器决策。关键技术点是两层哈希:
- 先对 traceID 做 FNV 哈希。这一步是为了防御客户端埋点的问题,当 traceID 生成没有用满 128 位键空间时,直接哈希容易产生倾斜。
- 再做 jumphash 决定归属哪个 peer。
为什么第二步不用简单的取模?因为取模会在 Collector 实例数量变化时重新划分整个键空间,大量 span 会在扩缩容瞬间变成「孤儿」。jumphash 的性质是:实例数为 N 时,最多只有 1/N 的 trace 需要重新分配。对经常弹性伸缩的集群,这个差别决定了扩容时会不会丢数据。
Grafana Labs 公开的部署规模是约 14k spans/秒、平均每条约 60 个 span。他们额外提到一个副作用上的好处:由于采样后写入后端的 trace 数量几乎恒定,即使上游流量出现尖峰,存储后端也不会被压垮。
需要说明的是,Grafana 当时靠自研的 aggregate processor 实现这一点,今天的通行做法是在社区版里直接用负载均衡导出器把 span 按 traceID 分片,再交给第二层 Collector 做尾部采样。目的完全一样,只是选择了不同的实现路径。
四、两层部署的完整配置
先看第一层,网关 Collector,只负责按 traceID 把 span 路由到正确的后端,不做采样决策:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
exporters:
load_balancing:
routing_key: traceID # traces 的默认值,显式写出更清晰
protocol:
otlp:
timeout: 1s
tls:
insecure: true
resolver:
k8s:
service: otel-sampler.observability
ports:
- 4317
service:
pipelines:
traces:
receivers: [otlp]
exporters: [load_balancing]
几个容易写错的地方:
- 导出器名字是
load_balancing,旧的 loadbalancing 已弃用。
routing_key 可选 traceID、service、metric、resource、streamID、attributes。traces 不配时默认就是 traceID;但如果这个 Collector 同时用 spanmetrics 之类的组件往 Prometheus 推指标,用 traceID 路由会导致多个 Collector 之间 Prometheus label 碰撞,这时应改用 service。
resolver 四选一,同时配多个会直接报 errMultipleResolversProvided。dns 的 port 默认 4317、interval 默认 5s;k8s 的 timeout 默认 1m,且要求 Collector 的服务账号对 discovery.k8s.io/v1 的 EndpointSlice 有 get/list/watch 权限,否则解析不到后端。
protocol.otlp 只是模板,里面的 endpoint 不生效,会被解析出来的后端地址覆盖。
第二层才是真正的采样层:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors:
memory_limiter:
check_interval: 1s
limit_mib: 4096
spike_limit_mib: 1024
tail_sampling:
decision_wait: 10s
num_traces: 100000
expected_new_traces_per_sec: 5000
decision_cache:
sampled_cache_size: 100000
non_sampled_cache_size: 100000
policies:
- name: errors
type: status_code
status_code:
status_codes: [ERROR]
- name: slow
type: latency
latency:
threshold_ms: 1500
- name: baseline
type: probabilistic
probabilistic:
sampling_percentage: 5
exporters:
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, tail_sampling]
exporters: [otlp/tempo]
参数取值参考默认值和文档说明:
| 参数 |
默认值 |
说明与调整思路 |
decision_wait |
30s |
等 trace 多久后做决策。调小省内存,但长链路可能还没凑齐就被判完 |
num_traces |
50000 |
内存里同时保留的 trace 上限,撑不住就调大,代价是内存 |
expected_new_traces_per_sec |
0 |
按预期新增 trace 速率预分配数据结构,生产环境建议按实测值填 |
decision_cache.sampled_cache_size |
0(未激活) |
缓存「保留」决策;建议远大于 num_traces |
decision_cache.non_sampled_cache_size |
0(未激活) |
缓存「丢弃」决策,同上 |
maximum_trace_size_bytes |
未设置 |
单条 trace 字节上限,超出立即丢弃,用来兜底保护 |
num_shards |
1 |
并行事件循环数,最大 256,按 traceID 哈希分片;大于 1 时不支持 tail_storage |
策略字段本身很直白:status_code 用 status_codes 列表,latency 用 threshold_ms,可选 upper_threshold_ms 设上限(不设上限意味着所有超过阈值的 trace 都会保留),probabilistic 用 sampling_percentage。
官方还有一条部署建议值得直接照做:把负载均衡层和采样层拆成两个独立的 Collector 部署,而不是在一个实例里塞两条 pipeline。故障隔离更好,扩容时互不牵连。
五、把 egress 成本也在本地掐掉
上面这套解决的是存储成本。但如果你用的是托管后端,真正的大头往往是「传到云上的量」,也就是 egress 和处理费。数据一旦被厂商摄入,账单就已经产生了,此时再过滤已经晚了。
对应做法是两级采样:在本地环境里先做一层概率采样,把大部分健康流量拦在出网之前;云端再做一次尾部采样做最终取舍。Grafana Cloud 的文档把这个结构写得很清楚:本地那一层(Alloy 或 OTel Collector)只控制有多少数据离开你的环境,不决定单条 trace 的取舍;最终保留哪些,仍然由云端的尾部采样策略决定。
本地这一层的配置很短:
processors:
probabilistic_sampler:
sampling_percentage: 25
service:
pipelines:
traces:
receivers: [otlp]
processors: [probabilistic_sampler, batch]
exporters: [otlp]
这里有一个曾经踩过的坑:如果直接在本地采样,后面的尾部采样和「由 trace 生成指标」这两条管线会看不到完整流量,无法知道到底被移除了多少,指标外推会失真。OpenTelemetry 的 trace state 解决了这个问题:本地采样时把「保留概率」这个采样元数据随 span 一起带下去,下游的尾部采样读取它,就能基于同一基线计算,指标也能准确外推。
更准确的心智模型是:两层采样率都相对 100% 原始流量这条基线计算。你在策略里写 5%,指的就是原始流量的 5%,而不是「到达云端流量的 5%」。这样本地采样率可以跟着 egress 预算独立调整,不必同时改动每一条云端策略。
六、上线前后该盯什么
尾部采样处理器本身有状态,一旦它跟不上流量,表现是「数据悄悄变少」,而不是报错。所以要主动监控这几个点:
sampling_trace_dropped_too_early:因为缓冲区满、还没决策就被丢掉的 trace 数。这个值持续大于 0,说明 num_traces 或 decision_wait 需要重新评估。
- 被采样 trace 的数量与占比:正常运行时这条曲线应该基本平稳,即使上游流量出现尖峰。曲线跟着流量一起剧烈波动,说明策略或容量有问题。
- Collector 侧被丢弃或被拒的 span、导出器失败次数:官方在成本优化的常见错误里专门点出这一点,这类失败会造成隐性数据丢失,不看指标发现不了。
memory_limiter 有没有频繁触发:尾部采样天然吃内存,触发限流之后采样质量会下降。
上线顺序建议按小步走:先在预发环境按同样的流量特征压一轮,确认内存曲线和丢弃指标;再在生产环境选一到两个服务接入,观察一周采样比例是否稳定;最后全量切换。
收口
适用场景:每秒 trace 数在千级以上的系统;绝大多数请求健康且差异很小、只有少数错误或高延迟链路值得细看;有能力维护一套有状态的 Collector 集群。
不适用场景:数据量本身很小,每秒只有几十条 trace;数据只用于聚合统计,可以预先聚合;受合规约束不能丢弃数据,且无法把未采样数据路由到低成本存储。这些情况下,采样带来的收益不足以覆盖多出来的一套有状态组件。
下一步:先别急着上尾部采样。第一步是把 SDK 侧的头部采样率固定住,让数据量先降到可控范围;第二步是统计真实流量下的每秒新 trace 数、平均每 trace 的 span 数、长链路的时间分布,这几个数直接决定 num_traces 和 decision_wait 该填多少;第三步才是按第四节的配置搭两层架构,并且把 sampling_trace_dropped_too_early 加进监控面板。
可观测性调优没有标准答案,更多落地细节也值得在云栈社区继续沉淀和讨论。