TT Lab
开始
学习 学习路径 课程

ClickHouse — 从内部理解列式分析数据库

跳数索引与投影 —— 加速排序键之外的过滤条件

在 TT Lab 中继续学习

一句话总结

跳数索引不是指向行位置的 B 树,而是为每组颗粒附加的摘要(最小值·最大值、值集合、布隆过滤器),只有在能断言“这里绝对没有”的时候,才会跳过那一组。投影是藏在数据片段内部的另一种排序(或预聚合)的拷贝。两者对已有的数据片段都要 MATERIALIZE 才会生成,而且都要用磁盘来付出代价。

为什么需要它

在前面的模块中,我们看到排序键几乎决定一切。可是排序键只有一个。把日志表按时间(ts)排序,“昨天下午”很快,但在故障排查中查找“这个 trace_id 对应的一行”时,要把 100 万行全读一遍。对于行式数据库,这就该再建一个辅助 B 树索引。官方文档解释说,这种办法在列式存储中行不通——因为磁盘上没有一个单独的“行”可以按行去指向。

ClickHouse 给出的答案有两条路。一条是多找出一些不必读取的颗粒(跳数索引),另一条是再放一份带有另一种排序键的拷贝(投影,或前面模块的物化视图)。究竟哪条合适,取决于数据的分布。

工作原理

上方是按排序键 ts 顺序排列的六个颗粒,每个颗粒上有 minmax 索引记录的 seq 最小值、最大值,以及 user_id 的最小值、最大值。seq 随时间增大,所以每个颗粒的范围很窄且互不重叠,WHERE seq BETWEEN 条件只留下一个颗粒。user_id 分布均匀,所以每个颗粒的范围都是 0 到 49999,一个颗粒也跳不过。布隆过滤器这一格会舍弃能断言不含 trace_id 的颗粒,只留下几个假阳性的颗粒。下方是数据片段目录内,按 user_id 重新排序的 p_user 拷贝和按服务、小时预先聚合的 p_svc_hour 拷贝,作为子目录存在,查询指向的仍是原来的表,但优化器会选择读取行数最少的一方

一个索引由四项来定义——名称、表达式、类型(TYPE)、GRANULARITY。GRANULARITY 是多少个颗粒作为一组来汇总。1 就是每 8192 行一份摘要,4 就是每 32768 行一份摘要。文档介绍的类型如下。

类型 每组记录的内容 适合的情形
minmax 表达式的最小值、最大值 与排序键一起大致增大的列的范围条件
set(N) 最多 N 个不同的值(超出则清空) 整体上取值多样,但一组之内只有几个值的列
bloom_filter 值集合的布隆过滤器(假阳性默认 0.025) 查找罕见的单个值的“大海捞针”
ngrambf_v1、tokenbf_v1 n-gram、token 的布隆过滤器 字符串的部分搜索——从 26.2 起计划弃用,建议使用 text 索引

在这个 Pod(26.8)上,对 100 万行、123 个颗粒的表执行 ADD INDEX idx_trace trace_id TYPE bloom_filter GRANULARITY 1,EXPLAIN indexes = 1 中会出现 Skip 一栏,但 Granules: 123/123。索引只是有了定义,已有的数据片段里没有索引文件(system.data_skipping_indices 中大小为 0)。因为正如文档所说,它只应用于新进来的数据。ALTER TABLE ... MATERIALIZE INDEX idx_trace 是在已有数据片段上生成文件的变形(mutation),所以数据片段名称从 all_1_1_1 变成了 all_1_1_1_2,之后就是 Granules: 3/123,读取了 24,576 行。要找的行只有一行,但由于布隆过滤器的假阳性,多读了两个颗粒。

同样是 minmax,结果却天差地别。随时间增大的 seq 的范围条件,只留下了 1/123 个颗粒,而与时间无关、分布均匀的 user_id,每个颗粒里都同时包含接近最小值和接近最大值的值,所以是 123/123,也就是一个也没能跳过。正如文档所说,有用的跳数索引,通常要求排序键与目标列之间有强相关性。即使某个值在一组之内只出现一次,也得把整组都读一遍,所以没有相关性的话,索引只会增加计算成本。

这时用的就是投影。执行 ADD PROJECTION p_user (SELECT * ORDER BY user_id) 再 MATERIALIZE PROJECTION,每个数据片段都会生成一份按 user_id 重新排序的拷贝。查询指向的仍然是 skp.logs,由优化器选择读取量最少的一方。在这个 Pod 上,WHERE user_id = 4242 从读取 100 万行减少到 8,192 行,system.query_log 的 projections 列中留下了 skp.logs.p_user。带有 GROUP BY 的投影会变成隐藏的 AggregatingMergeTree,成为预聚合的拷贝。按服务、小时的 count、avg 投影有 4,320 行,按服务的聚合查询只读它,而不读原始数据。

这些值要用磁盘来付出代价。在这个 Pod 上,p_user 拷贝约 24.6MB,接近包含拷贝的整个数据片段(约 52.9MB)的一半。聚合投影约 36KB,布隆过滤器索引约 1MB,minmax 索引只有 1KB 出头。投影还有限制——正如文档所写,它不能设置与原表不同的 TTL,不能级联,定义中不能使用 WHERE、JOIN,而对有投影的表执行轻量级 DELETE,在默认设置(lightweight_mutation_projection_mode = throw)下会被拒绝(在 Pod 上确认了错误码 344)。

最后是测量时的一个陷阱。这个 Pod 的 26.8 中默认开启的查询条件缓存(use_query_condition_cache)会记住“与这个条件匹配的行不存在的颗粒”。同一个条件的查询运行第二次,读取的行数从 100 万降到了 0。比较索引效果时,要加上 use_query_condition_cache = 0 再测量。

在现场相遇的样子

最常见的错误是“这是经常过滤的列,那就加索引吧”,结果给分布均匀的列加上了 minmax 或 set。如果 EXPLAIN 的 Skip 一栏是 123/123,这个索引就只有成本。加之前,先看目标列与排序键是否有相关性。

第二种是给运行中的大表只做了 ADD INDEX,然后问“为什么没变快?”。它只会在新数据片段上生成,已有的数据要 MATERIALIZE,而那是重写所有数据片段的变形——要在空闲时间,一边用 system.mutations 查看进度一边做。

第三种是查找 trace_id、request_id 这样的唯一值。这几乎是布隆过滤器唯一适合的场合,如果是字符串的部分搜索,在 26.x 中应先考虑 text 索引。只有当判断出“必须有第二个排序键,而且多用一倍的磁盘也无妨”之后,才使用投影。

下一项实验要做什么

向 skp.logs 写入 100 万行,并合并成一个数据片段。保存只做了 ADD 的布隆过滤器索引的 EXPLAIN,MATERIALIZE 之后测量读取的行数。给 seq 和 user_id 加上 minmax,比较效果,创建 user_id 投影和按服务、小时的聚合投影,并用 query_log 确认,最后从 system 表中读出这一切所占用的磁盘。