自适应尾部采样在 OTel Collector 中如何工作

Honeycomb 把 Refinery 的自适应尾部采样算法捐给了 OpenTelemetry Collector,新的 processor 用 OTTL 规则和 W3C TraceState 阈值取代固定采样率。

中文
复制
抽象插画:一个 OpenTelemetry Collector 把彩色数据流送进 Sampling Processor

你产生的 trace 数据比愿意付费存储的要多,所以你做采样。固定 1/100 的采样率能砍掉账单,但它是盲目的:错误的 1%、那条冷门路由请求的 1%、健康检查的 1%,全都按同一比例留下。你最不关心的噪声流量占据了保留数据的大头,而出故障时你真正需要的那些 trace,恰恰最可能已经被丢掉了。我们最近发布了 adaptive_tail_sampling processor——Honeycomb 把 Refinery 和 dynsampler-go 背后的自适应采样算法贡献给了 OpenTelemetry Collector。这个 processor 由我实现,并和同事 Yingrong Zhao 一起在上游维护。发布公告里讲了做这件事的原因和试用方法。这篇文章讲决策机制、各种 sampler,以及它如何与采样流水线的其余部分组合。有一点要先说清楚:这不是把 Refinery 移植进 Collector,而是同一套算法族用 OpenTelemetry 原生方式重新表达。规则是启动时校验的 OTTL 表达式。采样决策按照 OpenTelemetry 一致概率采样规范编码为 W3C TraceState 中的阈值(ot=th),而不是厂商自定义字段。它能与 SDK 的 head sampler 以及其他 Collector sampler 组合。任何理解该规范的系统都能对采样后的数据重新加权,无论你把它发到哪个后端。

采样 trace 的三种方式

发布博客介绍了 Collector 目前支持的三种方式。它们的语义值得说清楚,因为正是这些差异催生了一个新的 processor。

概率(头部)采样在 trace 开始时做决定。它最便宜,完全不需要缓冲,但看不到自己丢掉了什么。错误和稀有流量与其他所有请求以相同的比例保留。对稀有事件来说,数字会变得很难看。假设每 1,000 个请求中有一次支付失败,你按 1% 做头部采样。每次失败的 trace 和其他所有 trace 一样以 1% 的概率存活,所以平均每 100,000 个请求才留下一条失败 trace。一个每天处理 50,000 个请求的服务大约每天遇到 50 次这种失败,却每两天才留下一次证据。采样器无法偏向失败,因为做决定时还没有任何东西失败。

尾部采样缓冲整条 trace,在掌握全部信息后再做决定,用的是 tail_sampling processor。它的策略按 OR 组合。决定只有保留/丢弃两种,不附带采样概率,因此保留下来的数据通常无法在下游重新加权,从采样数据算出的计数也不再反映真实流量。OR 组合还排除了自适应采样。一个不保留 trace 的策略返回的是“无决定”而不是“丢弃”,于是 trace 会落到列表中的下一个策略,后面的任何策略仍可保留它。在这种语义下,自适应采样器无法控制它本应施加的概率。

自适应尾部采样,也就是这个 processor,把基于尾部的缓冲与始终知道自己所施加概率的自适应采样器结合起来。规则按首个匹配生效,而不是按 OR 组合。每个决定都带有以 ot=th 编码的显式采样阈值。你无需手工调参就能保留有意思的尾部,采样后的计数在统计上也依然可信。

工作原理

span 在内存中缓冲,按 trace ID 分组。一条 trace 在以下任一情况最先发生时进入可决策状态:收到 root span、trace_timeout 到期(默认 30s),或累计到 span_limit 个 span(默认 10,000)。span 上限约束了单条超大 trace 能占用多少内存,并会立即做出决定,因为继续等待只会让 trace 不断增长。另外两种触发条件下,processor 会等待 decision_delay(默认 2s)以收集迟到的 span,然后再评估。什么算 root span 本身可以用 OTTL 表达式配置(root_span_condition),当跨进程 trace 只有服务端到达你的 Collector,或者生产者把消息消费者 span 标记为实际 root 时,这一点很重要。

规则按顺序评估,首个匹配者生效。每条规则指定一个采样器,没有条件的规则充当兜底。一个典型配置会保留所有错误,其余交给自适应采样器处理:

processors:
 adaptive_tail_sampling:
 rules:
 - name: keep-errors
 conditions:
 - span.status.code == STATUS_CODE_ERROR
 sampler:
 type: always_sample

 - name: default
 sampler:
 type: adaptive_percentage
 goal_percentage: 10
 fingerprint_attributes:
 - resource.attributes["service.name"]
 - span.attributes["http.route"]

先说明一下术语,因为 OpenTelemetry 和 Honeycomb 对同一个决策的描述方式不同。用 OpenTelemetry 的说法,每条 trace 都带有一个 56 位随机数,要么显式写在 TraceState 里作为 ot=rv,要么取自 trace ID 的最低 7 个字节,取值范围是 0 到 2^56 - 1。采样器在同一范围内选出一个采样阈值,凡是随机数大于等于该阈值的 trace 都保留。把中点以上的全部保留,就保留了 50%。阈值会随被保留的 span 一起传递,写在 ot=th 里,因此后续任何环节都能还原出这条 trace 存活下来的概率。Honeycomb 把同一个决策表达为采样率:N 条里保留 1 条。dynsampler-go 的各算法产出的就是这种形式的采样率。processor 在两者之间做转换,把每个采样率换算成对应的阈值。

被采样的 trace 在转发时,TraceState 中带有阈值,span 属性中则标明命中的规则(otelcol.processor.adaptive_tail_sampling.rule)和触发该决策的事件(otelcol.processor.adaptive_tail_sampling.trigger),因此在 dashboard 上能清楚看到每条 trace 是被哪条规则留下的。决策缓存会记住最近的结果,所以迟到的 span 会与其所属 trace 的其余部分保持一致——要么同样被标记,要么同样被丢弃。

processor 在压力下同样诚实。缓冲区写满时(num_traces,默认 50k),最旧的 trace 会被驱逐,并且给出一个真实的采样决策,而不是简单丢弃。默认的驱逐策略会对目前看到的 span 运行你的规则,所以 keep-errors 在高压下依然有效。如果驱逐意味着真正的过载,可以改用常数时间的概率策略。关闭时会把缓冲区排空而不是丢弃,因此干净的重启不会丢数据。

选择采样器

自适应采样器按 fingerprint_attributes 对流量分组,也就是 resource.attributes["service.name"]、span.attributes["http.route"] 这类带作用域的 selector。应选择能对流量分类的属性,比如 route、method 或 status code,而不是能标识单个请求的属性——后者会让每条 trace 都有自己的指纹,自适应也就失效了。针对每个指纹,采样器计算出一个采样率。processor 把该采样率换算成阈值,再与 trace 的随机数比较。两种自适应类型覆盖了常见的目标:

  • adaptive_percentage 以 span 量的目标百分比(goal_percentage)为目标。它用指数移动平均跟踪每个 fingerprint 的流量,因此稀有 fingerprint 保持高采样率,而高频的则被激进采样,总量最终收敛到目标值。在我们的验证运行中,无论负载水平如何,它都稳定在 9.4–10.3%,目标是 10%。
  • adaptive_throughput 则改为向一个量预算靠拢(goal_throughput,单位为 spans/秒,按 collector 实例生效)。当下游预算是固定的,就用它。默认的 ema 算法用同样的移动平均来平滑流量,而 algorithm: windowed 在滑动窗口上重新计算,对流量变化反应更快,代价是对短时尖峰更敏感。

还有 always_sample 和 probabilistic(固定比例,相当于内联版的 probabilistic_sampler processor),用于不需要自适应的规则。一个重要细节是:sampler 只产生一个 rate。用这个 rate 与 trace 随机数比较阈值,是所有规则共用的决策机制,也正是它让决策可复现,并让每个被保留的 span 在下游得到正确的权重。

让后续 pipeline 可以信任的决策

由于决策是编码在 spec 里的阈值,而不是简单的保留/丢弃,这个 processor 能与上游和下游的采样阶段组合。如果某个 SDK head sampler 或 probabilistic_sampler processor 已经写入了 ot=th,则以更严格的那一级为准,最终存活的阈值始终反映端到端的有效概率。

我们用 probabilistic_sampler 在 equalizing 模式下以 50% 上游采样、加上本 processor 中 10% 的 adaptive_percentage 目标做了验证。原始流量大约有 10% 存活(不是上游 50% 中的 10%)。每个被保留的 span 都带着 10% 的阈值。如果下游换成 always-keep 规则,上游 50% 的阈值则原样保留。两个方向上,估算器重建出的原始发送量误差都在 1.5% 以内。

同样的性质在 trace 之外也有用。spanmetrics connector 会读取 ot=th,从采样数据中生成权重正确的 R.E.D 指标,因此即使大部分 span 被丢弃,请求速率和错误率依然准确。

一个合理的问题是,为什么这不做成 tail_sampling 里的一组新策略。简短的回答是,tail_sampling 的策略契约无法携带采样概率或阈值。它的 OR 组合模型也破坏了自适应采样器所依赖的核算。README 详细讲解了其中的机制。两个处理器各有各的用处,把它独立出来,tail_sampling 用户没有任何损失。

性能

整个开发过程中,我们对这个处理器做了基准测试和浸泡测试。Yingrong 和我对一个独立处理器做了完整的负载测试,部署环境是类生产的两层架构,采样层有两个副本,每个 7 vCPU、13GiB。在典型 span 大小下,一个副本用大约 0.3 个核稳定跑到每秒 10 万个 span 左右;CPU 随 span 负载大小成比例增长,基数测试则把吞吐推过了每秒 17 万个 span。

在缓冲区故意配得过小、持续 70 倍过载的情况下,淘汰机制把内存控制在有界范围内,进程从未因超出内存限制被杀掉。span_limit 上限证明了自己的价值:在超大 trace 上,吞吐不变,内存降低约 36%,代价是 CPU 增加约 34%。它的性能不错,在可比负载下与 Refinery 大致相当。我们会持续改进。

部署与当前限制

部署这个处理器之前,有几件事需要了解:

  • 决策是按实例做的, 所以一条 trace 的所有 span 必须到达同一个 Collector。用标准的两层 loadbalancing exporter 模式横向扩展,按 trace ID 路由。loadbalancing exporter 目前还不支持按 ot=rv 路由,但有一个已提交的 PR 要把它加为路由键。
  • 内存由 trace 数量决定((num_traces) and per-trace span count (span_limit),而不是字节数。它随 span 速率乘以缓冲窗口增长。
  • 规则在启动时固定, 改规则就得重启。关闭时会排空缓冲区,所以重启不会丢数据。
  • 目前只支持 trace。 规则条件评估的是 span context。

上手

这个处理器今天已在 Honeycomb OpenTelemetry Collector 发行版中以 beta 形式提供,早于上游的稳定性里程碑。发布公告里有 Kubernetes、Docker 和独立配置的分步设置说明。在上游,这个组件位于 collector-contrib 仓库,正在向 alpha 稳定性推进,达到之后就会进入 collector-contrib 发行版。

README 里有完整的配置参考、常见部署模式的示例,以及基于处理器自身指标构建仪表盘所需的遥测约定。欢迎在 #49311 上反馈问题和建议。

如果你一直在用固定采样率、忍受盲区,或者想要尾部采样却需要事后拿到准确的计数,这个处理器就是为你做的。我很想知道它在你的流量上表现如何。

为什么 Refinery 仍是最主流的采样工具

这个处理器把 Honeycomb 的采样算法带进了 OpenTelemetry Collector,但 Refinery 依然是最主流的采样工具。原因不在算法,而在运维。大规模采样是一套需要持续运营的系统,而不只是一个配置一下就完事的组件。Refinery 背后有多年的生产环境打磨。

  • 扩容不需要路由层。 Refinery 集群在节点之间共享 trace 归属,加节点就是加容量。Collector 处理器则需要两层拓扑,而且每个实例只能从自己看到的那部分流量里学习速率。
  • 规则热更新。 Refinery 重新加载采样规则无需重启,这在故障处理过程中最关键——你当下就需要收紧或放宽采样。
  • 过载保护。 压力缓解机制会检测饱和状态,并切换到确定性采样,直到集群恢复,因此它是可预期地降级,而不是直接崩掉。
  • 运维深度。 感知环境的多租户、丰富的运行时遥测,以及 Refinery as a Service 这种托管选项。

如果你想要的是原生集成在 OpenTelemetry Collector 流水线里的自适应采样,从这个处理器开始。如果你已经在跑 Refinery,README 里有一份 Refinery 采样器类型到处理器采样器类型的直接对照表。当采样变成关键基础设施,需要自己的一套扩容、租户隔离和故障响应方案时,Refinery 正是为此而建的。

来源: Honeycomb← 返回首页