ClickHouseInc

ClickHouse vs Prometheus: 高基数挑战,ClickHouse如何化解?(第1篇)

图片

本文字数:8727;估计阅读时间:22 分钟

作者:Rory Crispin and Dale McDiarmid

Image

您可能会经常听到我们说,当 ClickHouse 用于可观测性(Observability)工作负载时,高基数(High Cardinality)对其影响不大。尽管这一说法大致正确,但只有在您理解了高基数为何在传统时序(Time-series)系统中成为一个问题后,它才真正有意义。本文旨在为后续文章提供背景,该后续文章将探讨高基数在 ClickHouse 和其他列式(Column-oriented)数据库中行为不同的原因。我们将主要关注 Prometheus,因为它仍然是可观测性领域主流的指标存储(Metrics Store),并清晰地阐明了面向序列(Series-oriented)存储模型的权衡。我们将展示基数成本主要体现在何处:序列创建、内存使用、查询以及短期基础设施(Short-lived Infrastructure)导致的流失(Churn)。

如果您已经熟悉高基数、Prometheus 内部机制、序列流失以及基数在时序系统中引发的运维挑战,您可以直接跳到第二部分,我们将在此探讨 ClickHouse 如何以不同的方式处理这些工作负载。

Image
可观测性中的高基数是什么?

在可观测性系统中,高基数通常指代大量独特的标签组合。

我们将使用 Prometheus 作为参考模型,因为它仍然是可观测性领域主流的指标存储。此外,尽管存在替代方案,但它们通常基于相同的基本数据模型构建。

并非所有时序数据库都与 Prometheus 的实现方式完全相同,每个数据库都拥有其独特的内部架构和存储引擎。然而,许多以下述方式将数据建模为独立时序的系统,在基数增长时都会面临相似的挑战。

在时序数据库中,基数指的是唯一标签组合的数量。为了更好地理解这一点,我们需要退一步,先定义什么是指标、标签和时序。

为了定义什么是标签(Label),首先定义什么是指标(Metric)会很有帮助。一个指标实际上是一个可观测的数值属性,例如“HTTP 请求的数量”或“当前温度”。指标可以拥有被称为标签的额外维度。这些字符串值有效地揭示了指标的含义,每个标签的值都来自一个预定义的集合。

时序(Time Series)是指标的一个实例,它带有唯一的标签组合。 它包含一系列时间戳和值。

假设你有一个名为 http_response_time 的瞬时指标 (gauge metric),它表示最新观测到的响应时间,并带有 host、application、request_path 和 status 等标签。一个时间序列可能如下所示:

http_response_time {

  host="host-42",

  application="checkout-service",

  request_path="/api/payments",

  status="500"

}

这个时间序列可以包含一组时间戳和对应的值,例如:

(2026-02-24 10:00:00, 12)

(2026-02-24 10:01:00, 18)

(2026-02-24 10:02:00, 15)

...

本质上,标签 (label) 告诉我们正在观测什么,而时间戳和值则展示了它随时间的变化趋势。这些时间戳和值随后可以被渲染成图表上的一个时间序列。因此,一个指标 (metric) 可以包含一个或多个时间序列 (time series),具体数量取决于唯一标签值的数量。

Prometheus 通常从一个兼容 Prometheus 的 HTTP 端点抓取 (scrape) 数据,该端点在抓取时刻暴露每个时间序列的当前值 (一个样本,sample),且不包含明确的时间戳。Prometheus 会将抓取时刻作为其记录的 每个样本 的时间戳。简而言之,它会周期性地捕获所有暴露指标的快照,并将该抓取时间戳关联到在此期间返回的每个时间序列值上。

how_promethous_scrapes_metrics.png

因此,尽管单个时间序列看起来很简单,但从整体上看,它会引入复杂性。基数 (Cardinality) 不仅仅是指一个标签拥有许多唯一值,它指的是所有标签值的唯一组合总数,即唯一时间序列的数量。

例如,考虑上述 http_requests_total 指标——如果 host 的基数为 1000,application 的基数为 100,endpoint 的基数为 50,status code 的基数为 5,并且我们为每种唯一组合捕获一个指标,那么我们可能会得到以下数量的时间序列:

1,000 × 100 × 5 × 50 = 25,000,000 个唯一时间序列

这显然是一个最坏情况——它假设每个应用都会在所有主机上运行,且每个应用都存在对应的端点。而这尚未考虑引入额外的实际维度,例如地域 (region)、环境 (environment)、版本 (version) 或容器 ID (container ID)。

基数是指唯一时间序列的数量,而高基数 (high cardinality) 则是指时间序列数量非常庞大时!

label_cardinality_explosion.png

最后,值得注意的是,由于复合效应,向一个指标添加单个标签就能够显著增加时间序列的数量(最多可达新增标签基数的乘积)。

Image
Prometheus 与时间序列数据模型

仍以 Prometheus 作为示例数据库,其存储的基本单位是时间序列本身。一个指标的每个独特标签组合都会创建一个新的时间序列,并且每个时间序列都带有其自身的开销。如上所述,向时间序列添加一个标签会显著增加其基数。

但为什么每个时间序列都有如此显著的开销呢?

答案在于 Prometheus 服务器在内部处理这些时间序列的方式。

数据结构与内存开销

当 Prometheus 采集一个样本时,它首先需要检查该时间序列是否之前已存在。时间序列可以通过其指标名称和一组独特的标签值进行识别。因此,标签集和指标名称会被哈希处理,以生成一个唯一的序列标识符,并以此来查找该时间序列。这种查找操作快速且可预测,本质上是对内存中的时间序列索引进行一次哈希表查找。

假设该时间序列已存在,新的样本需要被追加到一个现有的内存结构——即 memSeries 中。除了标签值,该结构还保存了所有样本及其对应的时间戳。这种追加操作开销很低,代表了常见的“热路径”。

Image

如果该时间序列不存在,则需要创建一个 memSeries 并将其注册到内部结构中。这种创建操作发生在写入路径上,因此它直接影响摄入延迟。

在 memSeries 中,样本的管理方式也存在一些重要的细微之处。默认情况下,Prometheus 会在内存中保留最多两小时的最新数据,这部分数据被称为 Head block。在 Head block 内部,每个活动的时间序列将其样本存储在压缩数据块中,而非独立的数据点。memSeries 引用这些数据块,而这些数据块默认通常被设置为可容纳 120 个样本。

如果一个指标每分钟采集一次,这意味着在 Head block 中,一个数据块将覆盖每个时间序列的两小时数据。如果样本收集频率更高,数据块会更快地被填满,并且在相同的两小时窗口内会分配额外的数据块。因此,更高的采集频率会增加每个时间序列的内存数据块数量,从而增加内存消耗。

因此,每个时间序列的实际开销取决于几个因素:

• memSeries 结构体及其所需的字段本身大约占用 200 字节

• 标签的数量和大小

• 每个时间序列的样本数量以及生成的块 (chunk)。注意:每个块也存在元数据开销。

综上所述,Head 块的内存使用量主要受两个因素影响:时间序列的数量和每个时间序列的样本数量。

独立存储每个时间序列的决定,意味着每个时间序列都会固有地为其自身、其标签及其块带来元数据开销。

Prometheus 使用基于 XOR 的编码存储常规浮点样本,其中每个值都通过与前一个值进行异或 (XOR) 运算的结果来存储,此外还采用了一种紧凑的“增量之增量 (delta-of-deltas)”编码用于时间戳。时间戳增量和异或值都使用可变宽度位编码进行打包。这种压缩技术非常适用于在固定间隔采集且样本不变化的长期时间序列数据。

这尚未计入额外的系统级结构,例如倒排索引 (inverted index),这些结构使查询能够通过标签选择器找到时间序列。该倒排索引有效地为每个标签名和值存储了一个引用列表,指向包含该标签对的所有时间序列。随着基数 (cardinality) 的增加,这些倒排列表 (posting lists) 会随之增长,从而进一步增加内存开销,并提高了评估涉及多标签组合匹配的查询所需的工作量。

将数据结构存储到磁盘

如上所述,Head 块在内存中保存着大约两个小时的最新数据。为防止内存无限增长,Prometheus 会定期将 Head 块“截断”并写入磁盘,形成持久化块。

实际上,这一过程大约每两个小时发生一次,在此之前,最新写入的块会一直保留在 Head 块中。届时,该时间范围内的压缩块将作为新块的一部分写入磁盘。

最新数据驻留在内存中,每隔几个小时便会被“密封”并持久化。一旦写入磁盘,该时间范围内的内存中块数据便可从 Head 块中释放。随后,数据将在磁盘上按照指定的保留周期进行保留。

head_block_truncation.png

Prometheus 也运行后台数据压缩进程 (compaction process),其原理与 LSM (Log-Structured Merge-tree) 风格的其他存储系统类似。较小的块会合并成跨越更长时间范围的较大块。这减少了磁盘上的块索引数量,并通过降低每块开销提升了查询效率。数据压缩主要侧重于优化磁盘布局和长期存储效率。它不直接减少 Head (头部) 中活跃序列相关的内存开销。

清理内存

当数据块 (chunks) 写入磁盘后,大多数 memSeries (内存序列) 仍会在内存中保留最近的数据块。Head 块大约存储 2 小时的数据,并且如前所述,块切割 (block-cutting) 过程是基于时间的,并与 2 小时的时间边界存在偏移。因此,仍在接收样本的序列会自然地在内存中保留覆盖最近时间窗口的数据块。

然而,也可能存在内存中已无任何数据块 (chunks) 的 memSeries。例如,当一个 Pod (容器) 重启,且其 Pod ID 是标签 (labels) 的一部分时,就可能出现这种情况。在这种情况下,该序列是短暂的 (ephemeral);它停止接收数据,并且一旦其数据块随着块切割操作被写入磁盘,其内存中就不再保留任何样本了。

块切割完成后,Prometheus 会执行一次头部截断 (head truncation) 和清理过程 (cleanup pass)。在此过程中,Prometheus 会查找内存中不再包含任何数据块且近期未接收到样本的序列,并将其从内存索引中移除。这实际上就是孤立时间序列 (orphaned time series) 被清理的时机。

这通常能确保生命周期较短的序列不会无限期地持续消耗内存。尽管如此,由于块切割和头部截断是基于时间周期 (time-based cadence) 而非序列停止接收数据的确切时刻进行操作的,因此,一个仅短暂存在的序列可能在最终清理前,在内存中保留数小时之久。

Prometheus 优势

上述数据模型若能妥善运用,将非常有效。更具体地说,当您满足以下条件时:

• 数量适中且生命周期较长的序列

• 以固定间隔进行抓取 (scraped)

• 序列的样本值之间变化不剧烈。

在这种场景下,Prometheus 的数据块压缩 (chunk compression)、XOR 编码 (XOR encoding) 以及基于增量的时间戳存储机制 (delta-based timestamp storage),均能发挥极佳效用。

向现有序列追加新值既廉价又可预测。只要您重复采集相同的序列集,并且标签集保持在合理的大小,其存储模型就能高效且表现良好。因此,Prometheus 获得了广泛采用,并仍是低基数 (low-cardinality) 指标数据的一个成功的存储引擎。

高基数带来的写入阶段问题

该模型的弱点在高基数和高流失率 (high churn) 场景下暴露无遗。

首先,每个序列都有实际的开销。每个时间序列都在内存中拥有独立的结构,包括其标签、数据块 (chunks) 以及倒排索引中的条目。当序列数量稳定时,这种开销尚可管理,但随着唯一序列数量的增长,它会线性扩展。更多的标签意味着更多的可能组合,进而产生更多的序列。每个序列都会带来这份开销。在基数爆炸 (cardinality explosion) 场景下,Head 占用数十甚至数百 GB 的 RAM 并非罕见,这会导致内存压力,甚至系统崩溃。

container_id 等标签在 Kubernetes 等环境中尤其有用,因为它们能帮助运维人员识别特定 pod 或容器实例的问题,并保留完整的操作上下文信息。然而,挑战在于这些标签既属于高基数,又具有高度短暂 (highly ephemeral) 的特性。

随着工作负载的扩展、重启和终止,新的序列不断创建,而旧的序列则会一直驻留在内存中,直到被清理。在 Prometheus 中,这不仅增加了内存开销,还反复迫使系统采用开销更大的序列创建路径,而非成本更低的追加路径。因此,许多团队最终不得不剥离这些维度、积极地对其进行采样,或完全避免使用它们,以防止基数爆炸。例如,在 这篇文章 中,CloudFlare 详细阐述了他们如何通过限制新序列创建来抑制基数爆炸。

所有这些因素叠加在一起,使得当基数超过该模型最初优化的范围时,Prometheus 难以有效运行。

除了 Prometheus (普罗米修斯) 中基数 (cardinality) 带来的技术挑战,如果您正在使用可观测性供应商,还会面临商业影响。许多采用基于序列的时间序列数据模型的供应商,会根据基数收费,通常是按照活跃序列数或每分钟摄取的数据点数量计算。这是因为高基数会直接增加其基础设施成本,迫使他们将这部分成本转嫁给用户。

写入时妥协

用户通常通过限制标签 (label) 的数量、抓取 (scrape) 频率或数据摄取量来应对。团队不仅要考虑他们希望监控什么,还要思考如何保护 Prometheus 免受基数爆炸 (cardinality explosion) 的影响。

除了限制标签名称的长度和每个指标 (metric) 的标签数量等方法,Prometheus 还支持每次抓取 (scrape) 限制。这允许你限制单次抓取中接受的样本 (sample) 总数,其中每个样本可能属于一个不同的序列 (series)。

虽然这种方法有助于防止基数突然激增,但它并不能消除根本风险。一个端点 (endpoint) 仍然可能在每次抓取时发出低于配置限制的样本数,同时随着时间推移引入新的唯一序列,这会增加总体基数并持续消耗内存。目前已有提案 [1][2] 尝试通过多种技术实现更中心化的限制来解决此问题。

在达到基数限制时丢弃数据的系统,还会带来发布时风险 (release-time hazard):当一个新版本发布了更高基数的指标时,它可能会将现有的序列挤出摄取范围,从而破坏前一天还在正常工作的仪表板 (dashboard) 和告警 (alert)。

这些措施通过丢弃数据或限制数据摄取来保护 Prometheus。一旦达到限制,Prometheus 会直接丢弃样本或拒绝接受新的序列。为了维护系统稳定性,数据丢失是这种设计下的必然结果。

归根结底,最安全的方法是从一开始就审慎地管理基数。用户通常通过降低指标分辨率 (metric resolution) 或基数来应对。这通常意味着:

• 增加抓取 (scrape) 间隔 ,从而减少指标的收集频率。

• 使用重新打标签规则 (relabeling rules) 在抓取时丢弃特定指标

• 减少标签维度 (label dimensions) ,以创建更少的唯一序列。

• 限制每次抓取的样本数量 ,从而有效地丢弃多余的序列。

你会在许多博客和指南中发现 专门讨论如何在 Prometheus 中管理基数 (cardinality) 的内容。然而,在实践中,这给 SRE 团队带来了认知和操作上的双重负担,他们必须持续担忧新的部署、指标或标签可能突然引入足够多的时序数据流失 (series churn),从而导致系统不稳定。更重要的是,这些折衷方案往往会降低对团队最希望观察的特定事物的可见性,例如单个容器、瞬态工作负载或其他高粒度的操作维度。

读取时长的挑战

这种模型在读取时也存在一些权衡。当 Prometheus 将数据块写入磁盘时,这些数据块会进行内存映射 (memory-mapped)。这是一种高效的技术,操作系统仅在访问时才将页面加载到内存中,因此闲置的历史数据不会立即消耗堆空间。对于大多数工作负载而言,这种方式运行良好,并能使内存占用保持可预测。

如果你查询 特定的时间序列 (series),Prometheus 的效率会非常高。倒排索引 (inverted index) 将标签映射到时间序列,使其能够快速解析精确的标签匹配,将结果范围缩小到一小组时间序列,并仅读取相关的数据块 (chunks)。例如,请看以下 PromQL 查询:

rate(http_response_time_sum{

  host="host-42",

  application="checkout-service",

  request_path="/api/payments",

  status="500"

}[5m]) /

rate(http_response_time_count{

  host="host-42",

  application="checkout-service",

  request_path="/api/payments",

  status="500"

}[5m])

该查询通过将总响应时间的变化率除以请求数量的变化率,计算出特定 host、application、request path 和 status 在过去五分钟内的平均响应时间。

在这种情况下,索引查找非常精确,几乎没有倒排列表 (posting lists) 进行交叉操作,并且只有少量的数据块从磁盘读取。这是一个快速路径。当查询变得更广泛或聚合程度更高时,挑战便随之出现。假设你进行如下查询:

sum(rate(http_response_time_sum{

  application="checkout-service",

  request_path=~".+",

  status=~"2..|5.."

}[5m]))

/

sum(rate(http_response_time_count{

  application="checkout-service",

  request_path=~".+",

  status=~"2..|5.."

}[5m]))

该查询匹配了 checkout-service 的所有时间序列,涵盖所有主机、端点以及广泛的状态码。由于正则表达式可以匹配到许多可能的标签值,Prometheus 会为 endpoint 和 status_code 汇集大量的倒排列表集合 (posting sets),然后与应用程序约束进行组合。

缺乏谓词下推 (predicate pushdown)

此外,Prometheus 无法将任意值谓词下推到压缩分块存储(compressed chunk storage)中。一旦选定一组时间序列,引擎必须读取其对应的数据块(chunks),并在指定时间范围内扫描其中的样本。你无法通过指定“只返回高于 X 的值”来避免读取该时间序列的其余数据。即使只有部分数据相关,也必须对整个数据块进行解码。虽然有针对性的查询效率很高,但对高基数(high-cardinality)标签进行广泛聚合时,可能需要加载并解码大量时间序列及其数据块,这会涉及处理巨量数据。

读取效率的权衡

应尽量避免针对高基数维度的宽泛查询。省略关键标签过滤、严重依赖正则表达式匹配,或一次性聚合数百万个时间序列的查询,都会导致大规模的倒排索引(postings)交叉操作,并需要解码大量数据块。

特别是在高更替率(high-churn)环境中,对 pod_id、container_id 或 endpoint 等维度执行通配符式查询会迅速变得昂贵。旨在缩小标签组合范围的有针对性查询表现良好,然而,对大规模基数集执行宽泛、高层次的聚合,通常会导致性能显著下降。

Image
结论

高基数在 Prometheus 中带来了挑战,因为每个独特的标签组合都会创建一个独立的时间序列,并伴随着其自身的内存、索引和生命周期开销。随着维度数量和数据更替率的增加,这会影响数据摄入、查询性能以及操作稳定性,迫使用户在可见性、成本和系统可靠性之间进行权衡取舍。在下一篇文章中,我们将深入探讨为何同样的工作负载在 ClickHouse 中表现出截然不同的特性。我们将特别分析宽事件模型(wide events model)、列式存储(column-oriented storage)、动态属性(dynamic attributes)以及分析查询执行(analytical query execution)如何从根本上改变基数成本的体现方式,以及为何在 ClickHouse 中这些成本在实践中通常更易于管理。

/END/

试用阿里云 ClickHouse企业版

轻松节省30%云资源成本?阿里云数据库ClickHouse 云原生架构全新升级,首次购买ClickHouse企业版计算和存储资源组合,首月消费不超过99.58元(包含最大16CCU+450G OSS用量)了解详情:https://t.aliyun.com/Kz5Z0q9G

图片
图片

征稿启示

面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:[email protected]

图片图片