跳转到主要内容
我们建议用户始终为日志和链路追踪创建自己的 schema,原因如下:
  • 选择主键 - 默认 schema 使用的 ORDER BY 是针对特定访问模式优化的。你的访问模式很可能与其并不匹配。
  • 提取结构 - 你可能希望从现有列中提取新列,例如 Body 列。这可以通过物化列来实现 (更复杂的情况下可使用 materialized view) 。这需要修改 schema。
  • 优化 Map - 默认 schema 使用 Map 类型 来存储属性。这些列允许存储任意元数据。虽然这是一项必不可少的能力——因为事件中的元数据通常不会预先定义,因此无法以其他方式存储到像 ClickHouse 这样强类型的数据库中——但访问 map 中的键及其值,不如访问普通列高效。我们通过修改 schema 来解决这个问题,确保最常访问的 map 键成为顶层列——参见”使用 SQL 提取结构”。这需要修改 schema。
  • 简化 map 键访问 - 访问 map 中的键需要使用更繁琐的语法。你可以通过别名来缓解这一问题。参见”使用别名”以简化查询。
  • 二级索引 - 默认 schema 使用二级索引来加速对 Map 的访问并提升文本查询性能。通常并不需要这些索引,而且还会额外占用磁盘空间。它们可以使用,但应先经过测试,以确认确有必要。参见”二级索引 / 数据跳过索引”
  • 使用编解码器 - 如果你了解预期数据的特征,并且有证据表明这能提高压缩效果,那么你可能希望为列自定义编解码器。
下面我们将详细介绍上述每一种使用场景。 重要: 虽然我们鼓励用户扩展和修改自己的 schema 以获得最佳压缩率和查询性能,但在可能的情况下,仍应遵循 OTel 对核心列的 schema 命名。ClickHouse Grafana 插件会假定某些基础 OTel 列存在,以帮助构建查询,例如 Timestamp 和 SeverityText。日志和链路追踪所需的列分别记录在这里 [1][2]这里。你也可以选择更改这些列名,并在插件配置中覆盖默认值。
ClickStack 提供经过优化的默认 schemaClickStack 为日志、链路追踪和指标提供开箱即用的 schema,其中整合了最新的 ClickHouse 功能 (用于全文和 map 键搜索的文本索引、用于直接读取过滤的物化列和 ALIAS 数组、基于块号的行查找) ,并且已经过基准测试,可为日志和链路追踪工作负载提供强劲的开箱即用性能。你可以将它们作为自己设计 schema 时的参考起点。

使用 SQL 提取结构

无论是摄取结构化日志还是非结构化日志,用户通常都需要以下能力:
  • 从字符串 blob 中提取列。与在查询时使用字符串操作相比,直接查询这些列会更快。
  • 从 Map 中提取键。默认 schema 会将任意属性放入 Map 类型的列中。这种类型提供了无 schema 的能力,优势在于用户在定义日志和链路追踪时,无需预先为这些属性定义列——而在从 Kubernetes 收集日志并希望保留 pod (容器组) 标记以供后续搜索时,这往往是不现实的。访问 map 中的键及其值,比查询普通 ClickHouse 列更慢。因此,通常希望将 map 中的键提取到表的顶层列中。
请看以下查询: 假设我们希望使用结构化日志统计哪些 URL 路径接收了最多的 POST 请求。JSON blob 存储在 Body 列中,类型为 String。此外,如果用户在 collector 中启用了 json_parser,它也可能存储在 LogAttributes 列中,类型为 Map(String, String)
假设 LogAttributes 可用,用于统计站点中哪些 URL 路径收到最多 POST 请求的查询如下:
请注意,这里使用了 map 语法,例如 LogAttributes['request_path'],以及用于从 URL 中去除查询参数的 path 函数 如果用户尚未在 collector 中启用 JSON 解析,那么 LogAttributes 将为空,这意味着我们只能使用 JSON 函数 从 String Body 中提取列。
优先使用 ClickHouse 进行解析我们通常建议用户在 ClickHouse 中对结构化日志执行 JSON 解析。我们相信 ClickHouse 提供了速度最快的 JSON 解析实现。不过,我们也理解,你可能希望将日志发送到其他 source,而不希望这部分逻辑放在 SQL 中。
现在再来看一下非结构化日志的情况:
对于非结构化日志,类似的查询需要借助 extractAllGroupsVertical 函数和正则表达式来实现。
解析非结构化日志会增加查询的复杂度和成本 (请注意性能差异) ,因此我们建议用户尽可能始终使用结构化日志。
考虑使用字典上述查询可以优化为利用正则表达式字典。更多详情请参见使用字典
这两种用例都可以通过 ClickHouse 将上述查询逻辑移到写入时来实现。下面我们将介绍几种方法,并说明各自适用的场景。
使用 OTel 还是 ClickHouse 进行处理?你也可以按此处所述,使用 OTel collector 的处理器和操作器进行处理。在大多数情况下,你会发现 ClickHouse 在资源效率和速度方面都明显优于 collector 的处理器。使用 SQL 执行所有事件处理的主要缺点在于,解决方案会与 ClickHouse 绑定。例如,你可能希望从 OTel collector 将处理后的日志发送到其他目标端,例如 S3。

物化列

物化列提供了从其他列中提取结构化信息的最简单方式。这类列的值始终在写入时计算,且不能在 INSERT 查询中指定。
开销物化列会带来额外的存储开销,因为这些值会在写入时被提取并写入磁盘上的新列。
物化列支持任何 ClickHouse 表达式,并且可以利用各种分析函数来处理字符串 (包括正则和搜索) 以及处理 URL,执行类型转换从 JSON 中提取值数学运算 我们建议将物化列用于基础处理。它们尤其适合从 Map 中提取值、将其提升为顶层列,以及执行类型转换。在非常基础的 schema 中使用时,或与 materialized view 结合使用时,它们通常最有价值。请看下面这个日志 schema,其中 JSON 已由 collector 提取到 LogAttributes 列中:
使用 JSON 函数从 String Body 中提取数据的等效 schema 可在此处查看。 我们的三个物化列分别提取请求页面、请求类型以及引荐来源域名。它们会访问 map 中的键,并对其值应用函数。因此,后续查询会快得多:
默认情况下,物化列不会出现在 SELECT * 的返回结果中。这是为了保持这样一个不变性:SELECT * 的结果始终可以使用 INSERT 再次插入回该表。可通过设置 asterisk_include_materialized_columns=1 关闭此行为,也可以在 Grafana 中启用 (参见数据源配置中的 Additional Settings -> Custom Settings) 。

Materialized views

Materialized views 提供了对日志和链路追踪应用 SQL 过滤与转换的更强大方式。 Materialized Views 允许你将计算成本从查询时转移到写入时。ClickHouse materialized view 本质上只是一个 trigger:当数据块插入表中时,它会对这些块执行查询。该查询的结果会被插入到第二个“目标”表中。
实时更新ClickHouse 中的 materialized views 会在数据流入其所基于的表时实时更新,作用更像持续更新的索引。相比之下,在其他数据库中,materialized views 通常是某个查询的静态快照,必须刷新 (类似于 ClickHouse Refreshable Materialized Views) 。
与 materialized view 关联的查询理论上可以是任何查询,包括 aggregation,尽管 Joins 存在一些限制。对于日志和链路追踪所需的转换和过滤类 workload,你可以认为任何 SELECT statement 都是可行的。 你需要记住,这个查询只是一个 trigger,它会针对正在插入源表的行执行,并将结果发送到一个新表 (目标表) 。 为了确保数据不会被持久化两次 (即在源表和目标表中各存一份) ,我们可以将源表改为 Null table engine,同时保留原始 schema。我们的 OTel collectors 仍会继续向这张表发送数据。例如,对于日志,otel_logs 表会变成:
The Null table engine 是一种非常有效的优化方式——可以将其理解为 /dev/null。这个表不会存储任何数据,但任何附加的 materialized view 仍会在插入的行被丢弃前基于这些行执行。 请看下面的查询。它会把行转换为我们希望保留的格式:从 LogAttributes 中提取所有列 (这里假设这些列已由 collector 通过 json_parser operator 设置) ,并设置 SeverityTextSeverityNumber (依据一些简单条件以及这些列的定义) 。在这个示例中,我们也只选择明确会被填充的列——忽略 TraceIdSpanIdTraceFlags 等列。
我们还提取了上述 Body 列——以防后续新增了未被 SQL 提取的额外属性。该列在 ClickHouse 中应具有良好的压缩效果,且极少被访问,因此不会影响查询性能。最后,我们通过类型转换将 Timestamp 缩减为 DateTime (以节省存储空间——参见 “Optimizing Types”) 。
条件函数请注意,上文使用了条件函数来提取 SeverityTextSeverityNumber。它们对于编写复杂条件以及检查 Map 中是否存在已设置的值非常有用——这里我们只是简单假设 LogAttributes 中的所有键都存在。我们建议用户熟悉这些函数——除了处理空值的函数外,它们在日志解析中也非常好用!
我们需要一张表来接收这些结果。下面的目标表与上述查询相匹配:
此处选择的类型基于「优化类型」中讨论的优化方案。
请注意,我们已经对 schema 做了大幅调整。实际上,你很可能还有一些也需要保留的 trace 列,以及 ResourceAttributes 列 (其中通常包含 Kubernetes 元数据) 。Grafana 可以利用 trace 列提供日志与链路追踪之间的关联功能——参见”使用 Grafana”
下面,我们创建一个 materialized view otel_logs_mv,用于对 otel_logs 表执行上述查询,并将结果发送到 otel_logs_v2
如下图所示: 如果现在重启在”导出到 ClickHouse”中使用的 collector 配置,数据就会以所需格式出现在 otel_logs_v2 中。请注意,这里使用了带类型的 JSON 提取函数。
下面展示了一个等效的 Materialized view,它通过 JSON 函数从 Body 列中提取各列:

注意类型

上述 materialized view 依赖隐式类型转换,尤其是在使用 LogAttributes Map 的情况下。ClickHouse 通常会自动将提取出的值转换为目标表的类型,从而减少所需的语法。不过,我们建议用户始终通过以下方式测试视图:使用该视图的 SELECT 语句,并配合一个面向相同 schema 目标表的 INSERT INTO 语句。这样应能确认类型是否得到了正确处理。尤其要注意以下情况:
  • 如果某个 key 在 Map 中不存在,将返回空字符串。对于数值类型,你需要将其映射为合适的值。这可以通过条件函数实现,例如 if(LogAttributes['status'] = ", 200, LogAttributes['status']);如果可以接受默认值,也可以使用类型转换函数,例如 toUInt8OrDefault(LogAttributes['status'] )
  • 某些类型并不总是会被自动转换,例如,数值的字符串表示形式不会被转换为枚举值。
  • 如果未找到值,JSON 提取函数会返回该类型的默认值。请务必确认这些值是合理的!
避免使用 Nullable避免在 ClickHouse 中对可观测性数据使用 Nullable。在日志和链路追踪中,通常很少需要区分空值和 NULL。此功能会带来额外的存储开销,并对查询性能产生负面影响。更多详细信息请参见此处

选择主键 (排序键)

提取出所需的列后,就可以开始优化排序键/主键了。 可以套用一些简单的规则来帮助选择排序键。以下几点有时会彼此冲突,因此请按顺序权衡。通过这一过程,你可以确定多个候选键,通常 4 到 5 个就足够了:
  1. 选择符合常见过滤条件和访问模式的列。如果你通常通过按某个特定列 (例如 pod 名称) 过滤来开始可观测性调查,那么该列会经常出现在 WHERE 子句中。与使用频率较低的列相比,应优先将这类列纳入键中。
  2. 优先选择那些在过滤时能够排除总行数中很大一部分的列,从而减少需要读取的数据量。服务名称和状态码通常都是不错的候选项——不过对后者来说,仅当你过滤的值能排除大多数行时才成立。例如,在大多数系统中按 200 范围过滤会匹配大部分行,而 500 错误通常只对应较小的一个子集。
  3. 优先选择与表中其他列高度相关的列。这有助于确保这些值也连续存储,从而提升压缩效果。
  4. 对排序键中的列执行 GROUP BYORDER BY 操作时,内存利用率会更高。

确定了排序键的列子集后,还必须按特定顺序声明它们。这个顺序会显著影响查询中对排序键后续列的过滤效率,以及表数据文件的压缩率。一般来说,最好按基数升序排列这些键。但这需要结合这样一个事实来权衡:对排序键中越靠后的列进行过滤,效率会低于对 Tuple 中越靠前的列进行过滤。请结合你的访问模式,在这些因素之间做好平衡。最重要的是,要测试不同方案。若想进一步了解排序键及其优化方法,我们推荐阅读这篇文章
先确定结构我们建议在完成日志结构化之后再决定排序键。不要将 attribute Map 中的键或 JSON 提取表达式用作排序键。请确保排序键在表中是顶层列。

使用 Map

前面的示例展示了如何使用 map['key'] 这种 map 语法来访问 Map(String, String) 列中的值。除了使用 map 表示法访问嵌套键之外,还可以使用专门的 ClickHouse map functions 来过滤或选取这些列。 例如,下面的查询先使用 mapKeys function,再使用 groupArrayDistinctArray function (一种组合器) ,找出 LogAttributes 列中所有可用的唯一键。
避免使用点号不建议在 Map 列名中使用点号,此类用法后续可能会被弃用。请改用 _

使用别名

查询 Map 类型比查询普通列更慢——请参见”加速查询”。此外,它的语法也更复杂,编写起来可能比较繁琐。为了解决后一个问题,我们建议使用 Alias 列。 ALIAS 列是在查询时计算的,不会存储在表中。因此,无法向这种类型的列中 INSERT 值。借助别名,我们可以引用 Map 键并简化语法,将 Map 中的条目像普通列一样透明地暴露出来。请看下面的示例:
我们有几个物化列,以及一个用于访问 Map LogAttributesALIASRemoteAddr。现在,我们可以通过该列查询 LogAttributes['remote_addr'] 的值,从而简化查询,即
此外,使用 ALTER TABLE 命令添加 ALIAS 也很简单。这些列会立即可用,例如
默认排除别名列默认情况下,SELECT * 不会包含 ALIAS 列。可通过设置 asterisk_include_alias_columns=1 来禁用这一行为。

优化数据类型

ClickHouse 关于优化数据类型的通用最佳实践同样适用于此类 ClickHouse 使用场景。

使用编解码器

除了类型优化之外,在尝试优化 ClickHouse 可观测性 schema 的压缩时,你还可以遵循编解码器的一般最佳实践 一般而言,ZSTD 编解码器非常适用于日志和 trace 数据集。将压缩级别从默认值 1 调高,可能会提升压缩效果。不过,这一点仍需经过测试,因为更高的取值会在写入时带来更大的 CPU 开销。通常情况下,我们观察到调高该值带来的收益很有限。 此外,时间戳虽然能通过 delta 编码获得更好的压缩效果,但实践表明,如果该列被用作主键/排序键,可能会导致慢查询性能下降。我们建议用户评估压缩率与查询性能之间的权衡。

使用字典

字典是 ClickHouse 的一项核心特性,为来自各种内部和外部数据源的数据提供内存中的键值表示,并针对超低延迟查找查询进行了优化。 这在多种场景下都很实用,例如在不拖慢摄取过程的情况下实时富集已摄取的数据,以及整体提升查询性能,其中对 JOIN 的加速尤为明显。 虽然在可观测性场景中很少需要使用 JOIN,但字典在富集方面仍然很有用——无论是在写入时还是查询时。下面我们会分别给出这两种示例。
加速 JOIN如果你希望使用字典来加速 JOIN,可以在这里了解更多详情。

写入时 vs 查询时

字典可用于在查询时或写入时对数据集进行富集。这两种方式各有优缺点。总结如下:
  • 写入时 - 如果富集值基本不变,并且存在于可用于填充字典的外部数据源中,通常适合采用这种方式。在这种情况下,在写入时对行进行富集,可以避免查询时再到字典中查找。但代价是会影响写入性能,并带来额外的存储开销,因为富集后的值会作为列存储。
  • 查询时 - 如果字典中的值经常变化,则通常更适合在查询时查找。这样一来,如果映射值发生变化,就无需更新列 (以及重写数据) 。这种灵活性的代价是查询时的查找开销。如果很多行都需要查找,例如在过滤器子句中使用字典查找,这种查询时开销通常会比较明显。对于结果富集,也就是在 SELECT 中,这种开销通常并不明显。
我们建议用户先熟悉一下字典的基础知识。字典提供了一个内存中的查找表,可通过专用的函数从中获取值。 如需查看简单的富集示例,请参阅此处的字典指南。下面我们将重点介绍常见的可观测性富集任务。

使用 IP 字典

使用 IP 地址为日志和链路追踪添加经纬度信息进行地理富化,是一种常见的可观测性需求。我们可以使用 ip_trie 结构化字典来实现这一点。 我们使用由 DB-IP.com 提供的公开可用的 DB-IP 城市级数据集,其使用条款遵循 CC BY 4.0 许可证 readme 中可以看到,该数据的结构如下:
基于这一结构,我们先使用 url() 表函数快速查看一下数据:
为了简化操作,我们使用 URL() 表引擎,按我们的字段名创建一个 ClickHouse 表对象,并确认总行数:
由于 ip_trie 字典要求以 CIDR 表示法表示 IP 地址范围,因此我们需要转换 ip_range_startip_range_end 可以通过以下查询简洁地计算出每个范围对应的 CIDR:
上面的查询内容很多。感兴趣的话,可以阅读这篇很棒的解释。否则,只需知道上面的计算结果是为一个 IP 范围得出 CIDR。
对我们来说,只需要 IP 范围、国家代码和坐标,因此创建一个新表并插入 Geo IP 数据即可:
为了在 ClickHouse 中进行低延迟的 IP 查找,我们会使用字典在内存中存储 Geo IP 数据的键 -> 属性映射。ClickHouse 提供了 ip_trie 字典结构,可将网络前缀 (CIDR 块) 映射到坐标和国家/地区代码。下面的查询使用这种布局,并以上述表作为源来定义一个字典。
我们可以从字典中查询行,并确认这些数据可用于查找:
定期刷新ClickHouse 中的字典会根据底层表数据以及上文使用的 lifetime 子句定期刷新。要让我们的 Geo IP 字典反映 DB-IP 数据集中的最新变更,只需将 geoip_url 远程表中的数据重新插入 geoip 表,并应用相应的转换即可。
现在,我们已经将 Geo IP 数据加载到 ip_trie 字典中 (它的名字也恰好是 ip_trie) ,就可以用它来进行 IP 地理位置定位了。这可以通过如下方式使用 dictGet() 函数 来实现:
请注意这里的检索速度。这让我们能够对日志进行富集。在这种情况下,我们选择在查询时进行富集 回到最初的日志数据集,我们可以利用上述方法按国家/地区聚合日志。以下内容假定我们使用的是前面 materialized view 生成的 schema,其中已提取出 RemoteAddress 列。
由于 IP 到地理位置的映射可能发生变化,用户通常更希望知道请求发出当时来自何处,而不是同一地址当前对应的地理位置。因此,这里通常更适合在索引时进行富集。这可以像下面所示那样通过物化列来实现,也可以在 materialized view 的 SELECT 中实现:
定期更新用户通常会希望随着新数据的到来,定期更新 IP 富集字典。这可以通过使用字典的 LIFETIME 子句来实现,从而使字典定期从底层表重新加载。要更新底层表,请参阅”可刷新 materialized view”
上述国家和坐标不仅可用于按国家分组和过滤,还支持更多可视化方式。可参考”可视化地理数据”

使用正则表达式字典 (User-Agent 解析)

解析 user agent strings 是一个经典的正则表达式问题,也是基于日志和 trace 的数据集中常见的需求。ClickHouse 通过正则表达式树字典 (Regular Expression Tree Dictionaries) 提供了高效的 User-Agent 解析能力。 在 ClickHouse 开源版中,正则表达式树字典通过 YAMLRegExpTree 字典源类型定义,该类型提供指向包含正则表达式树的 YAML 文件的路径。如果你想提供自己的正则表达式字典,可在此处查看所需结构的详细说明。下面我们将重点介绍如何使用 uap-core 进行 User-Agent 解析,并为受支持的 CSV 格式加载字典。这种方法兼容 OSS 和 ClickHouse Cloud。
在下面的示例中,我们使用的是截至 2024 年 6 月最新版 uap-core 中用于 User-Agent 解析的正则表达式快照。最新文件会不定期更新,可在此处找到。你可以按照此处的步骤,将其加载到下面使用的 CSV 文件中。
创建以下 Memory 表。这些表保存了解析设备、浏览器和操作系统所需的正则表达式。
这些表可以通过 url 表函数,使用以下公开托管的 CSV 文件填充:
在内存表填充好数据后,我们就可以加载正则表达式字典了。请注意,我们需要将键值指定为列,这些列就是我们可以从 User-Agent 中提取的属性。
加载这些字典后,我们可以提供一个 user-agent 示例,并测试新的字典提取功能:
鉴于 User-Agent 相关规则几乎不会变化,而字典通常只需在出现新的浏览器、操作系统和设备时更新,因此在写入时执行这项提取是合理的。 我们既可以使用 materialized column 来完成这项工作,也可以使用 materialized view。下面我们将修改前面使用的 materialized view:
这需要我们修改目标表 otel_logs_v2 的 schema:
按照前文所述步骤重启采集器并摄取结构化日志后,我们就可以查询新提取出的 Device、Browser 和 Os 列了。
用于复杂结构的 Tuple请注意,这些 User-Agent 列使用了 Tuple。对于层级结构事先已知的复杂数据,建议使用 Tuple。子列在允许异构类型的同时,还能提供与普通列相同的性能 (这点不同于 Map 键) 。

延伸阅读

如需查看更多有关字典的示例和详细说明,推荐阅读以下文章:

加速查询

ClickHouse 支持多种提升查询性能的技术。只有在选定合适的主键/排序键,以针对最常见的访问模式进行优化并尽可能提高压缩率之后,才应考虑以下技术。通常,这样往往能以最小的投入带来最大的性能提升。

使用 Materialized views (增量) 进行聚合

在前面的章节中,我们已经介绍了如何将 Materialized views 用于数据转换和过滤。不过,Materialized views 还可以在写入时预先计算聚合结果并将其存储下来。后续写入的数据还能继续更新这些结果,从而实现于写入时预计算聚合。 其核心思想在于,这类结果通常是原始数据的更小表示形式 (对于聚合来说,则是部分草图) 。再配合一个更简单的查询来从目标表中读取结果,查询速度就会比直接对原始数据执行相同计算更快。 请看下面这个查询,我们使用结构化日志来计算每小时的总流量:
我们可以设想,这可能是用户在 Grafana 中绘制的一种常见折线图。这个查询确实非常快——数据集只有 1000 万行,而且 ClickHouse 本来就很快!不过,如果规模扩大到数十亿甚至数万亿行,我们理想情况下仍希望保持这样的查询性能。
如果使用 otel_logs_v2 表,这个查询还可以快 10 倍。该表来自我们前面创建的 materialized view,它会从 LogAttributes map 中提取 size 键。这里使用原始数据仅仅是为了演示;如果这是一个常见查询,我们建议使用前面的视图。
如果我们希望通过 Materialized view 在写入时完成这项计算,就需要一张表来接收结果。这张表应该每小时只保留 1 行。如果某个已有小时收到了更新,其他列应合并到该小时现有的行中。要实现这种增量状态的合并,其他列必须存储部分状态。 这需要使用 ClickHouse 中一种特殊的引擎类型:SummingMergeTree。它会将具有相同排序键的所有行合并为一行,其中包含数值列的汇总值。下面这张表会合并所有日期相同的行,并对其中的数值列求和。
为了演示我们的 materialized view,假设 bytes_per_hour 表为空,尚未接收任何数据。我们的 materialized view 会对插入到 otel_logs 的数据执行上述 SELECT (这会按配置大小的块进行) ,并将结果发送到 bytes_per_hour。其语法如下所示:
这里的 TO 子句至关重要,用于指定结果将发送到哪里,即 bytes_per_hour 如果我们重启 OTel collector 并重新发送日志,bytes_per_hour 表就会根据上述查询结果逐步增量填充。完成后,我们可以确认 bytes_per_hour 的大小——每小时应有 1 行:
通过存储查询结果,我们已将这里的行数从 1000 万 (otel_logs 中) 有效减少到 113。关键在于,如果有新的日志插入 otel_logs 表,新的值就会被发送到 bytes_per_hour 中对应的小时,并在后台自动异步合并——由于每小时只保留一行,bytes_per_hour 因此会始终保持体量小且数据最新。 由于行合并是异步进行的,因此用户发起查询时,每小时可能仍然有多于一行。要确保所有尚未合并的行都在查询时完成合并,我们有两种选择:
  • 在表名上使用 FINAL modifier (我们在上面的计数查询中就是这样做的) 。
  • 按最终表中使用的 排序键 (即 Timestamp) 进行聚合,并对指标求和。
通常,第二种方式效率更高,也更灵活 (该表还可用于其他用途) ,但对某些查询来说,第一种方式更简单。下面我们会展示这两种方式:
这使我们的查询时间从 0.6 秒缩短到 0.008 秒——快了 75 倍以上!
在更大的数据集上执行更复杂的查询时,性能提升幅度可能会更大。示例请参见此处

一个更复杂的示例

上述示例使用 SummingMergeTree 按小时对简单计数进行聚合。若要统计的不只是简单求和,则需要使用不同的目标表引擎:AggregatingMergeTree 假设我们希望计算每天的唯一 IP 地址数 (或唯一用户数) 。对应的查询如下:
为了持久化支持增量更新的基数统计,需要使用 AggregatingMergeTree。
为确保 ClickHouse 知道这里存储的是聚合状态,我们将 UniqueUsers 列定义为 AggregateFunction 类型,并指定部分状态对应的函数来源 (uniq) 以及源列的类型 (IPv4) 。与 SummingMergeTree 类似,具有相同 ORDER BY 键值的行会被合并 (如上例中的 Hour) 。 对应的 materialized view 使用前面的查询:
请注意,我们在聚合函数末尾加上了 State 后缀。这样返回的就是函数的聚合状态,而不是最终结果。其中会包含额外信息,以便这个部分状态能与其他状态合并。 通过重启采集器重新加载数据后,我们可以确认 unique_visitors_per_hour 表中有 113 行数据。
我们最终的查询需要在函数中使用 Merge 后缀 (因为这些列存储的是部分聚合状态) :
请注意,这里使用的是 GROUP BY,而不是 FINAL

使用 materialized view (增量式) 实现快速查找

选择 ClickHouse 排序键时,应结合查询访问模式,优先考虑经常出现在过滤和聚合子句中的列。在可观测性场景中,这种做法可能会受到限制,因为用户的访问模式更加多样,难以用单一的一组列来概括。默认 OTel schema 中内置的一个示例很好地说明了这一点。下面以链路追踪的默认 schema 为例:
此 schema 已针对按 ServiceNameSpanNameTimestamp 进行过滤做了优化。在 tracing 场景中,用户还需要能够按特定的 TraceId 执行查找,并获取该 trace 关联的 spans。虽然它也包含在排序键中,但由于其位于末尾,过滤效率不会那么高,因此在获取单个 trace 时,很可能仍需扫描大量数据。 OTel collector 还会安装一个 materialized view 及其关联表来应对这一问题。该表和视图如下所示:
该视图实际上确保 otel_traces_trace_id_ts 表中包含该 trace 的最小和最大时间戳。该表按 TraceId 排序,因此可以高效地检索这些时间戳。反过来,这些时间戳范围又可用于查询主 otel_traces 表。更具体地说,在根据 id 检索 trace 时,Grafana 会使用以下查询:
这里的 CTE 会先找出 trace ID ae9226c78d1d360601e6383928e4d22d 的最小和最大时间戳,再据此过滤主 otel_traces 表中与之关联的 spans。 同样的方法也适用于类似的访问模式。我们在数据建模的这里探讨了一个类似的示例。

使用 PROJECTION

ClickHouse 投影允许你为一张表指定多个 ORDER BY 子句。 在前面的章节中,我们探讨了如何在 ClickHouse 中使用 materialized view 预计算聚合、转换行,并针对不同访问模式优化可观测性查询。 我们给出了一个示例:materialized view 会将行写入目标表,而该目标表使用的排序键与接收 insert 的原始表不同,从而优化按 trace id 进行的查找。 PROJECTION 也可以用来解决同样的问题,让用户能够针对不属于主键的列优化查询。 理论上,这种能力可以让一张表拥有多个排序键,但有一个明显的缺点:数据重复。具体来说,除了需要按主键的主要排序顺序写入数据外,还必须额外按照每个 PROJECTION 指定的顺序再写入一次。这会降低 insert 速度,并占用更多磁盘空间。
PROJECTION 与 Materialized ViewsPROJECTION 具备许多与 materialized view 相同的能力,但应谨慎使用,通常更推荐后者。你需要了解它们的缺点以及适用场景。比如,虽然 PROJECTION 可用于预计算聚合,但我们更建议用户为此使用 Materialized views。
考虑下面这个查询,它会按 500 错误码过滤 otel_logs_v2 表中的数据。这很可能是日志场景中的一种常见访问模式,因为用户往往希望按错误码进行过滤:
使用 Null 衡量性能这里我们使用 FORMAT Null,不输出结果。这会强制读取所有结果但不返回,从而避免查询因 LIMIT 而提前终止。这样做只是为了展示扫描全部 1000 万行所花费的时间。
在所选排序键 (ServiceName, Timestamp)`` 下,上述查询需要进行线性扫描。虽然我们可以将 Status` 添加到排序键末尾,以提升上述查询的性能,但也可以添加 PROJECTION。
请注意,我们必须先创建 PROJECTION,然后再将其物化。后一条命令会使数据以两种不同的顺序在磁盘上各存储一份。PROJECTION 也可以在创建数据时一并定义,如下所示,并且会在数据插入时自动维护。
需要注意的是,如果 PROJECTION 是通过 ALTER 创建的,那么在发出 MATERIALIZE PROJECTION 命令后,其创建过程是异步进行的。你可以使用以下查询确认此操作的进度,并等待 is_done=1
如果再次执行上述查询,可以看到性能已显著提升,但代价是会占用更多存储空间 (关于如何衡量这一点,请参见”测量表大小和压缩”) 。
在上面的示例中,我们在 PROJECTION 中指定了前一个查询所使用的列。这意味着磁盘上作为 PROJECTION 一部分存储的只会是这些指定列,并且会按 Status 排序。或者,如果这里使用的是 SELECT *,则会存储所有列。虽然这样可以让更多查询 (使用任意列子集) 从 PROJECTION 中受益,但也会带来额外的存储开销。有关如何测量磁盘空间和压缩率,请参阅“测量表大小和压缩”

二级索引/数据跳过索引

无论在 ClickHouse 中如何精心调整主键,某些查询终究还是不可避免地需要全表扫描。虽然可以通过 materialized views (以及针对某些查询的 projections) 在一定程度上缓解这一问题,但这些方法都需要额外维护,而且用户还必须知道它们可用,才能确保真正利用起来。传统关系型数据库通常通过二级索引解决这个问题,但这类索引在 ClickHouse 这样的列式数据库中并不有效。为此,ClickHouse 使用“跳过”索引,使数据库能够跳过不包含匹配值的大块数据,从而显著提升查询性能。 默认的 OTel schema 使用二级索引来尝试加速对 map 的访问。虽然根据我们的经验,它们通常效果不佳,因此也不建议你在自定义 schema 中照搬,但数据跳过索引在某些情况下仍然有用。 在尝试使用它们之前,你应先阅读并理解二级索引指南 一般来说,只有当主键与目标非主键列/表达式之间存在很强的相关性,且用户查找的是稀有值——也就是不会出现在很多粒度中的值——时,它们才会有效。 ClickHouse 提供了一种专用于全文检索的文本索引。 该索引会在分词后的文本数据上构建倒排索引,从而实现快速的基于标记的搜索查询。 文本索引从 ClickHouse 26.2 版本开始可用。 它们可以定义在 MergeTree 表中的以下列类型上:StringFixedStringArray(String)Array(FixedString) 以及 Map (通过 mapKeysmapValues map 函数) 列。 文本索引在定义时需要提供 tokenizer 参数。也可以指定预处理函数,在分词前对输入字符串进行转换。 推荐用于检索文本索引的函数有:hasAnyTokenshasAllTokens。 存在文本索引时,一些传统的字符串搜索函数也会自动得到优化。 有关详细信息及支持的函数,请参阅此处此处的文档。 在下面的示例中,我们使用一个结构化日志数据集。
我们也可以在没有文本索引的情况下使用 hasAnyTokens,但这样查询时就会对 Body 列执行缓慢的全表扫描:

添加文本索引

可在创建表时为 Body 列添加文本索引:
或稍后通过 ALTER TABLE 添加:
如果再次运行相同的 SELECT 查询,就会执行一次文本索引查找。 访问数据量将从 GB 级降至 MB 级,性能提升约 45 倍。

使用预处理器

在这个数据集中,Body 列包含一个 JSON 格式的字符串,其中包含多个键值对 (例如 msgidctxattr 等) 。 假设我们只想在 msg 字段中搜索。 与其为整个 JSON 字符串建立索引,不如定义一个预处理器,在分词前仅提取 msg 的值。 例如:
在这个示例中,预处理器:
  • 减少了被分词和建立索引的文本量,
  • 缩小了索引体积,
  • 降低了误报概率,并且
  • 提升了查询性能。
与未经预处理的索引相比,性能可提升约 2 倍。 使用预处理器还可将索引大小从数 GB 减少到几百 KB,仅为原始大小的 0.01%。
**其他用于文本搜索的索引 有关二级跳过索引的更多详细信息,请参见此处

从 Map 中提取

Map 类型在 OTel schema 中很常见。该类型要求键和值具有相同的类型——这对于 Kubernetes 标记等元数据来说已经足够。请注意,查询 Map 类型中的某个子键时,整个父列都会被加载。如果该 Map 包含很多键,与将该键单独作为一列存储相比,这会带来明显的查询性能损耗,因为需要从磁盘读取更多数据。 如果你经常查询某个特定键,建议考虑将其移到根级别的独立专用列中。这通常是在部署后根据常见访问模式进行的调整,在生产环境上线前往往很难预判。有关如何在部署后修改 schema,请参阅“管理 schema 变更”

测量表大小与压缩

压缩是 ClickHouse 用于可观测性场景的主要原因之一。 除了能显著降低存储成本,磁盘上的数据更少还意味着更低的 I/O 开销,以及更快的查询和插入。对于 CPU 而言,I/O 的减少所带来的收益会超过压缩算法本身的开销。因此,在确保 ClickHouse 查询性能时,首先应关注如何提升数据的压缩效果。 有关如何测量压缩效果的详细信息,请参见此处
最后修改于 2026年6月25日