跳到主要内容
跳到主要内容

理解 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 会使用索引文件数据来确定是否必须处理每个相关数据块,或者可以跳过该块(假设该块尚未通过应用主键排除)。为简单起见,考虑以下使用可预测数据加载的表。

CREATE TABLE skip_table
(
  my_key UInt64,
  my_value UInt64
)
ENGINE MergeTree primary key my_key
SETTINGS index_granularity=8192;

INSERT INTO skip_table SELECT number, intDiv(number,4096) FROM numbers(100000000);

当执行一个不使用主键的简单查询时,将扫描 my_value 列中的所有 1 亿条条目:

SELECT * FROM skip_table WHERE my_value IN (125, 700)

┌─my_key─┬─my_value─┐
│ 512000 │      125 │
│ 512001 │      125 │
│    ... |      ... |
└────────┴──────────┘

8192 rows in set. Elapsed: 0.079 sec. Processed 100.00 million rows, 800.10 MB (1.26 billion rows/s., 10.10 GB/s.

现在添加一个非常基本的跳过索引:

ALTER TABLE skip_table ADD INDEX vix my_value TYPE set(100) GRANULARITY 2;

通常,跳过索引只在新插入的数据上应用,因此仅添加索引不会影响上述查询。

要对已存在的数据进行索引,请使用此语句:

ALTER TABLE skip_table MATERIALIZE INDEX vix;

重新运行使用新创建索引的查询:

SELECT * FROM skip_table WHERE my_value IN (125, 700)

┌─my_key─┬─my_value─┐
│ 512000 │      125 │
│ 512001 │      125 │
│    ... |      ... |
└────────┴──────────┘

8192 rows in set. Elapsed: 0.051 sec. Processed 32.77 thousand rows, 360.45 KB (643.75 thousand rows/s., 7.08 MB/s.)

ClickHouse 只读取和分析 32768 行 360 KB 的数据,而不是处理 800 MB 的 1 亿行数据——四个每个 8192 行的颗粒。

以更直观的形式,这就是具有 my_value 为 125 的 4096 行如何被读取和选择,以及后续行如何在未从磁盘读取的情况下被跳过:

简单跳过

用户可以通过在执行查询时启用跟踪来访问跳过索引使用的详细信息。从 clickhouse-client 中,设置 send_logs_level

SET send_logs_level='trace';

这将提供有用的调试信息,以便调整查询 SQL 和表索引。从上述示例中,调试日志显示跳过索引丢弃了除两个颗粒之外的所有颗粒:

<Debug> default.skip_table (933d4b2c-8cea-4bf9-8c93-c56e900eefd1) (SelectExecutor): Index `vix` has dropped 6102/6104 granules.

跳过索引类型

minmax

这种轻量级索引类型不需要参数。它存储每个块的索引表达式的最小值和最大值(如果表达式是元组,它将分别存储元组元素的每个值)。这种类型适用于按值排序较松散的列。这种索引类型通常在查询处理时成本最低。

这种类型的索引仅适用于标量或元组表达式——索引不会应用于返回数组或映射数据类型的表达式。

set

这种轻量级索引类型接受一个参数,即每个块的值集的最大大小(0 表示允许无限数量的离散值)。这个集合包含块中的所有值(如果值的数量超过最大大小,则为空)。这种索引类型适用于每组颗粒中基数较低的列(基本上是“聚集在一起”),但整体基数较高。

此索引的成本、性能和有效性取决于块内的基数。如果每个块包含大量唯一值,则针对大型索引集评估查询条件将非常昂贵,或者由于超出最大大小,索引将不会被应用,因为索引是空的。

Bloom 过滤器类型

Bloom 过滤器是一种数据结构,允许高效的空间测试集合成员资格,但存在轻微的假阳性几率。在跳过索引的情况下,假阳性并不是一个重大问题,因为唯一的缺点是读取一些不必要的块。然而,假阳性的潜在可能意味着所索引的表达式应该被期望为真,否则有效数据可能会被跳过。

由于 Bloom 过滤器能够更高效地处理大量离散值的测试,它们可以适用于产生更多值以进行测试的条件表达式。特别是,Bloom 过滤器索引可以应用于数组,其中数组的每个值都被测试,或通过使用 mapKeys 或 mapValues 函数将键或值转换为数组应用于映射。

基于 Bloom 过滤器有三种数据跳过索引类型:

  • 基本的 bloom_filter,它接受一个允许的“假阳性”率的单个可选参数,范围在 0 到 1(如果未指定,则使用 .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 过滤器设置之前需要一个额外参数,即要索引的 ngram 的大小。ngram 是长度为 n 的字符字符串,包括任何字符,因此字符串 A short string 具有 ngram 大小 4 将被索引为:

'A sh', ' sho', 'shor', 'hort', 'ort ', 'rt s', 't st', ' str', 'stri', 'trin', 'ring'

该索引对于文本搜索也非常有用,尤其是对于没有单词间隔的语言,如中文。

跳过索引函数

数据跳过索引的核心目的是限制受欢迎查询分析的数据量。鉴于 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 上有索引。考虑以下查询:

SELECT timestamp, url FROM table WHERE visitor_id = 1001`

在这种数据分布下,传统的次级索引将非常有利。它不会读取所有 32768 行以查找请求的 visitor_id 的 5 行,而是次级索引仅包括五个行位置,并且仅会读取这五行数据。 ClickHouse 数据跳过索引则完全相反。无论跳过索引的类型如何,visitor_id 列中的所有 32768 个值都将被测试。

因此,尝试通过简单地向关键列添加索引来加速 ClickHouse 查询的自然冲动往往是错误的。在调查其他替代方案后,这种高级功能才应该使用,例如修改主键(请参见 如何选择主键)、使用投影或使用物化视图。即使数据跳过索引是合适的,仔细调整索引和表通常也是必要的。

在大多数情况下,有用的跳过索引需要主键与目标非主列/表达式之间存在较强的关联。如果没有关联(如上图所示),那么在包含数千个值的块中,至少有一行满足过滤条件的几率很高,因此很少会跳过块。相反,如果主键的值范围(例如一天中的时间)与潜在索引列中的值(例如电视观看者的年龄)强烈相关,则 minmax 类型的索引可能是有益的。请注意,在插入数据时可以通过在排序/ORDER BY 键中包含额外的列,或通过将与主键相关联的值在插入时分组来增加这种关联性。例如,尽管主键是包含来自大量站点的事件的时间戳,但某个特定 site_id 的所有事件可以在同一批次中分组并一起插入。这将导致许多颗粒只包含少量 site_id,因此在按特定 site_id 值搜索时可以跳过许多块。

对于高基数表达式,其中任何一个值在数据中相对稀疏,另一个好的跳过索引候选是。一个例子可能是跟踪 API 请求中的错误代码的可观测性平台。某些错误代码在数据中虽然较少出现,但可能对搜索特别重要。在 error_code 列上应用集合跳过索引将允许跳过绝大多数不包含错误的块,从而显著提高重点查询的效率。

最后,关键的最佳实践是测试、测试、再测试。与 b-tree 次级索引或用于搜索文档的倒排索引不同,数据跳过索引的行为并不容易预测。将它们添加到表中会在数据摄取和查询中产生显著成本,出于各种原因并不受益于索引的查询。它们应始终在实际数据类型上进行测试,测试应包括类型、粒度大小和其他参数的变化。测试往往会揭示出仅靠思考实验无法发现的模式和陷阱。