一级索引

MergeTree的主键使用PRIMARY KEY定义,待主键定义之后,MergeTree会依据index_granularity间隔(默认8192行),为数据表生成一级索引并保存至primary.idx文件内,索引数据按照PRIMARY KEY排序。相比使用PRIMARY KEY定义,更为常见的简化形式是通过ORDER BY指代主键。在此种情形下,PRIMARY KEY与ORDER BY定义相同,所以索引(primary.idx)和数据(.bin)会按照完全相同的规则排序。

稀疏索引

primary.idx文件内的一级索引采用稀疏索引实现。稀疏索引和稠密索引的区别如图所示:

一级页表和一级索引的区别 一级索引文件_主键

在稠密索引中每一行索引标记都会对应到一行具体的数据记录。而在稀疏索引中,每一行索引标记对应的是一段数据,而不是一行。用一个形象的例子来说明:如果把MergeTree比作一本书,那么稀疏索引就好比是这本书的一级章节目录。一级章节目录不会具体对应到每个字的位置,只会记录每个章节的起始页码。

稀疏索引的优势是显而易见的,它仅需使用少量的索引标记就能够记录大量数据的区间位置信息,且数据量越大优势越为明显。以默认的索引粒度(8192)为例,MergeTree只需要12208行索引标记就能为1亿行数据记录提供索引。由于稀疏索引占用空间小,所以primary.idx内的索引数据常驻内存,取用速度自然极快。

索引粒度

索引粒度(index_granularity)对MergeTree而言是一个非常重要的概念,它就如同标尺一般,会丈量整个数据的长度,并依照刻度对数据进行标注,最终将数据标记成多个间隔的小段:

一级页表和一级索引的区别 一级索引文件_数据_02

数据以index_granularity的粒度(默认8192)被标记成多个小的区间,其中每个区间最多8192行数据。MergeTree使用MarkRange表示一个具体的区间,并通过start和end表示其具体的范围。index_granularity的命名虽然取了索引二字,但它不单只作用于一级索引(.idx),同时也会影响数据标记(.mrk)和数据文件(.bin)。因为仅有一级索引自身是无法完成查询工作的,它需要借助数据标记才能定位数据,所以一级索引和数据标记的间隔粒度相同(同为index_granularity行),彼此对齐。而数据文件也会依照index_granularity的间隔粒度生成压缩数据块。

索引数据生成过程

假设我们定义表hits_v1如下,按照月份进行分区,按照CounterID设置主键,我们来看下索引数据是如何生成的。

CREATE TABLE hits_v1 (
    CounterID Int64,
    EventDate Date
) ENGINE = MergeTree()
PRIMARY KEY CounterID  -- 也可以不写,默认和排序键保持一致
ORDER BY CounterID
PARTITION BY toYYYYMM(EventDate)

由于是稀疏索引,所以需要间隔index_granularity行数据才会生成一条索引,索引值会根据声明的主键字段获取。hits_v1使用年月分区(PARTITION BYtoYYYYMM(EventDate)),所以2014年3月份的数据最终会被划分到同一个分区目录内。如果使用CounterID作为主键(ORDER BY CounterID),则每间隔8192行数据就会取一次CounterID的值作为索引值,索引数据最终会被写入primary.idx文件进行保存。例如第0(8192 * 0)行CounterID取值57,第8192(8192 * 1)行CounterID取值1635,而第16384(8192 * 2)行CounterID取值3266,最终索引数据将会是5716353266,可以看出MergeTree对于稀疏索引的存储是非常紧凑的,索引值前后相连,按照主键字段顺序紧密地排列在一起,如下所示:

一级页表和一级索引的区别 一级索引文件_clickhouse_03

如果使用多个主键,例如ORDER BY (CounterID, EventDate),则每间隔8192行可以同时取CounterID与EventDate两列的值作为索引值,具体如下图所示:

一级页表和一级索引的区别 一级索引文件_数据_04

索引查询过程

经过上面的讲述,我们知道了一级索引的生成过程,那么我们在查询时候是如何配合一级索引来精确定位数据的呢?首先我们需要了解什么是 MarkRange,MarkRange 在 ClickHouse 中是用于定义标记区间的对象。MergeTree 按照 index_granularity 的间隔粒度,将一段完整的数据划分成了多个小的间隔数据段,一个具体的数据段就是一个 MarkRange,并与索引编号对应,使用start 和 end 两个属性表示其范围。通过 start 和 end 对应的索引编号的取值,即可得到它所对应的数值区间,而数值区别表示了此 MarkRange 的数据范围。

假如现在有一份测试数据,共192行记录。其中,主键ID为String类型,ID的取值从A000开始,后面依次为A001、A002……直至A192为止,假设MergeTree的索引粒度index_granularity=3,那么我们生成的索引文件如下:

一级页表和一级索引的区别 一级索引文件_数据_05

根据索引数据,MergeTree会将此数据片段划分成192/3=64个小的MarkRange,两个相邻MarkRange相距的步长为1。其中,所有MarkRange(整个数据片段)的最大数值区间为[A000,+inf),其完整的示意如图所示。

一级页表和一级索引的区别 一级索引文件_clickhouse_06

索引查询其实就是两个区间的交集判断。其中一个区间是由基于主键的查询条件转换而来的条件区间;另一个区间就是上面说的与 MarkRange 对应的数值区间。我们来看下整个查询过程:

1. 生成查询区间:首先将查询条件转换为区间,即使是单个值也会转换为区间的形式,举个栗子:

WHERE ID = 'A003' -> ['A003', 'A003']
WHERE ID > 'A012' -> ('A012', +inf]
WHERE ID < 'A185' -> [-inf, 'A185')
WHERE ID LIKE 'A006%' -> ['A006', 'A007')

2. 递归交集判断:以递归的形式,依次对 MarkRange 的数值区间与条件区间做交集判断,从最大的区间 [A000, +inf) 开始:

  • 如果不存在交集,则直接通过剪枝算法优化此整段 MarkRange;
  • 如果存在交集,且 MarkRange 步长大于等于 8(end - start),则将此区间进一步拆分成 8 个子区间(由 merge_tree_coarse_index_granularity 指定,默认值为 8),然后重复此过程,继续做递归交集判断;
  • 如果存在交集,且 MarkRange 不可再分解(步长小于 8),则记录 MarkRange 并返回。

3)合并 MarkRange 区间:将最终匹配的 MarkRange 聚在一起,合并它们的范围。

我们通过下面这张图,来展示一下上面的几个步骤,以上面的测试数据为例,查询条件为 ID = ‘A003’,

一级页表和一级索引的区别 一级索引文件_一级页表和一级索引的区别_07

MergeTree通过递归的形式持续向下拆分区间,最终将 MarkRange 定位到最细的粒度,以便在后续读取数据的时候,能够最小化数据的扫描范围。以上图为例,当查询条件为 ID = ‘A003’ 的时候,最终只需要读取 [A000, A003] 和 [A003, A006] 两个区间的数据,它们对应 MarkRange(start:0, end:2) 范围,而其它无用区间都被裁剪掉了。由于 MarkRange 转换的数值区间是闭区间,所以会额外匹配到临近的一个区间。

一级索引设计

在建表时,我们需要指定【排序字段】,这个就是主索引(建表sql中的order by部分)。索引的本质是通过排序的方式,跳过无用的数据扫描,加速查询。所有查询条件中的【where】都是数据过滤部分,该部分的条件,Clickhouse内部会尝试提高到索引过滤。我们的索引都是有序的,并且是前缀匹配的,比如索引是(A,B,C),那么查询(A), (A,B), (A, B, C),均可能会有不错的过滤效果,而查询(B), ©, (B, C)等,基本没有什么过滤效果。一般来说,我们可以按照下面方式进行排序键选取:

  1. 列出该表常用的 SELECT 语句。
  2. 对 WHERE 条件后的列,按使用比重选取出 [1,5] 个作为备选。
  3. 根据备选列的基数从小到大排序,得出最终排序键的顺序。
  4. 排序键数量保持在 [1,5] 个。不设置和超出数量都不好。

对于多个排序字段的顺序,可以遵循两个原则:

  • WHERE 子句中出现频次高的字段放到频率低字段的前面,增加查询命中索引的概率;
  • 维度基数大的字段放到维度基数小字段的后面,降低查询扫描范围。

参考

  1. https://mp.weixin.qq.com/s/Aa7BbutLoCK1Yn5vC5EuEA/
  2. https://mp.weixin.qq.com/s/VTTYMdY5A2SZNQdkZoXuhw
  3. https://clickhouse.com/docs/zh/guides/improving-query-performance/sparse-primary-indexes/