本文字数15840估计阅读时间40分钟作者Tom Schreiber and Lionel Palacin在上一篇文章中我们探讨了高基数high cardinality为何会给 Prometheus 和其他面向序列series-oriented的时序数据库带来挑战。我们分析了它们的底层存储模型——即每个唯一的标签组合都会创建一个独立的时间序列——如何随着维度dimensionality和数据更新频率churn的增加导致内存开销、写入放大、操作复杂性以及查询时间上的权衡。您会经常听到我们提到当 ClickHouse 用于可观测性observability工作负载时它不会受到高基数的显著影响。这对于我们通常所指的工作负载确实如此但仍需加以说明。ClickHouse 仍然会为基数付出代价这主要体现在查询时而非数据摄取ingestion阶段。在本文中我们将深入探讨 ClickHouse 以及更广泛的列式数据库为何在处理高基数可观测性数据方面与 Prometheus 等系统存在如此显著的差异。同时我们也会阐述该模型相较于 Prometheus 的不足之处。ClickHouse 并非 Prometheus 的即插即用替代品其应用需要我们以不同的视角来思考遥测telemetry、数据埋点instrumentation和数据聚合aggregation。ClickHouse 中基数为何不同为了理解高基数在 ClickHouse 中为何有不同的表现我们首先需要重新思考可观测性数据的建模方式。ClickHouse 提倡一种转变不再将遥测数据视作数百万个独立维护的时间序列而是将其表示为表中的行这些行包含可在查询时进行聚合的属性attributes和测量值measurements。这种方法常被称为“宽事件wide events”模式并由 Charity Majors 等实践者推广开来(https://charity.wtf/2022/08/15/live-your-best-life-with-structured-events/)。如果有助于理解您可以简单地将其视为附加了指标的日志。接下来我们首先探讨它与传统的 Prometheus 风格指标模型有何不同。以宽事件形式表示指标需要强调的是我们并非旨在 ClickHouse 中直接建模 Prometheus 的指标、指标类型或 PromQL 语义尽管相关工作正在进行(https://github.com/ClickHouse/ClickHouse/pull/102246)而且这种模型无疑具有其优势我们将在后续探讨。相反宽事件wide events方法推动了从传统 Prometheus 指标模型向可观测性数据的事件驱动型模型event-style model的更广泛转变。在此模型中遥测数据被表示为包含维度、属性和数值测量的带时间戳的事件而非独立维护的时间序列。每个事件都包含上下文属性以及一个或多个数值测量而不是将每个指标作为拥有独立身份和生命周期的单独序列进行存储。查询在读取时动态地将这些事件聚合成所需的视图、速率、摘要或时间桶。重要的是这通常是一种更宽松、更自然地生成遥测数据的方式。应用程序无需预先定义严格的指标 schema只需发出带有数值的结构化事件。在 ClickHouse 可观测性部署中一种常见模式是利用宽事件 schema 例如 otel\_logs(https://clickhouse.com/docs/use-cases/observability/clickstack/ingesting-data/schemas) 将指标数据直接与日志一同存储。例如服务可能不再为响应时间、请求计数、状态码和主机分别定义多个独立指标而是简单地发出以下事件logger.Info(request completed, zap.String(host, host-42), zap.String(application, checkout-service), zap.String(request_path, /api/payments), zap.Uint16(status, responseStatus), zap.Uint64(response_time, responseTime), zap.Uint64(size, responseSize), )采用宽事件模型后请求本身就成为遥测数据的自然单位。所有上下文维度和测量都随附于同一事件。而在 Prometheus 风格的模型中相同的信息通常会被分解到多个指标定义、计数器、仪表、直方图以及标签组合中。一旦这些信息被归并为聚合数据单个信号就会丢失且无法恢复。宽事件则颠覆了这一模式聚合数据是从原始事件派生而来而非取而代之因此图表上的任何峰值都能追溯到产生它的具体请求。列式存储在采用宽事件模型时我们最初可能会直觉性地借鉴传统分析工作负载的经验将每个标签建模为一个专用列。尽管这种做法对于稳定的 schema 能够良好运行但在可观测性领域却鲜有实用性并且也不符合宽事件 schema 的本质。标签通常是动态的、高基数的且不可预测的这使得僵硬的预先定义 schema 难以维护并且与真实的遥测数据格格不入。CREATE TABLE metrics ( time DateTime CODEC(Delta(4), ZSTD(3)), host LowCardinality(String), application LowCardinality(String), request_path String, remote_addr IPv4, remote_user LowCardinality(String), request_type LowCardinality(String), request_protocol LowCardinality(String), status UInt16, domain_referer LowCardinality(String), browser LowCardinality(String), device LowCardinality(String), response_time UInt16, size Decimal(7, 1) ) ENGINE MergeTree ORDER BY (host, toStartOfMinute(time), status, application, request_path, remote_addr)上述 schema 尽管针对每个标签独立设列进行了优化但在新指标频繁添加的高度动态环境中并不切实际。这不是一种推荐的宽事件 schema。为处理这种动态结构我们推荐 ClickHouse 的 Map(https://clickhouse.com/docs/sql-reference/data-types/map) 类型。鉴于我们文档(https://clickhouse.com/docs/use-cases/observability/clickstack/ingesting-data/schema/map-vs-json)中已深入探讨的原因我们建议在可观测性工作负载中使用 Map 类型而非 JSON 类型。Map 类型将标签存储为键值对其中键代表标签名值代表标签值。它的主要限制在于所有键和值必须共享相同的类型Map 的定义格式为 MapString, type。在实践中这通常意味着值以字符串形式存储。尽管这种方式很灵活但如果需要将值解释为数值或其他强类型数据则可能需要在查询时进行类型转换。从历史上看Map 在读取时也存在一个重要缺点。读取单个标签需要读取整个 Map 结构及其所有相关标签从而产生显著的 I/O 开销。然而最近对 sharded maps(https://clickhouse.com/blog/clickhouse-release26-03#sharded-map) 的支持通过允许更具选择性地访问 Map 内容在很大程度上缓解了这一问题。对于我们之前的示例一个针对宽事件的表结构可能如下所示CREATE TABLE events ( time DateTime CODEC(Delta(4), ZSTD(3)), labels Map(LowCardinality(String), String), response_time UInt16 MATERIALIZED toUInt16(labels[response_time]), host LowCardinality(String) MATERIALIZED labels[host], status LowCardinality(String) MATERIALIZED labels[status], application LowCardinality(String) MATERIALIZED labels[application], INDEX idx_labels_keys mapKeys(labels) TYPE text(tokenizer array), INDEX idx_labels_vals mapValues(labels) TYPE text(tokenizer array) ) ENGINE MergeTree ORDER BY (host, toStartOfMinute(time), status, application) SETTINGS map_serialization_version with_buckets, max_buckets_in_map 32, map_buckets_strategy sqrt;请注意此处的设置强制 Map 进行分片。更多详情请参见此处(https://clickhouse.com/docs/sql-reference/data-types/map#bucketed-map-serialization)。原则上此 Schema 与 ClickStack for OpenTelemetry logs(https://clickhouse.com/docs/use-cases/observability/clickstack/ingesting-data/schemas#logs) 所使用的 Schema 非常相似尽管已大大简化。在 ClickStack 中资源和范围属性有效地充当了动态标签 Map。该 Schema 随后利用了以下讨论的相同技术例如 Map 键和值上的文本索引。因此ClickHouse 并非以“series objects”的视角来思考而是简单地存储数据行其中包含时间戳、一个分片 Map 的标签以及用于指标值的常规数值列。我们还将标签 Map 中的键物化(https://clickhouse.com/docs/use-cases/observability/clickstack/performance_tuning#materialize-frequently_queried-attributes)这些键预期会被频繁过滤或访问以便它们能作为专用列存储在磁盘上。这种物化在数据写入时自动发生使 ClickHouse 能够在查询时直接过滤、裁剪和读取这些维度而无需访问标签 Map从而减少了常见访问模式的 I/O (Input/Output)。同样的方法也适用于频繁查询的数值。在上述示例中response_time 从 Map 中物化为一个专用的类型化列因为它预期会被定期聚合。这避免了在执行 avg(response_time) 或百分位计算等操作时重复访问 Map 以及在查询时进行类型转换。值得注意的是物化列也可以在数据摄入之后再添加。新插入的行将直接在磁盘上物理存储物化列而旧行在查询时仍可从底层 Map 间接解析出相应的值。例如如果我们后来决定 labels[region ]已成为一个常用过滤器我们可以添加ALTER TABLE events ADD COLUMN region LowCardinality(String) MATERIALIZED labels[region];新数据将立即受益于直接列访问、数据裁剪和优化的压缩而历史行仍可通过 region 进行查询且无需进行全表重写。在 ClickHouse 内部Map 被表示为键值对数组并采用分桶序列化 (bucketed serialization) 机制根据标签名的哈希值将键分布到多个独立的桶中。这意味着读取特定标签的查询只需访问包含该键的桶而无需遍历整个 Map 结构。指标列本身是独立存储的并与标签分开压缩。每次插入操作都会首先使用默认的 Map 布局创建一个 Level 0 部分其中键和值以平面数组形式存储不进行分桶。后续的插入会以相同的格式创建更多的 Level 0 部分。由于这些部分通常较小扫描完整的键和值数组的开销通常不大。在后台合并过程中ClickHouse 会通过将键哈希到更小的独立桶中将 Map 重写为分桶存储。当访问诸如 labels[status ]这样的键时ClickHouse 会计算键哈希并仅读取相应的桶而不是整个 Map从而显著减少读取 I/O。实际上根据 Map 的大小这可以将单键查找性能提升 2 到 49 倍。Prometheus 则采取了相反的做法。在 Prometheus 中每个新的标签组合都会创建一个新的序列对象 (series object)包含其自身的元数据、数据块 (chunks) 和索引开销。而在 ClickHouse 中添加新的标签值组合并不会创建新的结构对象或内存中的序列表示。它只是简单地添加一行其中包含一组不同的标签值。行在插入时会作为一个部分写入磁盘并按目标表的主键排序——具体到本例中排序依据是主机、以分钟为粒度的时间戳、状态和应用程序。在进行 ZSTD 压缩之前可以对每列应用编解码器 (Codec)以最大限度地减少后续读取操作的 I/O 开销。为了管理每张表的部分 (part) 数量一个后台合并任务 (background merge job) 会定期将较小的部分合并成较大的部分同时保留指定的排序顺序直到它们达到可配置的压缩大小通常约为 150 GB。随着时间的推移这个过程会形成一个合并后的部分的层次结构。在执行插入操作时ClickHouse 写入的是行而不是序列对象。列式存储的优势即使标签本身是以 Map 形式存储而非完全独立的列表的列式存储特性及其排序机制依然能带来多项重要优势。通过数据剪枝实现快速过滤 - 在表的排序键上构建稀疏索引该排序键在此场景下由根据标签映射 (label map) 具象化 (materialized) 出的常用过滤维度组成紧接着是时间戳。在上述示例中host、status 和 application 等标签被具象化为专用列并包含在排序键中。数据以数据分块 (parts) 的形式存储每个 part 划分为大约 8,000 行的 granule。对于每个 granuleClickHouse 记录排序键列的第一个值而非索引每一行因此该索引被视为稀疏索引。由于数据首先按常用查询维度然后按时间进行物理排序具有相似值的行在磁盘上自然地聚合在一起。当查询根据 host、application、status 或时间范围等常用标签进行过滤时ClickHouse 能够高效地对 granule 进行剪枝。它不必扫描整个数据集而是可以跳过大量不相关数据仅读取可能包含匹配行的 granule从而显著减少 I/O 并提高查询性能。在 mapKeys(labels) 和 mapValues(labels) 上建立的二级索引进一步完善了这一机制。这些倒排索引使得 ClickHouse 即使在过滤那些未具象化为专用列的标签时也能对 granule 进行剪枝从而有助于为高度动态的维度保留灵活性。压缩 - 即使标签存储在 Map 结构中ClickHouse 仍能为可观测性工作负载实现出色的数据压缩。数据根据主机 (host)、状态 (status) 和应用程序 (application) 等常用过滤维度进行物理排序随后按时间排序。这种排序方式自然地将拥有相似标签集、指标值和重复字符串的行聚合在一起形成了强大的数据局部性压缩算法可以高效地利用这一点。将频繁查询的标签物化为独立列能进一步提升压缩效果。由于具有相同值的行在磁盘上连续存储这使得字典编码 (dictionary encoding) 和压缩编解码器 (compression codecs) 的效率显著提高。已物化的指标列例如上述示例中的 response_time仍然保持完全的列式存储并使用特定类型编解码器和 ZSTD 等通用压缩算法进行独立压缩。数值列还可以额外受益于 Delta 等编解码器(https://clickhouse.com/blog/optimize-clickhouse-codecs-compression-schema)或 Gorilla(https://www.vldb.org/pvldb/vol8/p1816-teller.pdf)。Map 结构本身也具备高效的压缩能力。标签键在行之间重复性极高同时许多标签值在实际工作负载中表现出强大的时间局部性 (temporal locality)——在主键 (primary key) 中使用物化的 Map 标签有助于增强这种局部性。结合分片 Map 序列化 (sharded map serialization)ClickHouse 即使面对高度动态的标签集也能保持强大的压缩特性。减少 I/O - 查询只需读取满足其需求的 Granule、列和 Map 桶。频繁用于过滤的标签和指标例如 host、response_time 和 status被物化到专用列中这使得许多常见查询能够完全避免访问标签 Map。由于这些物化列是排序键 (sorting key) 的一部分ClickHouse 可以在读取指标数据之前高效地剪枝 (prune) Granule。对于仅存储在分片 Map 中的标签ClickHouse 只读取包含请求键的桶而非整个标签结构。相较于早期的 Map 实现这显著减少了 I/O 开销同时保留了动态标签的灵活性。该 Schema 还为 mapKeys(labels) 和 mapValues(labels) 定义了倒排索引(https://clickhouse.com/docs/engines/table-engines/mergetree-family/textindexes)。这些索引允许 ClickHouse 在查询根据非物化标签进行过滤时进一步剪枝 Granule从而避免读取那些不包含所需标签名称或值的 Granule。这使得即使对于 container_id 或 pod_id 等高度动态的维度也能实现高效过滤而无需为每个标签都创建独立的列。由于指标列在磁盘上独立且连续存储ClickHouse 还能高效地应用向量化查询执行 (vectorized query execution)将大批量值连续读入内存从而实现高分析吞吐量。在查询时文本索引采用分层查找机制通过稀疏索引、字典块和倒排列表无需扫描整个数据集即可高效找到匹配的行。欲了解更多详情请参阅我们的专题文章(https://clickhouse.com/blog/clickhouse-full-text-search-object-storage)。布隆过滤器索引(https://clickhouse.com/docs/optimize/skipping-indexes#bloom-filter-types)也可用于此目的并且对于许多工作负载而言可能已足够。实际上最佳选择取决于标签的分布、查询模式以及所应用过滤器的选择性。分析型聚合 - 在此模型中指标以数值列的形式表示例如response_time。聚合操作因此变得简单高效。例如如果我们要计算avg(response_time)ClickHouse只需读取符合查询条件的行中的response_time列。得益于稀疏索引和辅助过滤器ClickHouse引擎能够识别出满足过滤条件的特定粒度范围并仅从磁盘读取这些范围。这些范围可以在集群中的所有CPU核心和服务器上并行处理从而在存储层实现有效的谓词下推。因此聚合性能不再像基于时序的模型那样直接与基数挂钩。它与符合查询条件的行数更为密切。对于匹配的行只需读取相关的指标列。在高基数列上施加过滤器通常是有益的因为它们有助于减少需要扫描的行数。相反没有过滤器或在低基数列上施加过滤器的查询通常需要读取更多数据这将需要并行读取才能保证性能。示例考虑之前的metrics表。在我们的示例中该表存储了应用程序的50亿个响应时间及大小数据。这些数据中的某些列自然具有高基数在任何传统时序数据库如Prometheus中您都会避免将它们用作标签。SELECT count() FROM events ┌────count()─┐ │ 5339783200 │ -- 5.34 billion └────────────┘ 1 row in set. Elapsed: 0.004 sec.下文展示了每列的基数以及如果采用时序模型来表示这些数据时总的基数情况。SELECT uniq(host), uniq(application), uniq(labels[request_path]), uniq(labels[remote_addr]), uniq(labels[remote_user]), uniq(labels[request_type]), uniq(labels[request_protocol]), uniq(status), uniq(labels[domain_referer]), uniq(labels[browser]), uniq(labels[device]) FROM events FORMAT Vertical Row 1: ────── uniq(host): 100 uniq(application): 1000 uniq(arrayEl⋯est_path)): 881432 uniq(arrayEl⋯ote_addr)): 258115 uniq(arrayEl⋯ote_user)): 995890 uniq(arrayEl⋯est_type)): 5 uniq(arrayEl⋯protocol)): 7 uniq(status): 15 uniq(arrayEl⋯_referer)): 369 uniq(arrayEl⋯browser)): 9 uniq(arrayEl⋯ device)): 11 SELECT uniq(host, application, labels[request_path], labels[remote_addr], labels[remote_user], labels[request_type], labels[request_protocol], status, labels[domain_referer], labels[browser], labels[device]) AS total_time_series FROM events ┌─total_time_series─┐ │ 5239274309 │ -- 5.24 billion └───────────────────┘显然这里的时序数量非常庞大几乎与行数相同达到52亿。行与时间序列是不同的概念。在不增加独立时间序列数量的前提下你可以为现有序列添加更多数据点从而增加行数。在大多数数据集中总行数将远远超过时间序列的数量。在这个例子中每行甚至存储了响应时间、大小两个指标因此行、样本和序列之间的关系很少是一一对应的。现在我们来看以下查询该查询在一台32核机器上以毫秒级速度运行。它计算了上述表中响应时间的平均值按1分钟间隔进行分组按状态进行分桶并限定为特定的某一天SELECT toStartOfMinute(time) AS minute, labels[status] as status, avg(response_time) FROM metrics WHERE (time 2025-02-23 08:00:00) AND (time 2025-02-23 12:00:00) GROUP BY minute, status ORDER BY minute ASC ┌──────────────minute─┬─status─┬─avg(response_time)─┐ │ 2025-02-23 08:00:00 │ 403 │ 5065.290909090909 │ │ 2025-02-23 08:00:00 │ 500 │ 5327.543933054393 │ │ 2025-02-23 08:00:00 │ 302 │ 5119.796687088722 │ .. 3084 rows in set. Elapsed: 0.122 sec. Processed 49.21 million rows, 340.04 MB (404.24 million rows/s., 2.79 GB/s.) Peak memory usage: 726.64 MiB.这是一个在 Prometheus 中执行会面临挑战的查询示例。你需要访问许多独立的序列才能完成此计算。请注意此查询计算的是事件型数据上的简单平均值并按固定的一分钟时间窗口进行分组。它在语义上与之前的 avg_over_time(...5m) 示例并不完全相同。尽管结果在图表上可能看起来相似但底层的计算模型是不同的——详见When Prometheus still makes sense(#when-prometheus-still-makes-sense)。将查询范围缩小到更少的“序列”会减少需要读取的数据量。例如SELECT toStartOfMinute(time) AS minute, avg(response_time) FROM events WHERE application 603 AND host 3 AND status 200 GROUP BY minute ORDER BY minute ASC ┌──────────────minute─┬─avg(response_time)─┐ │ 2025-01-24 00:00:00 │ 3680.4615384615386 │ │ 2025-01-24 00:01:00 │ 5515.153846153846 │ .. 5 rows in set. Elapsed: 0.078 sec. Processed 53.40 million rows, 479.93 MB (681.97 million rows/s., 6.13 GB/s.) Peak memory usage: 613.54 MiB.在后一种情况下得益于倒排索引我们读取的数据量显著减少这从查询执行时间中得到了体现。要将查询范围缩小到特定的某个序列你只需为所有列添加过滤器即可。当指标的查询频率不足以支持将其物化时它们也可以保留在 labels 映射中并在查询时进行动态访问。尽管我们通常建议对 response_time 等常用的聚合指标进行物化但可观测性工作负载中常包含许多低频测量值这些值仅偶尔会被查询。在这种情况下直接从映射中访问指标是完全合理的做法。例如假设事件还包含一个表示响应负载大小的 size 属性。我们可能只偶尔需要计算随时间变化的整体流量。此时与其物化另一个专用列我们可以直接从映射中访问并转换该值SELECT toStartOfMinute(time) AS minute, sum(toDecimal32(labels[size], 1)) AS total_traffic FROM events WHERE application 603 GROUP BY minute ORDER BY minute ASC ┌──────────────minute─┬─total_traffic─┐ │ 2025-01-24 00:00:00 │ 89167 │ │ 2025-01-24 00:01:00 │ 352724 │ │ 2025-01-24 00:02:00 │ 284684 │ … 4 rows in set. Elapsed: 1.138 sec. Processed 53.31 million rows, 13.60 GB (46.84 million rows/s., 11.95 GB/s.)这种灵活性是宽事件模型wide events model的优势之一。频繁查询的维度和指标可以被物化为优化列而那些不常使用的属性则无需严格的预设模式定义也能动态访问。当 ClickHouse 处理高基数数据仍有成本时ClickHouse 并不能完全消除基数cardinality带来的成本。部分成本会转移到读取阶段尤其是在执行大型 GROUP BY 操作时。假设我们希望按 request_path 进行分组。此时我们正在读取时按一个高基数列进行分组并访问相应的映射结构。SELECT toStartOfMinute(time) AS minute, labels[request_path] as request_path, avg(response_time) FROM events WHERE toDate(time) 2025-01-24 GROUP BY minute, request_path ORDER BY minute ASC -- well omit the results :) 0 rows in set. Elapsed: 9.433 sec. Processed 153.23 million rows, 63.85 GB (16.24 million rows/s., 6.77 GB/s.) Peak memory usage: 22.82 GiB.这种成本在内存使用和查询耗时上均有所体现。对超高基数列进行高性能分组确实会产生内存开销因为聚合状态aggregation states必须在内存中构建。然而ClickHouse 支持在超出阈值时将数据溢写到磁盘(https://clickhouse.com/docs/sql-reference/statements/select/group-by#group-by-in-external-memory)这使得这些工作负载变得可管理而非灾难性。通过调整参数以限制内存消耗来换取性能即使在高基数场景下它们依然能展现出卓越的性能SELECT toStartOfMinute(time) AS minute, labels[request_path] as request_path, avg(response_time) FROM events WHERE toDate(time) 2025-01-24 GROUP BY minute, request_path ORDER BY minute ASC -- Start external aggregation around 10 GB SETTINGS max_bytes_before_external_group_by 10000000000 0 rows in set. Elapsed: 13.930 sec. Processed 153.23 million rows, 63.85 GB (11.00 million rows/s., 4.58 GB/s.) Peak memory usage: 9.57 GiB.然而更实际的问题是你如何在可观测性仪表盘中可视化近百万条不同的数据行如果你的工作负载确实需要渲染数百万个独立的数据序列ClickHouse 可能不是最理想的工具。但如果你需要对这数百万个序列进行聚合计算例如统计总数或平均值ClickHouse 则表现得非常出色。ClickHouse 的适用场景ClickHouse 特别适用于事件型可观测性数据例如经由指标metrics增强的日志logs和追踪traces在这类数据中高基数high cardinality是其固有特性而非偶然发生。它还擅长长期趋势分析、SLIs、KPIs 以及需要长时间范围分析的更广泛的业务指标。只要指标是从事件中派生而来并且需要灵活的基于时间或维度的聚合时ClickHouse 的列式模型columnar model就能充分发挥其优势。重要的是ClickHouse 不会受到短生命周期、临时性维度如 container_id、pod_id 或现代环境中常见的其他动态生成标识符的结构性影响。对 ClickHouse 而言这些都仅仅是普通的列值。这带来的实际影响非常显著。您可以将完整保真度的指标数据存储在 ClickHouse 中而无需仅仅为了控制基数cardinality而进行激进采样、预聚合或剥离标签。您无需为了避免数据爆炸而设计数据模型schema而是可以自然地建模数据并让查询模式决定读取哪些内容。Prometheus 何时仍然有意义Prometheus 在许多场景下仍然非常有用尤其因为它拥有广泛的生态系统支持、成熟的运维工具以及深度集成的告警能力。Prometheus 围绕着一种指标优先模型构建在该模型中样本被抓取并存储为独立的时间序列。它支持多种指标类型计数器counters、仪表盘gauges、直方图histograms和摘要summaries。每种类型都内含了数据应如何解释的假设。计数器用于处理速率和重置。直方图和摘要支持延迟和分布分析。这些语义深度嵌入在 PromQL 及其周边生态系统中。在 ClickHouse 的事件风格模型中指标通常作为事件的数值列存储并在查询时进行聚合。实际上这些值通常更像是仪表盘gauges而非严格的 Prometheus 风格计数器。与 Prometheus 类似ClickHouse 本质上存储数字直方图除外其语义来自查询逻辑而非存储层本身。相反Prometheus 假设的是定期采样的序列并为计数器、速率、直方图和范围向量range vectors提供一流的语义支持。而 ClickHouse 则处理带时间戳的事件这使其更适合对高基数可观测性数据进行灵活的事件风格聚合而非严格的时间序列语义。对于高度定向的单序列查询和绘图Prometheus 通常表现出色。它的倒排索引inverted index能快速解决标签交集并且读取单个压缩序列的效率很高。ClickHouse 在排序键ordering key与过滤器对齐时也能达到类似的性能但在某些情况下可能会扫描更多数据。这种差异很少是决定性的但这确实是 Prometheus 非常擅长的场景。Prometheus 在基数 (cardinality) 受控且可预测的情况下表现出色。对于中等数量的长期序列、实时告警和传统监控工作流Prometheus 效率极高。它能与 LGTM 等技术栈无缝集成其中 Prometheus 及其分布式对应产品如 Mimir是一等公民。虽然分布式系统解决了可扩展性问题但它们保留了底层的基于序列的模型这意味着基数管理仍需深思熟虑。ClickHouse 用于 Prometheus 指标ClickHouse 的开源可观测性栈 ClickStack 支持 Prometheus 风格的指标Open Telemetry metrics但不应将其视为 Prometheus 或现有成熟的 Prometheus 兼容系统的即插即用替代品。其底层的存储和查询模型截然不同ClickStack 偏爱事件导向的方法而非严格的时间序列模型。因此目前在 ClickStack 中查询 Prometheus 风格的指标是通过更高层的查询构建器和抽象实现的它们的能力比原生 PromQL 更有限。这主要是由于完整复现 Prometheus 语义的复杂性尤其是在处理计数器 (counters)、直方图 (histograms)、范围向量 (range vectors) 和更广泛的 PromQL 执行模型方面。如果您需要成熟、久经考验的 PromQL 支持并将其深度集成到仪表盘、告警和操作工作流中Prometheus 依然是显而易见的选择。话虽如此ClickHouse 对 PromQL 的支持仍在不断发展并取得了近期令人鼓舞的进展(https://github.com/ClickHouse/ClickHouse/issues/57545#issuecomment-4246791048)。手动将复杂的 PromQL 查询转换为 SQL 也并非易事特别是涉及直方图 (histograms) 和范围向量函数 (range-vector functions) 时——但在大型语言模型 (LLMs) 的帮助下仍有可能实现。对于中等基数指标、以 PromQL 为核心的告警以及有针对性的序列查询Prometheus 仍然是更合适的选择。结论ClickHouse 处理高基数 (cardinality) 并非没有代价但与传统的时间序列系统相比其成本体现在截然不同的方面。ClickHouse 通过将模型从面向序列转变为存储在列式存储表中的宽事件 (wide events)从而规避了在 Prometheus 等系统中因高基数而导致的每序列写入放大 (write amplification)、内存开销 (memory overhead) 和运维脆弱性 (operational fragility) 等难题。相应地其权衡主要体现在查询时聚合 (query-time aggregation) 和数据建模决策上而这些正是 ClickHouse 的列式执行模型 (columnar execution model)、压缩 (compression)、剪枝 (pruning) 和并行化 (parallelization) 等特性表现尤为突出的领域。征稿启示面向社区长期正文文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出图文并茂。质量合格的文章将会发布优秀者也有机会推荐到 ClickHouse 官网。关于我们ClickHouse 是全球速度最快资源利用最高效的在线分析列式数据库管理系统。现在ClickHouse可以作为一个安全可扩展的无服务器应用在云中提供服务。通过云服务ClickHouse使得任何人都能轻松获取高效的实时分析处理能力。
ClickHouse vs Prometheus: 高基数挑战,ClickHouse如何化解?(第2篇)
发布时间:2026/7/10 23:07:26
本文字数15840估计阅读时间40分钟作者Tom Schreiber and Lionel Palacin在上一篇文章中我们探讨了高基数high cardinality为何会给 Prometheus 和其他面向序列series-oriented的时序数据库带来挑战。我们分析了它们的底层存储模型——即每个唯一的标签组合都会创建一个独立的时间序列——如何随着维度dimensionality和数据更新频率churn的增加导致内存开销、写入放大、操作复杂性以及查询时间上的权衡。您会经常听到我们提到当 ClickHouse 用于可观测性observability工作负载时它不会受到高基数的显著影响。这对于我们通常所指的工作负载确实如此但仍需加以说明。ClickHouse 仍然会为基数付出代价这主要体现在查询时而非数据摄取ingestion阶段。在本文中我们将深入探讨 ClickHouse 以及更广泛的列式数据库为何在处理高基数可观测性数据方面与 Prometheus 等系统存在如此显著的差异。同时我们也会阐述该模型相较于 Prometheus 的不足之处。ClickHouse 并非 Prometheus 的即插即用替代品其应用需要我们以不同的视角来思考遥测telemetry、数据埋点instrumentation和数据聚合aggregation。ClickHouse 中基数为何不同为了理解高基数在 ClickHouse 中为何有不同的表现我们首先需要重新思考可观测性数据的建模方式。ClickHouse 提倡一种转变不再将遥测数据视作数百万个独立维护的时间序列而是将其表示为表中的行这些行包含可在查询时进行聚合的属性attributes和测量值measurements。这种方法常被称为“宽事件wide events”模式并由 Charity Majors 等实践者推广开来(https://charity.wtf/2022/08/15/live-your-best-life-with-structured-events/)。如果有助于理解您可以简单地将其视为附加了指标的日志。接下来我们首先探讨它与传统的 Prometheus 风格指标模型有何不同。以宽事件形式表示指标需要强调的是我们并非旨在 ClickHouse 中直接建模 Prometheus 的指标、指标类型或 PromQL 语义尽管相关工作正在进行(https://github.com/ClickHouse/ClickHouse/pull/102246)而且这种模型无疑具有其优势我们将在后续探讨。相反宽事件wide events方法推动了从传统 Prometheus 指标模型向可观测性数据的事件驱动型模型event-style model的更广泛转变。在此模型中遥测数据被表示为包含维度、属性和数值测量的带时间戳的事件而非独立维护的时间序列。每个事件都包含上下文属性以及一个或多个数值测量而不是将每个指标作为拥有独立身份和生命周期的单独序列进行存储。查询在读取时动态地将这些事件聚合成所需的视图、速率、摘要或时间桶。重要的是这通常是一种更宽松、更自然地生成遥测数据的方式。应用程序无需预先定义严格的指标 schema只需发出带有数值的结构化事件。在 ClickHouse 可观测性部署中一种常见模式是利用宽事件 schema 例如 otel\_logs(https://clickhouse.com/docs/use-cases/observability/clickstack/ingesting-data/schemas) 将指标数据直接与日志一同存储。例如服务可能不再为响应时间、请求计数、状态码和主机分别定义多个独立指标而是简单地发出以下事件logger.Info(request completed, zap.String(host, host-42), zap.String(application, checkout-service), zap.String(request_path, /api/payments), zap.Uint16(status, responseStatus), zap.Uint64(response_time, responseTime), zap.Uint64(size, responseSize), )采用宽事件模型后请求本身就成为遥测数据的自然单位。所有上下文维度和测量都随附于同一事件。而在 Prometheus 风格的模型中相同的信息通常会被分解到多个指标定义、计数器、仪表、直方图以及标签组合中。一旦这些信息被归并为聚合数据单个信号就会丢失且无法恢复。宽事件则颠覆了这一模式聚合数据是从原始事件派生而来而非取而代之因此图表上的任何峰值都能追溯到产生它的具体请求。列式存储在采用宽事件模型时我们最初可能会直觉性地借鉴传统分析工作负载的经验将每个标签建模为一个专用列。尽管这种做法对于稳定的 schema 能够良好运行但在可观测性领域却鲜有实用性并且也不符合宽事件 schema 的本质。标签通常是动态的、高基数的且不可预测的这使得僵硬的预先定义 schema 难以维护并且与真实的遥测数据格格不入。CREATE TABLE metrics ( time DateTime CODEC(Delta(4), ZSTD(3)), host LowCardinality(String), application LowCardinality(String), request_path String, remote_addr IPv4, remote_user LowCardinality(String), request_type LowCardinality(String), request_protocol LowCardinality(String), status UInt16, domain_referer LowCardinality(String), browser LowCardinality(String), device LowCardinality(String), response_time UInt16, size Decimal(7, 1) ) ENGINE MergeTree ORDER BY (host, toStartOfMinute(time), status, application, request_path, remote_addr)上述 schema 尽管针对每个标签独立设列进行了优化但在新指标频繁添加的高度动态环境中并不切实际。这不是一种推荐的宽事件 schema。为处理这种动态结构我们推荐 ClickHouse 的 Map(https://clickhouse.com/docs/sql-reference/data-types/map) 类型。鉴于我们文档(https://clickhouse.com/docs/use-cases/observability/clickstack/ingesting-data/schema/map-vs-json)中已深入探讨的原因我们建议在可观测性工作负载中使用 Map 类型而非 JSON 类型。Map 类型将标签存储为键值对其中键代表标签名值代表标签值。它的主要限制在于所有键和值必须共享相同的类型Map 的定义格式为 MapString, type。在实践中这通常意味着值以字符串形式存储。尽管这种方式很灵活但如果需要将值解释为数值或其他强类型数据则可能需要在查询时进行类型转换。从历史上看Map 在读取时也存在一个重要缺点。读取单个标签需要读取整个 Map 结构及其所有相关标签从而产生显著的 I/O 开销。然而最近对 sharded maps(https://clickhouse.com/blog/clickhouse-release26-03#sharded-map) 的支持通过允许更具选择性地访问 Map 内容在很大程度上缓解了这一问题。对于我们之前的示例一个针对宽事件的表结构可能如下所示CREATE TABLE events ( time DateTime CODEC(Delta(4), ZSTD(3)), labels Map(LowCardinality(String), String), response_time UInt16 MATERIALIZED toUInt16(labels[response_time]), host LowCardinality(String) MATERIALIZED labels[host], status LowCardinality(String) MATERIALIZED labels[status], application LowCardinality(String) MATERIALIZED labels[application], INDEX idx_labels_keys mapKeys(labels) TYPE text(tokenizer array), INDEX idx_labels_vals mapValues(labels) TYPE text(tokenizer array) ) ENGINE MergeTree ORDER BY (host, toStartOfMinute(time), status, application) SETTINGS map_serialization_version with_buckets, max_buckets_in_map 32, map_buckets_strategy sqrt;请注意此处的设置强制 Map 进行分片。更多详情请参见此处(https://clickhouse.com/docs/sql-reference/data-types/map#bucketed-map-serialization)。原则上此 Schema 与 ClickStack for OpenTelemetry logs(https://clickhouse.com/docs/use-cases/observability/clickstack/ingesting-data/schemas#logs) 所使用的 Schema 非常相似尽管已大大简化。在 ClickStack 中资源和范围属性有效地充当了动态标签 Map。该 Schema 随后利用了以下讨论的相同技术例如 Map 键和值上的文本索引。因此ClickHouse 并非以“series objects”的视角来思考而是简单地存储数据行其中包含时间戳、一个分片 Map 的标签以及用于指标值的常规数值列。我们还将标签 Map 中的键物化(https://clickhouse.com/docs/use-cases/observability/clickstack/performance_tuning#materialize-frequently_queried-attributes)这些键预期会被频繁过滤或访问以便它们能作为专用列存储在磁盘上。这种物化在数据写入时自动发生使 ClickHouse 能够在查询时直接过滤、裁剪和读取这些维度而无需访问标签 Map从而减少了常见访问模式的 I/O (Input/Output)。同样的方法也适用于频繁查询的数值。在上述示例中response_time 从 Map 中物化为一个专用的类型化列因为它预期会被定期聚合。这避免了在执行 avg(response_time) 或百分位计算等操作时重复访问 Map 以及在查询时进行类型转换。值得注意的是物化列也可以在数据摄入之后再添加。新插入的行将直接在磁盘上物理存储物化列而旧行在查询时仍可从底层 Map 间接解析出相应的值。例如如果我们后来决定 labels[region ]已成为一个常用过滤器我们可以添加ALTER TABLE events ADD COLUMN region LowCardinality(String) MATERIALIZED labels[region];新数据将立即受益于直接列访问、数据裁剪和优化的压缩而历史行仍可通过 region 进行查询且无需进行全表重写。在 ClickHouse 内部Map 被表示为键值对数组并采用分桶序列化 (bucketed serialization) 机制根据标签名的哈希值将键分布到多个独立的桶中。这意味着读取特定标签的查询只需访问包含该键的桶而无需遍历整个 Map 结构。指标列本身是独立存储的并与标签分开压缩。每次插入操作都会首先使用默认的 Map 布局创建一个 Level 0 部分其中键和值以平面数组形式存储不进行分桶。后续的插入会以相同的格式创建更多的 Level 0 部分。由于这些部分通常较小扫描完整的键和值数组的开销通常不大。在后台合并过程中ClickHouse 会通过将键哈希到更小的独立桶中将 Map 重写为分桶存储。当访问诸如 labels[status ]这样的键时ClickHouse 会计算键哈希并仅读取相应的桶而不是整个 Map从而显著减少读取 I/O。实际上根据 Map 的大小这可以将单键查找性能提升 2 到 49 倍。Prometheus 则采取了相反的做法。在 Prometheus 中每个新的标签组合都会创建一个新的序列对象 (series object)包含其自身的元数据、数据块 (chunks) 和索引开销。而在 ClickHouse 中添加新的标签值组合并不会创建新的结构对象或内存中的序列表示。它只是简单地添加一行其中包含一组不同的标签值。行在插入时会作为一个部分写入磁盘并按目标表的主键排序——具体到本例中排序依据是主机、以分钟为粒度的时间戳、状态和应用程序。在进行 ZSTD 压缩之前可以对每列应用编解码器 (Codec)以最大限度地减少后续读取操作的 I/O 开销。为了管理每张表的部分 (part) 数量一个后台合并任务 (background merge job) 会定期将较小的部分合并成较大的部分同时保留指定的排序顺序直到它们达到可配置的压缩大小通常约为 150 GB。随着时间的推移这个过程会形成一个合并后的部分的层次结构。在执行插入操作时ClickHouse 写入的是行而不是序列对象。列式存储的优势即使标签本身是以 Map 形式存储而非完全独立的列表的列式存储特性及其排序机制依然能带来多项重要优势。通过数据剪枝实现快速过滤 - 在表的排序键上构建稀疏索引该排序键在此场景下由根据标签映射 (label map) 具象化 (materialized) 出的常用过滤维度组成紧接着是时间戳。在上述示例中host、status 和 application 等标签被具象化为专用列并包含在排序键中。数据以数据分块 (parts) 的形式存储每个 part 划分为大约 8,000 行的 granule。对于每个 granuleClickHouse 记录排序键列的第一个值而非索引每一行因此该索引被视为稀疏索引。由于数据首先按常用查询维度然后按时间进行物理排序具有相似值的行在磁盘上自然地聚合在一起。当查询根据 host、application、status 或时间范围等常用标签进行过滤时ClickHouse 能够高效地对 granule 进行剪枝。它不必扫描整个数据集而是可以跳过大量不相关数据仅读取可能包含匹配行的 granule从而显著减少 I/O 并提高查询性能。在 mapKeys(labels) 和 mapValues(labels) 上建立的二级索引进一步完善了这一机制。这些倒排索引使得 ClickHouse 即使在过滤那些未具象化为专用列的标签时也能对 granule 进行剪枝从而有助于为高度动态的维度保留灵活性。压缩 - 即使标签存储在 Map 结构中ClickHouse 仍能为可观测性工作负载实现出色的数据压缩。数据根据主机 (host)、状态 (status) 和应用程序 (application) 等常用过滤维度进行物理排序随后按时间排序。这种排序方式自然地将拥有相似标签集、指标值和重复字符串的行聚合在一起形成了强大的数据局部性压缩算法可以高效地利用这一点。将频繁查询的标签物化为独立列能进一步提升压缩效果。由于具有相同值的行在磁盘上连续存储这使得字典编码 (dictionary encoding) 和压缩编解码器 (compression codecs) 的效率显著提高。已物化的指标列例如上述示例中的 response_time仍然保持完全的列式存储并使用特定类型编解码器和 ZSTD 等通用压缩算法进行独立压缩。数值列还可以额外受益于 Delta 等编解码器(https://clickhouse.com/blog/optimize-clickhouse-codecs-compression-schema)或 Gorilla(https://www.vldb.org/pvldb/vol8/p1816-teller.pdf)。Map 结构本身也具备高效的压缩能力。标签键在行之间重复性极高同时许多标签值在实际工作负载中表现出强大的时间局部性 (temporal locality)——在主键 (primary key) 中使用物化的 Map 标签有助于增强这种局部性。结合分片 Map 序列化 (sharded map serialization)ClickHouse 即使面对高度动态的标签集也能保持强大的压缩特性。减少 I/O - 查询只需读取满足其需求的 Granule、列和 Map 桶。频繁用于过滤的标签和指标例如 host、response_time 和 status被物化到专用列中这使得许多常见查询能够完全避免访问标签 Map。由于这些物化列是排序键 (sorting key) 的一部分ClickHouse 可以在读取指标数据之前高效地剪枝 (prune) Granule。对于仅存储在分片 Map 中的标签ClickHouse 只读取包含请求键的桶而非整个标签结构。相较于早期的 Map 实现这显著减少了 I/O 开销同时保留了动态标签的灵活性。该 Schema 还为 mapKeys(labels) 和 mapValues(labels) 定义了倒排索引(https://clickhouse.com/docs/engines/table-engines/mergetree-family/textindexes)。这些索引允许 ClickHouse 在查询根据非物化标签进行过滤时进一步剪枝 Granule从而避免读取那些不包含所需标签名称或值的 Granule。这使得即使对于 container_id 或 pod_id 等高度动态的维度也能实现高效过滤而无需为每个标签都创建独立的列。由于指标列在磁盘上独立且连续存储ClickHouse 还能高效地应用向量化查询执行 (vectorized query execution)将大批量值连续读入内存从而实现高分析吞吐量。在查询时文本索引采用分层查找机制通过稀疏索引、字典块和倒排列表无需扫描整个数据集即可高效找到匹配的行。欲了解更多详情请参阅我们的专题文章(https://clickhouse.com/blog/clickhouse-full-text-search-object-storage)。布隆过滤器索引(https://clickhouse.com/docs/optimize/skipping-indexes#bloom-filter-types)也可用于此目的并且对于许多工作负载而言可能已足够。实际上最佳选择取决于标签的分布、查询模式以及所应用过滤器的选择性。分析型聚合 - 在此模型中指标以数值列的形式表示例如response_time。聚合操作因此变得简单高效。例如如果我们要计算avg(response_time)ClickHouse只需读取符合查询条件的行中的response_time列。得益于稀疏索引和辅助过滤器ClickHouse引擎能够识别出满足过滤条件的特定粒度范围并仅从磁盘读取这些范围。这些范围可以在集群中的所有CPU核心和服务器上并行处理从而在存储层实现有效的谓词下推。因此聚合性能不再像基于时序的模型那样直接与基数挂钩。它与符合查询条件的行数更为密切。对于匹配的行只需读取相关的指标列。在高基数列上施加过滤器通常是有益的因为它们有助于减少需要扫描的行数。相反没有过滤器或在低基数列上施加过滤器的查询通常需要读取更多数据这将需要并行读取才能保证性能。示例考虑之前的metrics表。在我们的示例中该表存储了应用程序的50亿个响应时间及大小数据。这些数据中的某些列自然具有高基数在任何传统时序数据库如Prometheus中您都会避免将它们用作标签。SELECT count() FROM events ┌────count()─┐ │ 5339783200 │ -- 5.34 billion └────────────┘ 1 row in set. Elapsed: 0.004 sec.下文展示了每列的基数以及如果采用时序模型来表示这些数据时总的基数情况。SELECT uniq(host), uniq(application), uniq(labels[request_path]), uniq(labels[remote_addr]), uniq(labels[remote_user]), uniq(labels[request_type]), uniq(labels[request_protocol]), uniq(status), uniq(labels[domain_referer]), uniq(labels[browser]), uniq(labels[device]) FROM events FORMAT Vertical Row 1: ────── uniq(host): 100 uniq(application): 1000 uniq(arrayEl⋯est_path)): 881432 uniq(arrayEl⋯ote_addr)): 258115 uniq(arrayEl⋯ote_user)): 995890 uniq(arrayEl⋯est_type)): 5 uniq(arrayEl⋯protocol)): 7 uniq(status): 15 uniq(arrayEl⋯_referer)): 369 uniq(arrayEl⋯browser)): 9 uniq(arrayEl⋯ device)): 11 SELECT uniq(host, application, labels[request_path], labels[remote_addr], labels[remote_user], labels[request_type], labels[request_protocol], status, labels[domain_referer], labels[browser], labels[device]) AS total_time_series FROM events ┌─total_time_series─┐ │ 5239274309 │ -- 5.24 billion └───────────────────┘显然这里的时序数量非常庞大几乎与行数相同达到52亿。行与时间序列是不同的概念。在不增加独立时间序列数量的前提下你可以为现有序列添加更多数据点从而增加行数。在大多数数据集中总行数将远远超过时间序列的数量。在这个例子中每行甚至存储了响应时间、大小两个指标因此行、样本和序列之间的关系很少是一一对应的。现在我们来看以下查询该查询在一台32核机器上以毫秒级速度运行。它计算了上述表中响应时间的平均值按1分钟间隔进行分组按状态进行分桶并限定为特定的某一天SELECT toStartOfMinute(time) AS minute, labels[status] as status, avg(response_time) FROM metrics WHERE (time 2025-02-23 08:00:00) AND (time 2025-02-23 12:00:00) GROUP BY minute, status ORDER BY minute ASC ┌──────────────minute─┬─status─┬─avg(response_time)─┐ │ 2025-02-23 08:00:00 │ 403 │ 5065.290909090909 │ │ 2025-02-23 08:00:00 │ 500 │ 5327.543933054393 │ │ 2025-02-23 08:00:00 │ 302 │ 5119.796687088722 │ .. 3084 rows in set. Elapsed: 0.122 sec. Processed 49.21 million rows, 340.04 MB (404.24 million rows/s., 2.79 GB/s.) Peak memory usage: 726.64 MiB.这是一个在 Prometheus 中执行会面临挑战的查询示例。你需要访问许多独立的序列才能完成此计算。请注意此查询计算的是事件型数据上的简单平均值并按固定的一分钟时间窗口进行分组。它在语义上与之前的 avg_over_time(...5m) 示例并不完全相同。尽管结果在图表上可能看起来相似但底层的计算模型是不同的——详见When Prometheus still makes sense(#when-prometheus-still-makes-sense)。将查询范围缩小到更少的“序列”会减少需要读取的数据量。例如SELECT toStartOfMinute(time) AS minute, avg(response_time) FROM events WHERE application 603 AND host 3 AND status 200 GROUP BY minute ORDER BY minute ASC ┌──────────────minute─┬─avg(response_time)─┐ │ 2025-01-24 00:00:00 │ 3680.4615384615386 │ │ 2025-01-24 00:01:00 │ 5515.153846153846 │ .. 5 rows in set. Elapsed: 0.078 sec. Processed 53.40 million rows, 479.93 MB (681.97 million rows/s., 6.13 GB/s.) Peak memory usage: 613.54 MiB.在后一种情况下得益于倒排索引我们读取的数据量显著减少这从查询执行时间中得到了体现。要将查询范围缩小到特定的某个序列你只需为所有列添加过滤器即可。当指标的查询频率不足以支持将其物化时它们也可以保留在 labels 映射中并在查询时进行动态访问。尽管我们通常建议对 response_time 等常用的聚合指标进行物化但可观测性工作负载中常包含许多低频测量值这些值仅偶尔会被查询。在这种情况下直接从映射中访问指标是完全合理的做法。例如假设事件还包含一个表示响应负载大小的 size 属性。我们可能只偶尔需要计算随时间变化的整体流量。此时与其物化另一个专用列我们可以直接从映射中访问并转换该值SELECT toStartOfMinute(time) AS minute, sum(toDecimal32(labels[size], 1)) AS total_traffic FROM events WHERE application 603 GROUP BY minute ORDER BY minute ASC ┌──────────────minute─┬─total_traffic─┐ │ 2025-01-24 00:00:00 │ 89167 │ │ 2025-01-24 00:01:00 │ 352724 │ │ 2025-01-24 00:02:00 │ 284684 │ … 4 rows in set. Elapsed: 1.138 sec. Processed 53.31 million rows, 13.60 GB (46.84 million rows/s., 11.95 GB/s.)这种灵活性是宽事件模型wide events model的优势之一。频繁查询的维度和指标可以被物化为优化列而那些不常使用的属性则无需严格的预设模式定义也能动态访问。当 ClickHouse 处理高基数数据仍有成本时ClickHouse 并不能完全消除基数cardinality带来的成本。部分成本会转移到读取阶段尤其是在执行大型 GROUP BY 操作时。假设我们希望按 request_path 进行分组。此时我们正在读取时按一个高基数列进行分组并访问相应的映射结构。SELECT toStartOfMinute(time) AS minute, labels[request_path] as request_path, avg(response_time) FROM events WHERE toDate(time) 2025-01-24 GROUP BY minute, request_path ORDER BY minute ASC -- well omit the results :) 0 rows in set. Elapsed: 9.433 sec. Processed 153.23 million rows, 63.85 GB (16.24 million rows/s., 6.77 GB/s.) Peak memory usage: 22.82 GiB.这种成本在内存使用和查询耗时上均有所体现。对超高基数列进行高性能分组确实会产生内存开销因为聚合状态aggregation states必须在内存中构建。然而ClickHouse 支持在超出阈值时将数据溢写到磁盘(https://clickhouse.com/docs/sql-reference/statements/select/group-by#group-by-in-external-memory)这使得这些工作负载变得可管理而非灾难性。通过调整参数以限制内存消耗来换取性能即使在高基数场景下它们依然能展现出卓越的性能SELECT toStartOfMinute(time) AS minute, labels[request_path] as request_path, avg(response_time) FROM events WHERE toDate(time) 2025-01-24 GROUP BY minute, request_path ORDER BY minute ASC -- Start external aggregation around 10 GB SETTINGS max_bytes_before_external_group_by 10000000000 0 rows in set. Elapsed: 13.930 sec. Processed 153.23 million rows, 63.85 GB (11.00 million rows/s., 4.58 GB/s.) Peak memory usage: 9.57 GiB.然而更实际的问题是你如何在可观测性仪表盘中可视化近百万条不同的数据行如果你的工作负载确实需要渲染数百万个独立的数据序列ClickHouse 可能不是最理想的工具。但如果你需要对这数百万个序列进行聚合计算例如统计总数或平均值ClickHouse 则表现得非常出色。ClickHouse 的适用场景ClickHouse 特别适用于事件型可观测性数据例如经由指标metrics增强的日志logs和追踪traces在这类数据中高基数high cardinality是其固有特性而非偶然发生。它还擅长长期趋势分析、SLIs、KPIs 以及需要长时间范围分析的更广泛的业务指标。只要指标是从事件中派生而来并且需要灵活的基于时间或维度的聚合时ClickHouse 的列式模型columnar model就能充分发挥其优势。重要的是ClickHouse 不会受到短生命周期、临时性维度如 container_id、pod_id 或现代环境中常见的其他动态生成标识符的结构性影响。对 ClickHouse 而言这些都仅仅是普通的列值。这带来的实际影响非常显著。您可以将完整保真度的指标数据存储在 ClickHouse 中而无需仅仅为了控制基数cardinality而进行激进采样、预聚合或剥离标签。您无需为了避免数据爆炸而设计数据模型schema而是可以自然地建模数据并让查询模式决定读取哪些内容。Prometheus 何时仍然有意义Prometheus 在许多场景下仍然非常有用尤其因为它拥有广泛的生态系统支持、成熟的运维工具以及深度集成的告警能力。Prometheus 围绕着一种指标优先模型构建在该模型中样本被抓取并存储为独立的时间序列。它支持多种指标类型计数器counters、仪表盘gauges、直方图histograms和摘要summaries。每种类型都内含了数据应如何解释的假设。计数器用于处理速率和重置。直方图和摘要支持延迟和分布分析。这些语义深度嵌入在 PromQL 及其周边生态系统中。在 ClickHouse 的事件风格模型中指标通常作为事件的数值列存储并在查询时进行聚合。实际上这些值通常更像是仪表盘gauges而非严格的 Prometheus 风格计数器。与 Prometheus 类似ClickHouse 本质上存储数字直方图除外其语义来自查询逻辑而非存储层本身。相反Prometheus 假设的是定期采样的序列并为计数器、速率、直方图和范围向量range vectors提供一流的语义支持。而 ClickHouse 则处理带时间戳的事件这使其更适合对高基数可观测性数据进行灵活的事件风格聚合而非严格的时间序列语义。对于高度定向的单序列查询和绘图Prometheus 通常表现出色。它的倒排索引inverted index能快速解决标签交集并且读取单个压缩序列的效率很高。ClickHouse 在排序键ordering key与过滤器对齐时也能达到类似的性能但在某些情况下可能会扫描更多数据。这种差异很少是决定性的但这确实是 Prometheus 非常擅长的场景。Prometheus 在基数 (cardinality) 受控且可预测的情况下表现出色。对于中等数量的长期序列、实时告警和传统监控工作流Prometheus 效率极高。它能与 LGTM 等技术栈无缝集成其中 Prometheus 及其分布式对应产品如 Mimir是一等公民。虽然分布式系统解决了可扩展性问题但它们保留了底层的基于序列的模型这意味着基数管理仍需深思熟虑。ClickHouse 用于 Prometheus 指标ClickHouse 的开源可观测性栈 ClickStack 支持 Prometheus 风格的指标Open Telemetry metrics但不应将其视为 Prometheus 或现有成熟的 Prometheus 兼容系统的即插即用替代品。其底层的存储和查询模型截然不同ClickStack 偏爱事件导向的方法而非严格的时间序列模型。因此目前在 ClickStack 中查询 Prometheus 风格的指标是通过更高层的查询构建器和抽象实现的它们的能力比原生 PromQL 更有限。这主要是由于完整复现 Prometheus 语义的复杂性尤其是在处理计数器 (counters)、直方图 (histograms)、范围向量 (range vectors) 和更广泛的 PromQL 执行模型方面。如果您需要成熟、久经考验的 PromQL 支持并将其深度集成到仪表盘、告警和操作工作流中Prometheus 依然是显而易见的选择。话虽如此ClickHouse 对 PromQL 的支持仍在不断发展并取得了近期令人鼓舞的进展(https://github.com/ClickHouse/ClickHouse/issues/57545#issuecomment-4246791048)。手动将复杂的 PromQL 查询转换为 SQL 也并非易事特别是涉及直方图 (histograms) 和范围向量函数 (range-vector functions) 时——但在大型语言模型 (LLMs) 的帮助下仍有可能实现。对于中等基数指标、以 PromQL 为核心的告警以及有针对性的序列查询Prometheus 仍然是更合适的选择。结论ClickHouse 处理高基数 (cardinality) 并非没有代价但与传统的时间序列系统相比其成本体现在截然不同的方面。ClickHouse 通过将模型从面向序列转变为存储在列式存储表中的宽事件 (wide events)从而规避了在 Prometheus 等系统中因高基数而导致的每序列写入放大 (write amplification)、内存开销 (memory overhead) 和运维脆弱性 (operational fragility) 等难题。相应地其权衡主要体现在查询时聚合 (query-time aggregation) 和数据建模决策上而这些正是 ClickHouse 的列式执行模型 (columnar execution model)、压缩 (compression)、剪枝 (pruning) 和并行化 (parallelization) 等特性表现尤为突出的领域。征稿启示面向社区长期正文文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出图文并茂。质量合格的文章将会发布优秀者也有机会推荐到 ClickHouse 官网。关于我们ClickHouse 是全球速度最快资源利用最高效的在线分析列式数据库管理系统。现在ClickHouse可以作为一个安全可扩展的无服务器应用在云中提供服务。通过云服务ClickHouse使得任何人都能轻松获取高效的实时分析处理能力。