- 选择主键 - 默认 schema 使用的
ORDER BY是针对特定访问模式优化的。你的访问模式很可能与其并不匹配。 - 提取结构 - 你可能希望从现有列中提取新列,例如
Body列。这可以通过物化列来实现 (更复杂的情况下可使用 materialized view) 。这需要修改 schema。 - 优化 Map - 默认 schema 使用 Map 类型 来存储属性。这些列允许存储任意元数据。虽然这是一项必不可少的能力——因为事件中的元数据通常不会预先定义,因此无法以其他方式存储到像 ClickHouse 这样强类型的数据库中——但访问 map 中的键及其值,不如访问普通列高效。我们通过修改 schema 来解决这个问题,确保最常访问的 map 键成为顶层列——参见”使用 SQL 提取结构”。这需要修改 schema。
- 简化 map 键访问 - 访问 map 中的键需要使用更繁琐的语法。你可以通过别名来缓解这一问题。参见”使用别名”以简化查询。
- 二级索引 - 默认 schema 使用二级索引来加速对 Map 的访问并提升文本查询性能。通常并不需要这些索引,而且还会额外占用磁盘空间。它们可以使用,但应先经过测试,以确认确有必要。参见”二级索引 / 数据跳过索引”。
- 使用编解码器 - 如果你了解预期数据的特征,并且有证据表明这能提高压缩效果,那么你可能希望为列自定义编解码器。
使用 SQL 提取结构
- 从字符串 blob 中提取列。与在查询时使用字符串操作相比,直接查询这些列会更快。
- 从 Map 中提取键。默认 schema 会将任意属性放入 Map 类型的列中。这种类型提供了无 schema 的能力,优势在于用户在定义日志和链路追踪时,无需预先为这些属性定义列——而在从 Kubernetes 收集日志并希望保留 pod (容器组) 标记以供后续搜索时,这往往是不现实的。访问 map 中的键及其值,比查询普通 ClickHouse 列更慢。因此,通常希望将 map 中的键提取到表的顶层列中。
Body 列中,类型为 String。此外,如果用户在 collector 中启用了 json_parser,它也可能存储在 LogAttributes 列中,类型为 Map(String, String)。
LogAttributes 可用,用于统计站点中哪些 URL 路径收到最多 POST 请求的查询如下:
LogAttributes['request_path'],以及用于从 URL 中去除查询参数的 path 函数。
如果用户尚未在 collector 中启用 JSON 解析,那么 LogAttributes 将为空,这意味着我们只能使用 JSON 函数 从 String Body 中提取列。
优先使用 ClickHouse 进行解析我们通常建议用户在 ClickHouse 中对结构化日志执行 JSON 解析。我们相信 ClickHouse 提供了速度最快的 JSON 解析实现。不过,我们也理解,你可能希望将日志发送到其他 source,而不希望这部分逻辑放在 SQL 中。
extractAllGroupsVertical 函数和正则表达式来实现。
考虑使用字典上述查询可以优化为利用正则表达式字典。更多详情请参见使用字典。
使用 OTel 还是 ClickHouse 进行处理?你也可以按此处所述,使用 OTel collector 的处理器和操作器进行处理。在大多数情况下,你会发现 ClickHouse 在资源效率和速度方面都明显优于 collector 的处理器。使用 SQL 执行所有事件处理的主要缺点在于,解决方案会与 ClickHouse 绑定。例如,你可能希望从 OTel collector 将处理后的日志发送到其他目标端,例如 S3。
物化列
INSERT 查询中指定。
开销物化列会带来额外的存储开销,因为这些值会在写入时被提取并写入磁盘上的新列。
LogAttributes 列中:
Body 中提取数据的等效 schema 可在此处查看。
我们的三个物化列分别提取请求页面、请求类型以及引荐来源域名。它们会访问 map 中的键,并对其值应用函数。因此,后续查询会快得多:
默认情况下,物化列不会出现在
SELECT * 的返回结果中。这是为了保持这样一个不变性:SELECT * 的结果始终可以使用 INSERT 再次插入回该表。可通过设置 asterisk_include_materialized_columns=1 关闭此行为,也可以在 Grafana 中启用 (参见数据源配置中的 Additional Settings -> Custom Settings) 。Materialized views
实时更新ClickHouse 中的 materialized views 会在数据流入其所基于的表时实时更新,作用更像持续更新的索引。相比之下,在其他数据库中,materialized views 通常是某个查询的静态快照,必须刷新 (类似于 ClickHouse Refreshable Materialized Views) 。
SELECT statement 都是可行的。
你需要记住,这个查询只是一个 trigger,它会针对正在插入源表的行执行,并将结果发送到一个新表 (目标表) 。
为了确保数据不会被持久化两次 (即在源表和目标表中各存一份) ,我们可以将源表改为 Null table engine,同时保留原始 schema。我们的 OTel collectors 仍会继续向这张表发送数据。例如,对于日志,otel_logs 表会变成:
/dev/null。这个表不会存储任何数据,但任何附加的 materialized view 仍会在插入的行被丢弃前基于这些行执行。
请看下面的查询。它会把行转换为我们希望保留的格式:从 LogAttributes 中提取所有列 (这里假设这些列已由 collector 通过 json_parser operator 设置) ,并设置 SeverityText 和 SeverityNumber (依据一些简单条件以及这些列的定义) 。在这个示例中,我们也只选择明确会被填充的列——忽略 TraceId、SpanId 和 TraceFlags 等列。
Body 列——以防后续新增了未被 SQL 提取的额外属性。该列在 ClickHouse 中应具有良好的压缩效果,且极少被访问,因此不会影响查询性能。最后,我们通过类型转换将 Timestamp 缩减为 DateTime (以节省存储空间——参见 “Optimizing Types”) 。
我们需要一张表来接收这些结果。下面的目标表与上述查询相匹配:
请注意,我们已经对 schema 做了大幅调整。实际上,你很可能还有一些也需要保留的 trace 列,以及
ResourceAttributes 列 (其中通常包含 Kubernetes 元数据) 。Grafana 可以利用 trace 列提供日志与链路追踪之间的关联功能——参见”使用 Grafana”。otel_logs_mv,用于对 otel_logs 表执行上述查询,并将结果发送到 otel_logs_v2。
otel_logs_v2 中。请注意,这里使用了带类型的 JSON 提取函数。
Body 列中提取各列:
注意类型
LogAttributes Map 的情况下。ClickHouse 通常会自动将提取出的值转换为目标表的类型,从而减少所需的语法。不过,我们建议用户始终通过以下方式测试视图:使用该视图的 SELECT 语句,并配合一个面向相同 schema 目标表的 INSERT INTO 语句。这样应能确认类型是否得到了正确处理。尤其要注意以下情况:
- 如果某个 key 在 Map 中不存在,将返回空字符串。对于数值类型,你需要将其映射为合适的值。这可以通过条件函数实现,例如
if(LogAttributes['status'] = ", 200, LogAttributes['status']);如果可以接受默认值,也可以使用类型转换函数,例如toUInt8OrDefault(LogAttributes['status'] ) - 某些类型并不总是会被自动转换,例如,数值的字符串表示形式不会被转换为枚举值。
- 如果未找到值,JSON 提取函数会返回该类型的默认值。请务必确认这些值是合理的!
选择主键 (排序键)
- 选择符合常见过滤条件和访问模式的列。如果你通常通过按某个特定列 (例如 pod 名称) 过滤来开始可观测性调查,那么该列会经常出现在
WHERE子句中。与使用频率较低的列相比,应优先将这类列纳入键中。 - 优先选择那些在过滤时能够排除总行数中很大一部分的列,从而减少需要读取的数据量。服务名称和状态码通常都是不错的候选项——不过对后者来说,仅当你过滤的值能排除大多数行时才成立。例如,在大多数系统中按 200 范围过滤会匹配大部分行,而 500 错误通常只对应较小的一个子集。
- 优先选择与表中其他列高度相关的列。这有助于确保这些值也连续存储,从而提升压缩效果。
- 对排序键中的列执行
GROUP BY和ORDER BY操作时,内存利用率会更高。
确定了排序键的列子集后,还必须按特定顺序声明它们。这个顺序会显著影响查询中对排序键后续列的过滤效率,以及表数据文件的压缩率。一般来说,最好按基数升序排列这些键。但这需要结合这样一个事实来权衡:对排序键中越靠后的列进行过滤,效率会低于对 Tuple 中越靠前的列进行过滤。请结合你的访问模式,在这些因素之间做好平衡。最重要的是,要测试不同方案。若想进一步了解排序键及其优化方法,我们推荐阅读这篇文章。
先确定结构我们建议在完成日志结构化之后再决定排序键。不要将 attribute Map 中的键或 JSON 提取表达式用作排序键。请确保排序键在表中是顶层列。
使用 Map
map['key'] 这种 map 语法来访问 Map(String, String) 列中的值。除了使用 map 表示法访问嵌套键之外,还可以使用专门的 ClickHouse map functions 来过滤或选取这些列。
例如,下面的查询先使用 mapKeys function,再使用 groupArrayDistinctArray function (一种组合器) ,找出 LogAttributes 列中所有可用的唯一键。
避免使用点号不建议在 Map 列名中使用点号,此类用法后续可能会被弃用。请改用
_。使用别名
LogAttributes 的 ALIAS 列 RemoteAddr。现在,我们可以通过该列查询 LogAttributes['remote_addr'] 的值,从而简化查询,即
ALTER TABLE 命令添加 ALIAS 也很简单。这些列会立即可用,例如
默认排除别名列默认情况下,
SELECT * 不会包含 ALIAS 列。可通过设置 asterisk_include_alias_columns=1 来禁用这一行为。优化数据类型
使用编解码器
ZSTD 编解码器非常适用于日志和 trace 数据集。将压缩级别从默认值 1 调高,可能会提升压缩效果。不过,这一点仍需经过测试,因为更高的取值会在写入时带来更大的 CPU 开销。通常情况下,我们观察到调高该值带来的收益很有限。
此外,时间戳虽然能通过 delta 编码获得更好的压缩效果,但实践表明,如果该列被用作主键/排序键,可能会导致慢查询性能下降。我们建议用户评估压缩率与查询性能之间的权衡。
使用字典
加速 JOIN如果你希望使用字典来加速 JOIN,可以在这里了解更多详情。
写入时 vs 查询时
- 写入时 - 如果富集值基本不变,并且存在于可用于填充字典的外部数据源中,通常适合采用这种方式。在这种情况下,在写入时对行进行富集,可以避免查询时再到字典中查找。但代价是会影响写入性能,并带来额外的存储开销,因为富集后的值会作为列存储。
- 查询时 - 如果字典中的值经常变化,则通常更适合在查询时查找。这样一来,如果映射值发生变化,就无需更新列 (以及重写数据) 。这种灵活性的代价是查询时的查找开销。如果很多行都需要查找,例如在过滤器子句中使用字典查找,这种查询时开销通常会比较明显。对于结果富集,也就是在
SELECT中,这种开销通常并不明显。
使用 IP 字典
ip_trie 结构化字典来实现这一点。
我们使用由 DB-IP.com 提供的公开可用的 DB-IP 城市级数据集,其使用条款遵循 CC BY 4.0 许可证。
从 readme 中可以看到,该数据的结构如下:
URL() 表引擎,按我们的字段名创建一个 ClickHouse 表对象,并确认总行数:
ip_trie 字典要求以 CIDR 表示法表示 IP 地址范围,因此我们需要转换 ip_range_start 和 ip_range_end。
可以通过以下查询简洁地计算出每个范围对应的 CIDR:
上面的查询内容很多。感兴趣的话,可以阅读这篇很棒的解释。否则,只需知道上面的计算结果是为一个 IP 范围得出 CIDR。
ip_trie 字典结构,可将网络前缀 (CIDR 块) 映射到坐标和国家/地区代码。下面的查询使用这种布局,并以上述表作为源来定义一个字典。
定期刷新ClickHouse 中的字典会根据底层表数据以及上文使用的 lifetime 子句定期刷新。要让我们的 Geo IP 字典反映 DB-IP 数据集中的最新变更,只需将
geoip_url 远程表中的数据重新插入 geoip 表,并应用相应的转换即可。ip_trie 字典中 (它的名字也恰好是 ip_trie) ,就可以用它来进行 IP 地理位置定位了。这可以通过如下方式使用 dictGet() 函数 来实现:
RemoteAddress 列。
定期更新用户通常会希望随着新数据的到来,定期更新 IP 富集字典。这可以通过使用字典的
LIFETIME 子句来实现,从而使字典定期从底层表重新加载。要更新底层表,请参阅”可刷新 materialized view”。使用正则表达式字典 (User-Agent 解析)
创建以下 Memory 表。这些表保存了解析设备、浏览器和操作系统所需的正则表达式。
url 表函数,使用以下公开托管的 CSV 文件填充:
otel_logs_v2 的 schema:
用于复杂结构的 Tuple请注意,这些 User-Agent 列使用了 Tuple。对于层级结构事先已知的复杂数据,建议使用 Tuple。子列在允许异构类型的同时,还能提供与普通列相同的性能 (这点不同于 Map 键) 。
延伸阅读
加速查询
使用 Materialized views (增量) 进行聚合
如果使用
otel_logs_v2 表,这个查询还可以快 10 倍。该表来自我们前面创建的 materialized view,它会从 LogAttributes map 中提取 size 键。这里使用原始数据仅仅是为了演示;如果这是一个常见查询,我们建议使用前面的视图。bytes_per_hour 表为空,尚未接收任何数据。我们的 materialized view 会对插入到 otel_logs 的数据执行上述 SELECT (这会按配置大小的块进行) ,并将结果发送到 bytes_per_hour。其语法如下所示:
TO 子句至关重要,用于指定结果将发送到哪里,即 bytes_per_hour。
如果我们重启 OTel collector 并重新发送日志,bytes_per_hour 表就会根据上述查询结果逐步增量填充。完成后,我们可以确认 bytes_per_hour 的大小——每小时应有 1 行:
otel_logs 中) 有效减少到 113。关键在于,如果有新的日志插入 otel_logs 表,新的值就会被发送到 bytes_per_hour 中对应的小时,并在后台自动异步合并——由于每小时只保留一行,bytes_per_hour 因此会始终保持体量小且数据最新。
由于行合并是异步进行的,因此用户发起查询时,每小时可能仍然有多于一行。要确保所有尚未合并的行都在查询时完成合并,我们有两种选择:
- 在表名上使用
FINALmodifier (我们在上面的计数查询中就是这样做的) 。 - 按最终表中使用的 排序键 (即 Timestamp) 进行聚合,并对指标求和。
在更大的数据集上执行更复杂的查询时,性能提升幅度可能会更大。示例请参见此处。
一个更复杂的示例
UniqueUsers 列定义为 AggregateFunction 类型,并指定部分状态对应的函数来源 (uniq) 以及源列的类型 (IPv4) 。与 SummingMergeTree 类似,具有相同 ORDER BY 键值的行会被合并 (如上例中的 Hour) 。
对应的 materialized view 使用前面的查询:
State 后缀。这样返回的就是函数的聚合状态,而不是最终结果。其中会包含额外信息,以便这个部分状态能与其他状态合并。
通过重启采集器重新加载数据后,我们可以确认 unique_visitors_per_hour 表中有 113 行数据。
GROUP BY,而不是 FINAL。
使用 materialized view (增量式) 实现快速查找
ServiceName、SpanName 和 Timestamp 进行过滤做了优化。在 tracing 场景中,用户还需要能够按特定的 TraceId 执行查找,并获取该 trace 关联的 spans。虽然它也包含在排序键中,但由于其位于末尾,过滤效率不会那么高,因此在获取单个 trace 时,很可能仍需扫描大量数据。
OTel collector 还会安装一个 materialized view 及其关联表来应对这一问题。该表和视图如下所示:
otel_traces_trace_id_ts 表中包含该 trace 的最小和最大时间戳。该表按 TraceId 排序,因此可以高效地检索这些时间戳。反过来,这些时间戳范围又可用于查询主 otel_traces 表。更具体地说,在根据 id 检索 trace 时,Grafana 会使用以下查询:
ae9226c78d1d360601e6383928e4d22d 的最小和最大时间戳,再据此过滤主 otel_traces 表中与之关联的 spans。
同样的方法也适用于类似的访问模式。我们在数据建模的这里探讨了一个类似的示例。
使用 PROJECTION
ORDER BY 子句。
在前面的章节中,我们探讨了如何在 ClickHouse 中使用 materialized view 预计算聚合、转换行,并针对不同访问模式优化可观测性查询。
我们给出了一个示例:materialized view 会将行写入目标表,而该目标表使用的排序键与接收 insert 的原始表不同,从而优化按 trace id 进行的查找。
PROJECTION 也可以用来解决同样的问题,让用户能够针对不属于主键的列优化查询。
理论上,这种能力可以让一张表拥有多个排序键,但有一个明显的缺点:数据重复。具体来说,除了需要按主键的主要排序顺序写入数据外,还必须额外按照每个 PROJECTION 指定的顺序再写入一次。这会降低 insert 速度,并占用更多磁盘空间。
PROJECTION 与 Materialized ViewsPROJECTION 具备许多与 materialized view 相同的能力,但应谨慎使用,通常更推荐后者。你需要了解它们的缺点以及适用场景。比如,虽然 PROJECTION 可用于预计算聚合,但我们更建议用户为此使用 Materialized views。
otel_logs_v2 表中的数据。这很可能是日志场景中的一种常见访问模式,因为用户往往希望按错误码进行过滤:
使用 Null 衡量性能这里我们使用
FORMAT Null,不输出结果。这会强制读取所有结果但不返回,从而避免查询因 LIMIT 而提前终止。这样做只是为了展示扫描全部 1000 万行所花费的时间。(ServiceName, Timestamp)`` 下,上述查询需要进行线性扫描。虽然我们可以将 Status` 添加到排序键末尾,以提升上述查询的性能,但也可以添加 PROJECTION。
ALTER 创建的,那么在发出 MATERIALIZE PROJECTION 命令后,其创建过程是异步进行的。你可以使用以下查询确认此操作的进度,并等待 is_done=1。
SELECT *,则会存储所有列。虽然这样可以让更多查询 (使用任意列子集) 从 PROJECTION 中受益,但也会带来额外的存储开销。有关如何测量磁盘空间和压缩率,请参阅“测量表大小和压缩”。
二级索引/数据跳过索引
用于全文检索的文本索引
tokenizer 参数。也可以指定预处理函数,在分词前对输入字符串进行转换。
推荐用于检索文本索引的函数有:hasAnyTokens 和 hasAllTokens。
存在文本索引时,一些传统的字符串搜索函数也会自动得到优化。
有关详细信息及支持的函数,请参阅此处和此处的文档。
在下面的示例中,我们使用一个结构化日志数据集。
hasAnyTokens,但这样查询时就会对 Body 列执行缓慢的全表扫描:
添加文本索引
ALTER TABLE 添加:
使用预处理器
msg、id、ctx、attr 等) 。
假设我们只想在 msg 字段中搜索。
与其为整个 JSON 字符串建立索引,不如定义一个预处理器,在分词前仅提取 msg 的值。
例如:
- 减少了被分词和建立索引的文本量,
- 缩小了索引体积,
- 降低了误报概率,并且
- 提升了查询性能。