理解 ClickHouse 数据跳过索引
引言
许多因素影响 ClickHouse 查询性能。在大多数情况下,关键因素是 ClickHouse 能否在评估查询 WHERE 子句条件时使用主键。因此,选择适用于最常见查询模式的主键对于有效的表设计至关重要。
然而,无论主键调优得多么仔细,总会有查询用例无法有效利用它。用户通常依赖 ClickHouse 来处理时间序列类型数据,但他们往往希望根据其他业务维度(如客户 ID、网站 URL 或产品编号)分析相同的数据。在这种情况下,查询性能可能会显著降低,因为可能需要对每个列值进行全面扫描以应用 WHERE 子句条件。虽然在这些情况下 ClickHouse 仍然相对快速,但评估数百万或数十亿个单独值会导致“未索引”的查询执行速度远低于那些基于主键的查询。
在传统的关系数据库中,解决此问题的一种方法是将一个或多个“二级”索引附加到一个表。这是一种 b-tree 结构,允许数据库在 O(log(n)) 时间内找到所有匹配的行,而不是 O(n) 时间(表扫描),其中 n 是行数。然而,这种类型的二级索引在 ClickHouse(或其他列式数据库)中不起作用,因为在磁盘上没有单独的行可供添加到索引中。
相反,ClickHouse 提供了一种不同类型的索引,在特定情况下可以显著提高查询速度。这些结构被标记为“跳过”索引,因为它们使 ClickHouse 能够跳过读取保证没有匹配值的大块数据。
基本操作
用户只能在 MergeTree 家族的表上使用数据跳过索引。每个数据跳过索引有四个主要参数:
- 索引名称。索引名称用于在每个分区中创建索引文件。同时,在删除或物化索引时还需要作为参数。
- 索引表达式。索引表达式用于计算存储在索引中的值集合。它可以是列、简单运算符和/或由索引类型确定的函数子集的组合。
- TYPE。索引的类型控制决定是否可以跳过读取和评估每个索引块的计算。
- GRANULARITY。每个索引块由 GRANULARITY 个粒度组成。例如,如果主表索引的粒度为 8192 行,而索引粒度为 4,则每个索引“块”将为 32768 行。
当用户创建数据跳过索引时,表的每个数据部分目录中将会有两个额外的文件。
skp_idx_{index_name}.idx
,其中包含有序的表达值skp_idx_{index_name}.mrk2
,其中包含与关联数据列文件的相应偏移量。
如果在执行查询和读取相关列文件时,WHERE 子句筛选条件的某部分与跳过索引表达式匹配,ClickHouse 将使用索引文件数据来确定是否必须处理每个相关数据块或可以绕过该块(假设该块尚未通过应用主键被排除)。为了使用一个非常简单的示例,请考虑以下加载有可预测数据的表。
当执行一个不使用主键的简单查询时,my_value
列中所有 1 亿个条目都会被扫描:
现在添加一个非常基本的跳过索引:
通常,跳过索引只会应用于新插入的数据,因此仅添加索引不会影响以上查询。
要对已存在的数据建立索引,请使用以下语句:
使用新创建的索引重新运行查询:
ClickHouse 只需读取和分析 32768 行的 360 千字节,而不是处理 800 兆字节的 1 亿行数据 -- 仅四个粒度的 8192 行。
更直观地说,具有 my_value
为 125 的 4096 行是如何被读取和选择的,以及如何跳过后续行而无需从磁盘读取:
用户可以通过在执行查询时启用跟踪来访问有关跳过索引使用的详细信息。 从 clickhouse-client,设置 send_logs_level
:
这将提供有用的调试信息,以便在尝试优化查询 SQL 和表索引时。 从上述示例中,调试日志显示跳过索引丢弃了除了两个粒度以外的所有粒度:
跳过索引类型
minmax
这种轻量级索引类型不需要参数。它存储每个块的索引表达式的最小值和最大值(如果表达式是元组,则分别存储每个元素的值)。这种类型非常适合值松散排序的列。通常,这种索引类型在查询处理期间的开销最小。
这种类型的索引仅适用于标量或元组表达式 -- 不会应用于返回数组或映射数据类型的表达式。
set
这种轻量级索引类型接受一个参数,即每个块的值集的 max_size(0 允许无限数量的离散值)。该集合包含块中的所有值(如果值的数量超过 max_size,则为空)。这种索引类型在每组粒度中具有低基数的列中表现良好(本质上,“聚集在一起”),但整体上具有更高的基数。
该索引的成本、性能和有效性取决于块内的基数。如果每个块包含大量独特值,则对大型索引集评估查询条件将非常昂贵,或者由于超过 max_size,索引将不会应用而为空。
Bloom 过滤器类型
Bloom 过滤器是一种数据结构,允许以空间有效的方式测试集合成员资格,代价是略微增加误报的可能性。在跳过索引的情况下,误报不是一个重要问题,因为唯一的缺点是读取一些不必要的块。然而,误报的潜在可能性确实意味着应该期待被索引的表达式为真,否则有效数据可能会被跳过。
由于 Bloom 过滤器可以更有效地处理大量离散值的测试,因此它们可能适用于生成更多值进行测试的条件表达式。特别是,Bloom 过滤器索引可以应用于数组,对数组的每个值进行测试,并对映射进行测试,通过使用 mapKeys 或 mapValues 函数将键或值转换为数组。
基于 Bloom 过滤器有三种数据跳过索引类型:
-
基本的 bloom_filter,它接受一个可选参数,即允许的误报率在 0 和 1 之间(如果未指定,则使用 0.025)。
-
专门的 tokenbf_v1。其接受三个参数,均与调整所使用的 Bloom 过滤器有关:(1)过滤器的字节大小(较大的过滤器具有较少的误报,但存储成本较高),(2)应用的哈希函数数量(同样,更多的哈希过滤器会减少误报)以及(3)Bloom 过滤器哈希函数的种子。有关这些参数如何影响 Bloom 过滤器功能的更多详细信息,请参见计算器 这里。 此索引仅适用于 String、FixedString 和 Map 数据类型。输入表达式按非字母字符分隔为字符序列。例如,列值
This is a candidate for a "full text" search
将包含标记This
is
a
candidate
for
full
text
search
。它旨在用于 LIKE、EQUALS、IN、hasToken() 及类似的搜索单词和其他长字符串中的值。例如,可能的一个用法是查找自由形式的应用程序日志行中的少量类名称或行号。 -
专门的 ngrambf_v1。该索引的功能与标记索引相同。它在 Bloom 过滤器设置之前接受一个额外参数,即要索引的 ngrams 大小。n-gram 是长度为
n
的任意字符的字符串,因此字符串A short string
在 ngram 大小为 4 的情况下将被索引为:
该索引对于文本搜索也很有用,特别是在没有单词分隔符的语言中,例如中文。
跳过索引功能
数据跳过索引的核心目的是限制通过流行查询分析的数据量。鉴于 ClickHouse 数据的分析性质,这些查询的模式大多数情况下包括功能表达式。因此,跳过索引必须与常见函数正确交互以提高效率。这可以在以下两种情况下发生:
- 数据插入并且索引被定义为功能表达式(表达式的结果存储在索引文件中),或者
- 查询被处理,并且表达式应用于存储的索引值以确定是否排除该块。
每种类型的跳过索引都适用于与列出 这里 的索引实现相关的可用 ClickHouse 函数的子集。一般而言,set 索引和基于 Bloom 过滤器的索引(另一种类型的 set 索引)都是无序的,因此与范围不兼容。相比之下,minmax 索引与范围特别兼容,因为确定范围是否相交的速度非常快。部分匹配函数 LIKE、startsWith、endsWith 和 hasToken 的有效性依赖于使用的索引类型、索引表达式和数据的特定形状。
跳过索引设置
有两个适用于跳过索引的设置。
- use_skip_indexes (0 或 1,默认 1)。并非所有查询都能有效利用跳过索引。如果特定的过滤条件可能包含大多数粒度,则应用数据跳过索引会带来不必要且有时显著的成本。对于不太可能从任何跳过索引中受益的查询,将值设置为 0。
- force_data_skipping_indices (以逗号分隔的索引名称列表)。可以使用此设置防止某些类型的低效查询。在查询表代价过高的情况下,除非使用跳过索引,否则使用此设置并指定一个或多个索引名称将为任何未使用列出索引的查询返回异常。这将防止编写不良查询消耗服务器资源。
跳过最佳实践
跳过索引不直观,特别是对于习惯使用 RDMS 领域的二级基于行的索引或文档存储的倒排索引的用户。要获得任何好处,应用 ClickHouse 数据跳过索引必须避免足够的粒度读取,以抵消计算索引的成本。至关重要的是,如果一个值在索引块中出现一次,这意味着整个块必须被读取到内存中并进行评估,并且索引成本就不必要地被消耗了。
考虑以下数据分布:
假设主/排序键是 timestamp
,并且在 visitor_id
上存在索引。考虑以下查询:
对于这种数据分布,传统的二级索引将非常有利。它将只包括五个行位置,而不是读取所有 32768 行以找到请求的 visitor_id 的五个行。而 ClickHouse 数据跳过索引的正好相反。无论跳过索引的类型如何,visitor_id
列中的所有 32768 个值都将被测试。
因此,尝试通过简单地将索引添加到关键列来加速 ClickHouse 查询的自然冲动往往是错误的。在调查其他替代方案后,这种高级功能才应使用,例如修改主键(见 如何选择主键)、使用投影或使用物化视图。即使数据跳过索引合适,仔细调整索引和表也通常是必要的。
在大多数情况下,一个有用的跳过索引需要在主键与目标的非主列/表达式之间有强烈的相关性。如果没有相关性(如上图所示),在几千个值的块中至少有一行满足过滤条件的几率很高,且很少会跳过块。相反,如果主键的值范围(如一天中的时间)与潜在索引列中的值(如电视观众年龄)有强烈关联,则 minmax 类型索引可能会带来好处。注意,在插入数据时,通过在排序/ORDER BY 键中包含其他列或以一种将与主键关联的值在插入时分组的方式批量插入,可能会增加这种相关性。例如,特定 site_id
的所有事件可以通过摄取过程分组并一起插入,即使主键是一个包含来自多个网站事件的时间戳。这将导致许多只包含少数网站 ID 的粒度,因此在按特定 site_id
值搜索时可以跳过许多块。
高基数表达式的跳过索引的另一个好候选是任何一个值在数据中相对稀疏。例如,在追踪 API 请求中的错误代码的可观察平台中,某些错误代码尽管在数据中稀有,但可能在搜索中尤其重要。对 error_code
列的 set 跳过索引将允许跳过绝大多数不包含错误的块,从而显著提高对错误的聚焦查询的性能。
最后,关键最佳实践是测试、测试、测试。再次强调,与二级 b-tree 索引或用于搜索文档的倒排索引不同,数据跳过索引的行为并不易于预测。将它们添加到表中带来一定的成本,无论是数据摄取还是因为各种原因未从索引中受益的查询。它们应始终在真实世界类型的数据上进行测试,测试应包括类型、粒度大小和其他参数的变化。测试通常会揭示出一些仅靠思考实验无法明显看出的模式和陷阱。