简介
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 * queries 之外。这样可以保持一个不变性:查询结果始终可以重新插入该表。物化历史数据
system.mutations 表监控变更进度,例如:
is_done = 1。
优化 2:添加跳过索引
- 对高基数字符串的过滤,例如 TraceId、会话标识符、属性键或属性值
- 借助
*AttributeItems列上的文本索引加速 Map 子键过滤 - 对数值范围的过滤,例如 span 耗时
text(tokenizer = 'array') 索引,而不是布隆过滤器,并在 lower(Body) 上额外添加了一个用于全文搜索的 text(tokenizer = 'splitByNonAlpha') 索引。完整 DDL 请参见“ClickStack 使用的表和 schema”。
布隆过滤器
PodName:"checkout-675775c4cc-f2p9c" 之类查询时的数据读取量。
当值的分布使某个给定值只出现在相对较少的 parts 中时,布隆过滤器的效果最佳。这种情况在可观测性工作负载中很常见,因为 pod 名称、trace ID 或 session 标识符等元数据通常与时间相关,因此会按表的排序键聚集。
与所有跳过索引一样,布隆过滤器应有选择地添加,并根据真实的查询模式进行验证,以确保它们能带来可衡量的收益——请参阅 “评估跳过索引的有效性。“
文本索引
WHERE 条件进行判断。文本索引属于倒排索引,会将标记映射到分片内的精确偏移量。由于它们判断的是偏移量而非粒度,且不会产生误报,因此通常无需加载底层列就能满足 WHERE 条件。这种优化称为直接读取。由于加载数据通常是查询耗时的最大来源,直接读取可以显著降低查询延迟。
此外,文本索引本身也可以被查询,从而为 ClickStack 中的自动补全和其他内部信息功能提供支持。
以下两种分词器涵盖了大多数 ClickStack 使用场景:
用于 Map 和数组列的 Array 分词器
array 分词器为 mapKeys 和 materialized 项数组建立索引:
用于日志正文的 splitByNonAlpha
Body 列执行全文搜索时,splitByNonAlpha 文本
索引会更有效。ClickStack 在 lower(Body) 上定义了该索引,因此不区分大小写的
Lucene 搜索可以利用它:
lower(Body) 上检测到 text(tokenizer = 'splitByNonAlpha') 索引时,会将 error 或
"connection refused" 这类未显式指定列的 Lucene 查询改写为 hasAllTokens(lower(Body), lower(...)),从而让
索引无需读取完整的 Body 列即可完成过滤。对于大多数
可观测性日志工作负载而言,这是筛选性能提升最显著的一项优化。
文本索引 vs
tokenbf_v1较旧的 tokenbf_v1 索引类型 (默认链路追踪 schema 中的
lower(SpanName) 仍在使用) 在功能上与之类似,但在 ClickHouse 26.2 及以上版本中已弃用。
新的文本搜索索引应使用 text(tokenizer = ...)。默认日志 schema 中的文本索引
otel_logs schema 预置了上文讨论的所有文本索引:在 TraceId、各个 mapKeys(...) 和 *AttributeItems 数组上使用 text(tokenizer = 'array'),并在 lower(Body) 上使用 text(tokenizer = 'splitByNonAlpha') 以支持全文搜索。标准 DDL 请参见“ClickStack 使用的表和 schema”;下方也列出了相同的 schema。
Min-max 索引
SpanAttributes 中查询 Kafka 偏移量:
物化跳过索引
物化跳过索引物化跳过索引通常开销较小,执行起来也比较安全,尤其是对于 minmax 索引。对于大型数据集上的布隆过滤器索引,用户可能更倾向于按分区进行物化,以便更好地控制资源使用,例如:
is_done = 1。
完成后,确认索引数据已创建:
0.01 提高到 0.05,会生成一个更小且评估速度更快的索引,但代价是剪枝效果会减弱。虽然被跳过的数据粒度可能会更少,但由于索引评估更快,整体查询延迟反而可能有所改善。
因此,调优 布隆过滤器 参数是一项依赖工作负载的优化,应结合真实的查询模式和接近生产环境的数据量进行验证。
有关跳过索引的更多信息,请参阅指南”了解 ClickHouse 数据跳过索引”
评估跳过索引的有效性
EXPLAIN indexes = 1,它会显示在查询计划的每个阶段中,有多少 parts 和 粒度 被排除。在大多数情况下,你会希望在 Skip 阶段看到粒度数量大幅减少,理想情况下,此时主键已经缩小了搜索范围。跳过索引是在分区剪枝和主键剪枝之后评估的,因此其影响最好相对于剩余的 parts 和粒度来衡量。
EXPLAIN 可以确认是否发生了剪枝,但并不能保证一定会带来净性能提升。评估跳过索引本身是有成本的,尤其是在索引较大时。务必在添加并物化索引前后都对查询进行基准测试,以确认真实性能提升。
例如,来看默认 Traces schema 中包含的、用于 TraceId 的默认布隆过滤器跳过索引:
EXPLAIN indexes = 1 查看它对选择性查询的优化效果:
FORMAT Null 以避免结果序列化的开销,并禁用查询条件缓存以确保多次运行具有可重复性:
use_query_condition_cache 可确保结果不受已缓存过滤决策的影响,而将 use_skip_indexes = 0 可提供一个干净的对比基线。如果剪枝有效且索引评估成本较低,那么带索引的查询应该会明显更快,就像上面的示例所示。
何时添加跳过索引
IN 过滤,还额外支持基于标记的谓词 (hasToken、hasAllTokens、has) ,这些谓词可用于全文搜索和 Map 直接读取优化。对于尚不支持文本索引的旧集群,布隆过滤器仍然是稳妥的选择。
布隆过滤器对高基数字符串列最为有效,即每个值的出现频率相对较低,这意味着大多数分片和粒度中都不包含要查找的值。经验上,当某一列至少有 10,000 个不同值时,布隆过滤器通常比较值得尝试;而当不同值达到 100,000+ 时,往往效果最佳。如果匹配值集中在少量连续的分片中,它们也会更有效,而这种情况通常发生在该列与排序键存在关联时。同样,这里的实际效果可能因场景而异——没有什么比真实环境测试更可靠。
优化 3. Map 直接读取
LogAttributes['k8s.pod.name'] = 'checkout',ClickHouse 必须从磁盘读取整个 LogAttributes Map 列,并解包每一行来评估该过滤条件。将经常查询的属性物化
可以解决你预先已知的那些键的问题,但对于用户临时按需过滤的任意属性,这种方法无法扩展。
即使某个 schema 在 mapKeys 和 mapValues 上建有索引,这些索引也只能告诉你某一行是否包含某个键,以及是否包含某个值,却无法判断该键和值是否属于同一个条目。换句话说,mapKeys 可以回答 mapContainsKey(ResourceAttributes, 'foo'),mapValues 可以回答 mapContainsValue(ResourceAttributes, 'bar'),但二者都无法回答 ResourceAttributes['foo'] = 'bar'。
通过将键和值拼接到一个 Array(String) 列中,Map 直接读取优化可以让 ResourceAttributes['foo'] = 'bar' 在无需加载底层 Map 的情况下得到结果。Map 通常很大,而且会随着数据量增加而变大。再结合应用层的查询重写,针对任意 Map 子键的等值过滤都会变成一次由该索引支持的 has(...) 调用,查询时无需对 Map 进行反序列化。此外,唯一需要付出的存储成本是文本索引,因为底层列是 ALIAS 列,不会被存储。
此优化是自动启用的。ClickStack 在默认的日志和 trace 表中提供了所需的列和索引,并且当连接的 ClickHouse server 支持底层基本类型时,会在运行时重写 Map 下标过滤条件。如果你的 schema 不包含这些列,或者除了默认列之外,你还想加速其他 Map 列,请继续阅读以启用它们。
Schema
Array(String) ALIAS 列,通过 = 将每个键和值连接起来:
ALIAS 形式意味着该数组不会在磁盘上占用任何额外字节。ClickHouse 会在
查询时以及构建索引时计算它。ALIAS 列上的 text(tokenizer = 'array') 跳过索引
会为每个 key=value 对存储一个标记,ClickHouse 利用它在不访问源 Map 的情况下跳过相应粒度:
查询重写
LogAttributeItems 上的文本索引,剪除所有不包含
key=value 标记的行,并且对于不匹配的行,完全不会反序列化源 LogAttributes Map。对于高基数的
可观测性工作负载,与 Map 下标访问相比,这通常可将 I/O 降低一个数量级。
这种重写会自动进行——引用 LogAttributes['key'] 的已保存查询、仪表盘和告警
无需任何更改即可获得性能提升。
ClickHouse 版本要求
SELECT version(),按 connection 缓存) ,并且只有在
server 版本达到或高于该阈值时才会生成重写后的形式。较旧的
server 会自动回退到原始的 Map 下标形式。
为什么使用 ALIAS,而不是 MATERIALIZEDitems 数组只是对已存储在 Map 列中数据的一层视图。
如果将它存储两次——一次在 Map 中,一次在数组中——会使写入 IO
翻倍,却无法支持新的查询模式。
ALIAS 列上的文本索引
会在写入时基于同一份源数据构建,因此这项优化只会为磁盘增加
索引占用。优化 4. 修改主键
术语说明在本文档中,“排序键”和“primary key”这两个术语可互换使用。严格来说,它们在 ClickHouse 中并不相同,但对 ClickStack 而言,它们通常都是指表
ORDER BY 子句中指定的同一组列。有关详情,请参阅 ClickHouse 文档中关于如何选择与 sorting key 不同的主键的说明。- 日志 (
otel_logs) -(toStartOfFiveMinutes(Timestamp), ServiceName, 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
更新 ClickStack UI 以从 merge 表读取数据
将 ClickStack UI 配置为使用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 策略 (这也是推荐做法) ,那些使用旧主键且不再接收写入的表会随着数据过期而逐渐清空。应监控这些表,并在其中不再包含数据后定期清理。目前,此清理过程仍需手动完成。
利用块列加速行查找
(_block_number, _block_offset) 对,用于在一个
分片内唯一标识该行。当你在 ClickStack UI 中点击某条日志行以打开详情面板时,ClickStack
会发起一次后续查询来拉取这一行。如果没有块列,该
行的 WHERE 子句必须包含足够多的列——通常是主键
再加上 Body 和 SeverityText——才能唯一定位这一行。有了块列后,
主键加上 _block_number 再加上 _block_offset 就足够了。像 Body 这样的大
列在查找时完全不需要读取,从而有效加快查询速度。
ClickStack 会从表的 CREATE 语句中识别这一设置,并在这两列都启用时
自动生成更精简的 WHERE 子句。无需修改任何
应用配置。
要在现有的日志或链路追踪表上启用这一优化:
ALTER 之后写入的数据。现有的分片仍会继续
使用原先的逐行查找方式,直到在合并过程中被重写。
优化 5. 利用 Materialized Views
优化 6:利用 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 挂接到主表上,把原始行写入一个具有不同排序键的辅助表中 (这是一种反规范化或路由模式) 。然后为该目标表创建数据源。示例见此处。