Skip to content
Published on

原生直方图已经 stable 了,为什么还不能打开

分享
Authors

引言 — "stable" 这个词实际改变了什么

Prometheus 原生直方图是一个等待已久的特性。官方规范文档的第一句话就概括了它的历史 — 2022年11月作为实验特性引入,第一个支持它的服务端版本是 v2.40.0,当时需要用 --enable-feature=native-histograms 标志来开启。同一段落里还有这样一句话。

Starting with v3.8.0, native histograms are supported as a stable feature.

3.8.0 于2025年12月2日发布(按 GitHub release 计,CHANGELOG 的标题日期写的是11月28日)。一个背了三年多「实验性」标签的特性,终于变成 stable 了。

不过在本文写作的这个时间点,有一件更要紧的事。3.13 已于2026年7月1日发布,它是新的 LTS。 而现在很多组织可能正在跑的 3.5 LTS,将于2026年7月31日停止支持。 也就是两周之后。把发布周期文档里的 LTS 表原样搬过来是这样的。

Release          Date          End of support   Status
Prometheus 2.53  2024-06-16    2025-07-31       End of life
Prometheus 3.5   2025-07-14    2026-07-31       Supported
Prometheus 3.13  2026-07-01    2027-07-31       Supported
TBD              2027-06       2028-07-31       Upcoming

把这两件事叠在一起,能得出一个结论。3.5 LTS(2025年7月)是在原生直方图变为 stable 之前四个月就被切出去的版本。而 LTS 文档里明确写出了不在支持范围内的项目 — 在 API 稳定性保证中被归为 unstable 的东西、实验性特性,以及 OpenBSD 支持。也就是说,如果你走的是 3.5 LTS,原生直方图从一开始就是 LTS 支持范围之外的实验特性,而 3.13 是第一个以 stable 状态收录这个特性的 LTS。这正是为什么对走 LTS 路线的组织来说,现在不是「以后再看」,而是真正需要做决定的时刻。

经典直方图会制造出多少个时间序列

先说清楚为什么会想要这个特性。问题出在基数(cardinality)上。

经典直方图在暴露格式上看起来像一个直方图,但在被抓取的那一刻,就会被拆成好几个 float 类型的时间序列。规范文档是这样描述这个分解过程的 — summary 与(经典版的)histogram 在采集时会被分解为 float 组成部分,两种类型都带有 sum 和 count 这两个组成部分,而 histogram 还会额外附带多个 bucket 样本。

实际情况如何,规范文档给出的例子说得很准。一个叫 rpc_latency_seconds 的直方图,按经典方式采集后,会变成 rpc_latency_seconds_sumrpc_latency_seconds_count,以及若干个带有不同 le 标签的 rpc_latency_seconds_bucket 时间序列。如果有 N 个 bucket,就是 N+2 个时间序列。而按原生直方图采集,就正如其名,只有一个 rpc_latency_seconds 时间序列。

一旦按标签做分区(partitioning),这个差距就会被放大。用规范文档的说法,给一个原本就很重的经典直方图加上标签、变成 100 个直方图,成本就会涨到 100 倍。原生直方图的情况不同 — 分区之后,各个直方图往往各自只填充较少的 bucket(比如只装状态码 404 请求的直方图,其分布通常很窄),所以填充的 bucket 总数的增长速度,会比直方图数量的倍数慢得多。规范文档写下「即便按标签分区,现在也变得远为可承受」,依据正是这一点。

这里有个想编一个具体数字的冲动,但我不会这么做。Prometheus 规范文档和 Grafana Mimir 文档都描述说原生直方图「相比经典直方图,以远低得多的资源成本提供了更高的分辨率」,但这是厂商/项目文档的定性描述,并不是带明确条件的基准测试数字。节省幅度完全取决于你原本用了多少个 bucket、分布有多宽 — 如果一个直方图只有 5 个 bucket,那基本没什么可省的。

Schema — 为什么 bucket 边界是公式而不是配置

原生直方图的核心思路是:bucket 边界不由人来定,而由公式来定。决定这一点的值就是 schema,按规范文档的说法,它是一个 int8 整数,目前有效的取值是 -53,以及 -4 到 +8。-53 用于自定义 bucket 边界(NHCB),其余的都是标准的指数 schema。

在标准 schema n 下,索引为 i 的正数 bucket 的边界按如下方式计算(规范文档用 Python 语法给出)。

# 上界(含):  (2**2**-n)**i
# 下界(不含):  (2**2**-n)**(i-1)

这里会推出一个重要的性质。schema n 的分辨率是 schema n+1 的一半,因此一个 n+1 精度的直方图可以通过合并相邻 bucket 转换为 n。这正是标准 schema 之间可以相互合并的原因,也是跨多个实例对直方图求和这类查询能够成立的依据。经典直方图里两个服务用了不同 bucket 边界就没法合并的问题,在这里消失了。

在埋点(instrumentation)一侧,并不直接选择 schema,而是指定一个增长因子。Go 客户端的 NativeHistogramBucketFactor 就是这个东西,client_golang 源码中的注释给出了确切数值 — 在成本与精度之间一个通常不错的折中值是 1.1(每个 bucket 比前一个最多宽 10%),这个值换算下来,是每个 2 的幂次区间被分成 8 个 bucket(1 到 2 之间 8 个,2 到 4 之间 8 个,4 到 8 之间 8 个……)。实际的因子按 2^(2^-n) 计算,n 取 -4 到 8 之间的整数中,能给出不超过指定值的最大因子的那一个。也就是说可能取到的最小因子约为 1.00271(= 2^(2^-8))。

零附近要单独处理。指数 bucket 会无限逼近零,所以绝对值在某个阈值以下的观测值,全部会被归到「零 bucket」里。Go 客户端的默认阈值是 DefNativeHistogramZeroThreshold = 2.938735877055719e-39。

而存储采用的是稀疏(sparse)表示 — 空的 bucket 几乎是免费的。这正是「覆盖整个 float64 范围」这句话不会破产的原因。覆盖的范围再宽,也只有实际填充的 bucket 才产生成本。

stable 没有保证的那些事

从这里开始才是本文的正文。stable 声明的意思是,服务端处理这种数据类型的方式在 v4 之前不会被打破,而不是说「现在直接打开就行了」。规范文档自己也在 stable 声明之后紧跟着列出了限定条件。

依然需要显式开启。 抓取(scrape)必须通过 scrape_native_histograms 配置来启用,而 Remote-Write 传输则要另外用 send_native_histograms 开启。在 3.8 中,原有的 feature flag 只剩下把这项配置设为 true 的效果;从 3.9 起,这个标志变成了彻底的 no-op,因此必须显式设置配置项。规范文档里的一句括号注释透露了路线图 — 从 v4 起,这两项配置的默认值都会变成 true。也就是说,现在处在「stable 但需 opt-in」和 v4「默认开启」之间的过渡区间。

暴露方式目前只有 protobuf 一条路。 这是实践中最常绊倒人的一点。规范文档明确写道,经典的 Prometheus 文本格式没有为原生直方图做扩展,也没有这个计划。OpenMetrics 文本格式的支持有一份提案文档,但实现状态仍然是 Not implemented。OpenMetrics 那边关于 protobuf 支持的 PR(OpenMetrics#256)于2026年3月19日未合并就被关闭了,理由挺有意思 — 本质上是一次品牌层面的清理。决定是:既然 Prometheus 的 protobuf 暴露格式已经支持原生直方图和其余所有特性,那就把它定为正式的 protobuf 格式,而 OpenMetrics 则专指基于文本的格式。归纳一下,截至2026年7月,暴露原生直方图只有一条路 — 经典的 Prometheus protobuf 格式。

客户端库还没跟上。 这一点值得自己动手确认一下。client_golang 的最新发行版是 v1.23.2(2025年9月5日),而 HistogramOpts 的注释里,这句话原封不动还在。

NOTE: Native Histograms are still an experimental feature. Their behavior might still change without a major version bump. Subsequently, all NativeHistogram... options here might still change their behavior or name (or might completely disappear) without a major version bump.

可以说 v1.23.2 是在 3.8.0(2025年12月)之前发布的版本,这句话已经过时了 — 但实际上,main 分支里也还留着同一句话。要点在于,stable 声明的主语是服务端。服务端的数据模型和存储、查询行为是 stable 的,但埋点库的选项 API 在文档层面并没有给出同样的保证。

Python 这边更简单 — client_python 的原生直方图实现 issue至今仍是打开状态。文本格式提案文档给出的动机之一正是这个:像 Python 客户端这样的部分库,并不想仅仅为了暴露原生直方图就新增一个 protobuf 依赖。

Kubernetes 是 alpha 状态。 原生直方图进入 Kubernetes 组件指标是从 v1.36(2026年4月22日发布)开始的,但 NativeHistograms 这个 feature gate 的状态是 alpha,默认 false。适用对象是 kube-apiserver、kube-controller-manager、kube-scheduler、kubelet、kube-proxy,可以按组件分别开启。开启后是同时暴露经典和原生两种格式的双重暴露(dual exposition)方式,具体拿到哪一种由请求的 Accept 头决定。

protobuf 抓取成本 — 曾经拖住 stable 进度的问题

这里有一段有意思的历史。开启 scrape_native_histograms 会改变内容协商(content negotiation),让对应的抓取配置转而偏好 protobuf。正如规范文档所说,在未指定默认值的情况下,生效的优先级顺序会变成这样。

[ PrometheusProto, OpenMetricsText1.0.0, OpenMetricsText0.0.1, PrometheusText0.0.4 ]

问题在于,这会作用于该抓取配置下的所有目标。为了拿到少数几个原生直方图,就得把整个抓取都切到 protobuf。Issue #14668 正面处理了这个问题,正文里的诊断是这样的 — protobuf 解析会产生大量内存搅动(memory churn),大部分体现为 GC 的 CPU 占用上升,根据使用模式的不同,CPU 可能大幅增加,在实际案例中观察到过 2 倍的增长。同一条 issue 正文里还有一句话被加粗写出。

(Or in other words: This issue needs to get resolved to declare native histograms a stable feature.)

也就是说,这个性能问题曾是 stable 声明的前提条件。这条 issue 于2025年2月13日被标记为 completed 关闭,随后在2025年12月做出了 stable 声明。在关闭之前的讨论中,维护者报告说,在优化之后的现实基准测试里,速度比文本解析器快 3 倍,分配的内存少 10%。

这里有一点需要诚实地说清楚。规范文档里至今还留着一条 note,说用基于文本的格式抓取,通常会比 protobuf 明显更省资源,并把这条 issue 作为依据链接了进去 — 可这条 issue 已经被标记为 completed 关闭了。文档里的这条 note,看起来反映的是优化之前的旧状态,已经过时。所以,无论是说「protobuf 抓取依然贵 2 倍」,还是说「这已经彻底解决了」,就目前的资料来看都属于言过其实。唯一的办法是在你自己的环境里实测 CPU。 在开启前后,比较一下被抓取进程和 Prometheus 自身的 CPU 占用。

不过这条 issue 也留下了一份实用的遗产 — 一些收窄暴露范围的手段。直接指定 scrape_protocols,即便 scrape_native_histograms 已开启,也可以强制特定目标使用非 protobuf 格式。而 3.13 新增了可以通过 relabeling 按目标做覆盖的内部标签 — __scrape_native_histograms____always_scrape_classic_histograms__(#18929),以及 __convert_classic_histograms_to_nhcb__(#18840)。也就是说,不用拆分抓取配置,也能精确地只对暴露原生直方图的目标开启这个特性。

NHCB — 一条不动埋点代码的路径

把所有经典直方图重新埋点为原生直方图,工作量很大。所以存在一条中间路径。schema -53,也就是带自定义 bucket 边界的原生直方图(NHCB)。

开启 convert_classic_histograms_to_nhcb 这个抓取选项后,Prometheus 会在采集时把经典直方图转换成 NHCB 再写入。埋点代码原封不动,却能拿到存储效率上的好处。用规范文档的说法,NHCB 的核心优势在于存储成本通常低得多,尤其是新增一个 bucket 的边际成本很低,所以即便是 bucket 很多的经典直方图,也能以可承受的成本被采集。

代价也很明确。NHCB 与其他直方图之间的合并能力从根本上是受限的。规范文档这样警告 — 不同 bucket 布局之间的自动协调,只会保留边界精确匹配的部分,所以如果大部分边界能对上,结果还算可用,但如果 bucket 布局任意不同,就可能得到没有任何边界能对上、只剩一个溢出 bucket 的直方图。因此规范文档建议,schema -53 只应该在特定用例中作为一个经过深思熟虑的决定来使用。如果在一个各服务 bucket 边界各不相同的代码库里,把所有直方图都硬塞进 NHCB 再跑聚合查询,你会悄无声息地得到一堆没用的结果。

诚实的权衡 — 为什么迁移很难

规范文档的迁移章节把问题归纳成三点。这就是这个特性落地的真实难度所在。

第一,查询方式不同。 规范文档自己的说法是,大多数情况下改动很小、也很直观,但存在一些棘手的边界情形,使得可靠的自动转换很困难。也就是说,仪表盘和告警规则得靠手动搬迁。

第二,经典和原生彼此不能聚合。 这是最痛的一点。如果在某个时间点从经典切到原生,就很难做出一个横跨这个切换时间点的仪表盘,任何跨越切换点的 range vector 都必然是不完整的 — 选经典就只有前半段数据,选原生就只有后半段数据。规范文档给出的例子很具体。

histogram_quantile(0.9, rate(rpc_duration_seconds[1d]))

这个计算一天内 90 分位延迟的查询,如果原生直方图的采集时间还没满一天,覆盖的就只是比一天更短的一段。所以规范文档的建议是保守的 — 让经典和原生并行采集一段时间。如果并行了一个月,那么回看窗口不超过一个月的仪表盘,就可以不用纠结切换时间点,直接迁移过去。并行采集通过 always_scrape_classic_histograms 选项开启。经典直方图会以带后缀的一组时间序列存在,原生直方图则以名称本身对应的单一时间序列存在,所以在 TSDB 里不会冲突。当然,这段时间里两边的成本都要付。

第三,无法设置任意的 bucket 边界。 这一点对做 SLO 的团队可能是决定性的。经典直方图可以量身定制,把边界精确设在你关心的那个点上 — 比如「300ms 以内的响应比例」是一项 SLO,把边界钉在 300ms,这个比例就是精确值而不是估计值。标准 schema 下的原生直方图分辨率高,但无法把边界放在任意数值上。Grafana Mimir 的文档也把同一点列为缺点 — 没有办法设置任意的 bucket 边界,比如对 SLO 定义特别重要的那些阈值,给定阈值上下的观测比例,通常只能用插值(interpolation)来估计。规范文档对这类情况坦率地写道,「原生直方图带来的用户体验实际上可能会变差」,并给出的解法是:那个直方图干脆就不迁移,继续留着。

除此之外还有一点。因为 bucket 是在第一次被填充时动态创建的,如果实际分布比预期更宽,产生的 bucket 就会比预期更多,占用的内存也更多。规范文档甚至警告说,如果观测值可以被外部操纵,这会变成一个通过耗尽内存发起的 DoS 攻击向量。然而 Go 客户端的 NativeHistogramMaxBucketNumber 默认值是 0,也就是不限制(规范文档自己也把这一点留作 TODO)。一个常用的取值是 160,这也是 OTel 指数直方图的默认值。在采集端也可以做防御 — 抓取配置里的 native_histogram_bucket_limit(单个直方图的 bucket 数量上限)和 native_histogram_min_bucket_factor(增长因子下限)就是干这个的,两者一旦超限,都会降低分辨率来适配。不过如果是 NHCB 超过了 bucket 数量上限,就无法降低分辨率,抓取会直接失败。

那么,你到底该不该开启

现在就值得开启的情况

  • 正在使用 bucket 很多(几十个)的经典直方图,并且因为按标签分区,基数问题已经很痛了。因为节省幅度和原本的 bucket 数量成正比,这里是收益最大的地方。
  • 像延迟分布这类事先很难知道范围的观测值。不用再去猜 bucket 边界该放在哪,这本身就是实质性的收益。
  • 用 Go 做埋点,并且计划把服务端升级到 3.13 LTS 的地方。在目前的技术栈上,这条路走起来最顺。
  • 不能动埋点代码,但想省存储成本的地方 — 这种情况下可以只开启 NHCB 转换。但要先理解清楚合并方面的限制再用。

暂时还不行的情况

  • 依赖精确对齐 SLO 阈值的 bucket 边界的直方图。一旦改成插值估计,SLO 计算的性质就变了。这类就老老实实留在经典直方图里。
  • 埋点用的是 Python client 的地方。目前还没实现,没得选。
  • bucket 数量本来就没几个的直方图。能省的不多,迁移成本却一分不少。
  • 大部分抓取目标都不暴露原生直方图,但抓取配置是按整体切换到 protobuf 的这种架构。用 3.13 的按目标 relabeling 收窄范围,或者用 scrape_protocols 来控制。
  • 目的是 Kubernetes 组件指标的地方。1.36 alpha 里默认关闭,因此在生产集群里开启一个 alpha 阶段的 feature gate,需要另外单独做决定。

结语

归纳一下。原生直方图确实变成 stable 了(3.8.0,2025年12月),3.13 是第一个以 stable 状态收录这个特性的 LTS(2026年7月1日),而 3.5 LTS 将在7月31日终止支持。数据模型确实很出色 — N+2 个时间序列变成了 1 个,不用再猜 bucket 边界,标准 schema 之间还能相互合并。

不过必须精确读懂「stable」这个词的主语。它的意思是服务端处理这种类型的方式在 v4 之前不会被打破,而不是说整个生态已经准备好了。暴露方式目前仍然只有 protobuf 一条路,Go 客户端仍在文档里把自己的选项标注为实验性,Python 尚未实现,Kubernetes 处于 alpha。v4 会把默认值打开这个方向是明确的,但现在跨越这段过渡期的人,得自己去核实这些空缺。

所以实务上的顺序是这样的。升级到 3.13 这件事,反正因为7月31日的截止日期也得做,所以先把它做掉,把原生直方图当成一个独立的决定来对待。然后只挑出基数问题最痛的那几个直方图,从并行采集开始,亲自实测 protobuf 切换带来的 CPU 影响,把并行期设得足够长,好留出时间把仪表盘迁移过去。这是一个花了三年才变成 stable 的特性,没有理由非得在两周内全部迁完。

参考资料