简介
ClickHouse 概念
在 ClickHouse 中,表会归属于某个数据库。默认使用
default 数据库——这可以在 OpenTelemetry Collector 中修改。
至少,你应该了解以下 ClickHouse 基础概念:
这些概念是 ClickHouse 性能的核心。它们决定了数据如何写入、如何在磁盘上组织,以及 ClickHouse 在查询时能多高效地跳过不必要的数据读取。本指南中的每一项优化——无论是物化列、跳过索引、主键、projections 还是 materialized view——都建立在这些核心机制之上。
建议你在开始任何调优之前先阅读以下 ClickHouse 文档:
- 在 ClickHouse 中创建表 - 关于表的简要介绍。
- Parts
- 分区
- Merges
- 主键/索引
- ClickHouse 如何存储数据:parts 和 granules - 更深入地介绍 ClickHouse 中数据如何组织和查询,并详细讲解 granules 和主键。
- MergeTree- 适合查阅命令和内部实现细节的高级 MergeTree 参考指南。
优化 1. 将频繁查询的属性物化
LogAttributes、ScopeAttributes 和 ResourceAttributes 中经常查询的属性,并通过物化列将它们提升为顶层列。
仅这一项优化,通常就足以支撑 ClickStack 部署达到每天数十 TB 的规模,因此在考虑更高级的调优技术之前,应先应用这一优化。
为什么要物化属性
Map(String, String) 列中。这样做虽然灵活,但查询 Map 子键会带来一个重要的性能问题。
查询 Map 列中的单个键时,ClickHouse 必须从磁盘读取整个 Map 列。如果 Map 中包含很多键,相比读取专用列,这会导致不必要的 IO,并使查询变慢。
将经常访问的属性物化,可在写入时提取其值并将其存储为独立列,从而避免这部分开销。
物化列:
- 在写入期间自动计算
- 不能在 INSERT 语句中显式设置
- 支持任何 ClickHouse 表达式
- 支持将 String 转换为更高效的数值或日期类型
- 支持使用跳过索引和主键
- 通过避免读取整个 Map 来减少磁盘读取
ClickStack 会自动检测从 Map 中提取出的物化列,并在查询执行时透明地使用它们,即使用户仍然查询原始属性路径也是如此。
示例
ResourceAttributes 中:
ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c":
这会生成一个类似于以下内容的 SQL 谓词:
ResourceAttributes 列——如果该 Map 包含很多键,这一列可能会非常大。
如果这个属性经常被查询,就应将其物化为顶层列。
要在写入时提取 pod (容器组) 名称,请添加一个物化列:
PodName 中。
现在,用户可以使用 Lucene 语法高效查询 pod (容器组) 名称,例如 PodName:"checkout-675775c4cc-f2p9c"
对于新写入的数据,这样可以完全避免访问 map,并显著减少 I/O。
不过,即使用户继续查询原始属性路径,例如 ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c",ClickStack 也会在内部自动将查询重写为使用物化后的 PodName 列,即使用以下谓词:
默认情况下,物化列不会包含在
SELECT * 查询 中。这样可以保持一个不变量:查询结果始终可以重新插入到表中。物化历史数据
system.mutations 表监控变更进度,例如:
is_done = 1。
优化 2:添加跳过索引
- 对高基数字符串的过滤,例如 TraceId、会话标识符、属性键或属性值
- 对数值范围的过滤,例如 span 耗时
布隆过滤器
PodName:"checkout-675775c4cc-f2p9c" 之类查询时的数据读取量。
当值的分布使某个给定值只出现在相对较少的 parts 中时,布隆过滤器的效果最佳。这种情况在可观测性工作负载中很常见,因为 pod 名称、trace ID 或 session 标识符等元数据通常与时间相关,因此会按表的排序键聚集。
与所有跳过索引一样,布隆过滤器应有选择地添加,并根据真实的查询模式进行验证,以确保它们能带来可衡量的收益——请参阅 “评估跳过索引的有效性。“
Min-max 索引
SpanAttributes 中查询 Kafka 偏移量:
物化跳过索引
物化跳过索引物化跳过索引通常开销较小,执行起来也比较安全,尤其是对于 minmax 索引。对于大型数据集上的布隆过滤器索引,用户可能更倾向于按分区进行物化,以便更好地控制资源使用,例如:
is_done = 1。
完成后,确认索引数据已创建:
0.01 提高到 0.05,会生成一个更小且评估速度更快的索引,但代价是剪枝效果会减弱。虽然被跳过的数据粒度可能会更少,但由于索引评估更快,整体查询延迟反而可能有所改善。
因此,调优 Bloom filter 参数是一项依赖工作负载的优化,应结合真实的查询模式和接近生产环境的数据量进行验证。
有关跳过索引的更多信息,请参阅指南”了解 ClickHouse 数据跳过索引”
评估跳过索引的有效性
EXPLAIN indexes = 1,它会显示在查询计划的每个阶段中,有多少 parts 和 粒度 被排除。在大多数情况下,你会希望在 Skip 阶段看到粒度数量大幅减少,理想情况下,此时主键已经缩小了搜索范围。跳过索引是在分区剪枝和主键剪枝之后评估的,因此其影响最好相对于剩余的 parts 和粒度来衡量。
EXPLAIN 可以确认是否发生了剪枝,但并不能保证一定会带来净性能提升。评估跳过索引本身是有成本的,尤其是在索引较大时。务必在添加并物化索引前后都对查询进行基准测试,以确认真实性能提升。
例如,来看默认 Traces schema 中包含的、用于 TraceId 的默认布隆过滤器跳过索引:
EXPLAIN indexes = 1 查看它对选择性查询的优化效果:
FORMAT Null 以避免结果序列化的开销,并禁用查询条件缓存以确保多次运行具有可重复性:
use_query_condition_cache 可确保结果不受已缓存过滤决策的影响,而将 use_skip_indexes = 0 可提供一个干净的对比基线。如果剪枝有效且索引评估成本较低,那么带索引的查询应该会明显更快,就像上面的示例所示。
何时添加跳过索引
优化 3. 修改主键
术语说明在本文档中,“排序键”和“primary key”这两个术语可互换使用。严格来说,它们在 ClickHouse 中并不相同,但对 ClickStack 而言,它们通常都是指表
ORDER BY 子句中指定的同一组列。有关详情,请参阅 ClickHouse 文档中关于如何选择与 sorting key 不同的主键的说明。- 日志 (
otel_logs) -(ServiceName, TimestampTime, Timestamp) - 链路追踪 (‘otel_traces) -
(ServiceName, SpanName, toDateTime(Timestamp))
选择主键
修改默认主键默认主键在大多数情况下已经足够。应谨慎修改,并且只应在充分理解查询模式的前提下进行。修改主键可能会降低其他工作流的性能,因此务必进行测试。
- 选择与你常用过滤条件和访问模式相符的列。如果你通常通过按某个特定列 (例如 pod 名称) 过滤来开始可观测性调查,那么这个列会频繁出现在
WHERE子句中。与使用频率较低的列相比,应优先将这类列纳入键中。 - 优先选择那些在过滤时能够排除大部分行的列,从而减少需要读取的数据量。服务名称和状态码通常都是不错的候选项——对于后者,前提是你过滤的值确实能排除大多数行。例如,在大多数系统中,过滤 200 状态码通常会匹配大多数行,而 500 错误只对应较小的一个子集。
- 优先选择那些很可能与表中其他列高度关联的列。这有助于让这些值也连续存储,从而提升压缩效果。
- 如果某些列包含在排序键中,那么对这些列执行
GROUP BY(图表聚合) 和ORDER BY(排序) 操作时,内存使用通常会更高效。
更改主键
SeverityText 排在 ServiceName 之前。
1
创建新表
排序键与主键注意,在上面的示例中,需要同时指定
PRIMARY KEY 和 ORDER BY。
在 ClickStack 中,这两者几乎总是相同的。
ORDER BY 控制数据的物理存储布局,而 PRIMARY KEY 定义稀疏索引。
在极少数超大规模工作负载中,它们可能不同,但对大多数用户来说,应保持两者一致。在大规模场景下,将现有数据回填到新表通常得不偿失。其计算资源和 IO 成本通常很高,不足以证明性能收益值得这样做。更好的做法是让旧数据通过生存时间 (TTL)自然过期,而让较新的数据受益于改进后的键。
SeverityText 作为主键第一列的同一个示例。在这种情况下,会为新数据创建一张表,同时保留旧表用于历史分析。
1
2
3
更新 HyperDX 以从 merge 表读取
将 HyperDX 配置为使用otel_logs_merge 作为日志数据源对应的表。此时,写入仍会进入使用原始主键的 otel_logs,而读取则通过 merge 表进行。对用户没有可见变化,也不会影响摄取。4
交换这些表
现在使用EXCHANGE 语句,以原子方式交换 otel_logs 和 otel_logs_23_01_2025 这两张表的名称。otel_logs 表。现有数据仍保留在 otel_logs_23_01_2025 中,并且仍可通过 merge 表访问。该后缀表示应用此更改的日期,也表示该表中包含的最新时间戳。该流程可以在不中断摄取、且对用户无可见影响的情况下完成主键变更。SeverityNumber 而不是 SeverityText 纳入主键,仍可采用同样的方法。只要还需要修改主键,下面的流程就可以重复使用。
1
2
交换表
现在使用EXCHANGE 语句以原子方式交换 otel_logs 和 otel_logs_30_01_2025 两个表的名称。otel_logs 表。旧数据仍保留在 otel_logs_30_01_2025 中,并且可通过 merge 表访问。冗余表如果已配置 TTL 策略 (这也是推荐做法) ,那些使用旧主键且不再接收写入的表会随着数据过期而逐渐清空。应监控这些表,并在其中不再包含数据后定期清理。目前,此清理过程仍需手动完成。
优化 4. 利用 Materialized Views
优化 5:利用 PROJECTION
ORDER BY 键不同,这使 ClickHouse 能够针对那些不符合原始排序方式的访问模式,更高效地剪枝数据。
materialized view 也可以通过显式将行写入一个具有不同排序键的独立目标表来实现类似效果。关键区别在于,PROJECTION 由 ClickHouse 自动且透明地维护,而 materialized view 是显式定义的表,必须由 ClickStack 有意识地注册和选择。
当查询针对基表时,ClickHouse 会评估基表布局以及所有可用 PROJECTION,对它们的主索引进行采样,并选择在读取最少粒度的前提下仍能返回正确结果的布局。这个决定由查询分析器自动作出。
因此,在 ClickStack 中,PROJECTION 最适合用于纯粹的数据重排序,即:
- 访问模式与默认主键存在根本差异
- 用单一排序键覆盖所有工作流并不现实
- 你希望 ClickHouse 透明地选择最优的物理布局
示例 PROJECTION
使用通配符在上面的示例 PROJECTION 中,使用了通配符 (
SELECT *) 。虽然只选择部分列可以减少写入开销,但也会限制该 PROJECTION 的适用范围,因为只有完全可由这些列满足的查询才能使用它。在 ClickStack 中,这通常会把 PROJECTION 的使用场景限制得非常狭窄。因此,一般建议使用通配符,以尽可能扩大其适用范围。物化 PROJECTION 可能耗时很长,并占用大量资源。由于可观测性数据通常会通过生存时间 (TTL) 过期,因此仅应在确有必要时才这样做。在大多数情况下,只让 PROJECTION 应用于新摄取的数据就已足够,这样它就能优化查询最频繁的时间范围,例如最近 24 小时。
SELECT *) 进行直接重排,且查询中的过滤条件与 PROJECTION 的 ORDER BY 高度匹配时,PROJECTION 通常最可靠。
对 TraceId 进行过滤 (尤其是等值过滤) 且包含时间范围的查询,会从上述 PROJECTION 中受益。例如:
TraceId 的查询,或者主要按 PROJECTION 排序键中非前导的其他维度进行过滤的查询,通常无法从 PROJECTION 中获益 (而且可能会改为通过基础布局读取) 。
PROJECTION 也可以存储聚合结果 (类似于 materialized views) 。在 ClickStack 中,通常不建议使用基于 PROJECTION 的聚合,因为是否选用这类聚合取决于 ClickHouse analyzer,而且其使用行为更难控制和判断。相较之下,更推荐使用 ClickStack 能在应用层显式注册并按预期选择的 materialized views。
成本与使用建议
- 插入开销:对于使用不同排序键的
SELECT *PROJECTION,本质上相当于将数据写入两次,这会增加写入 I/O,并且可能需要额外的 CPU 和磁盘吞吐量来维持摄取。 - 谨慎使用:投影更适合用于访问模式确实存在明显差异的场景,即第二种物理排序能够为大量查询带来显著的 剪枝 效果,例如两个团队以根本不同的方式查询同一数据集。
- 用基准测试验证:与其他调优一样,在添加并物化某个 PROJECTION 前后,都应比较真实的查询延迟和资源使用情况。
带有 _part_offset 的轻量级 PROJECTION
轻量级 PROJECTION 在 ClickStack 中处于 Beta 阶段不建议将基于
_part_offset 的轻量级 PROJECTION 用于 ClickStack 工作负载。尽管它们可以减少存储占用和写入 I/O,但也可能在查询时引入更多随机访问,而且其在可观测性规模下的生产环境表现仍在评估中。随着该功能逐渐成熟,以及我们获得更多运行数据,这一建议可能会发生变化。_part_offset 指针,而不是复制完整的行。这可以大幅降低存储开销,最近的改进还支持按粒度剪枝,使其行为更接近真正的二级索引。请参见:
替代方案
- 将 OpenTelemetry collector 配置为写入两个具有不同
ORDER BY键的表,并为每个表分别创建 ClickStack 数据源。 - 创建一个作为复制管道的 materialized view,即将 materialized view 挂接到主表上,把原始行写入一个具有不同排序键的辅助表中 (这是一种反规范化或路由模式) 。然后为该目标表创建数据源。示例见此处。