MergeTree原理详解之索引

前面我们提到了ClickHouse的MergeTree引擎,在ClickHouse众多的表引擎中,MergeTree引擎最为强大,在生产环境中的绝大多数场景都会使用此系列的表引擎。

值得注意的是只有MergeTree系列的表引擎才支持主键索引,数据分区,数据副本,数据采样这样的特性,只有此系列的表引擎才支持ALTER操作。

MergeTree表引擎在写入一批数据的时候,数据总会以数据片段的形式写入磁盘,并且数据片段不可修改。为了避免片段过多,clickhouse会通过后台的的线程,定期合并这些数据片段,属于不同分区的数据片段会被合并成一个新的片段,这就是MergeTree的基本特点。

MergeTree家族中还有其他表引擎是在它的基础上进行扩展,例如ReplacingMergeTree表引擎具有删除重复数据的特性;SummingMergeTree表引擎则会按照排序键自动聚合数据。加上Replicated前缀,又会得到一组支持数据副本的表引擎,例如ReplicatedMergeTree、ReplicatedReplacingMergeTRee、ReplicatedSummingMergeTree等。可以看出MergeTree是根基,只有吃透了MergeTree表引擎的原理,就能掌握该系列引擎的精髓。

下面将开启我们MergeTree原理详解系列的第一篇索引

表引擎语法结构

CREATE TABLE [IF NOT EXISTS] [db.]table_name [ON CLUSTER cluster]
(
    name1 [type1] [DEFAULT|MATERIALIZED|ALIAS expr1],
    name2 [type2] [DEFAULT|MATERIALIZED|ALIAS expr2],
    ...
    INDEX index_name1 expr1 TYPE type1(...) GRANULARITY value1,
    INDEX index_name2 expr2 TYPE type2(...) GRANULARITY value2
) ENGINE = MergeTree()
[PARTITION BY expr]
[ORDER BY expr]
[PRIMARY KEY expr]
[SAMPLE BY expr]
[SETTINGS name=value, ...]

代码解释:

  1. ENGINE:创建MergeTree的表引擎指定ENGINE=MergeTree()
  2. ORDER BY语句:排序键,用于指定在一个数据片段内数据以何种标准排序,默认情况下是主键与排序键相同。排序键可以指定单个列字段,也可以多个列字段。
  3. PARTITION BY:分区键,用于指定表数据以何种标准进行分区。分区键可以指定单个列字段,也可以是通过元祖形式使用使用多个列字段,还可以支持使用列表达式。若不声明分区键则clickhouse会生成一个名为all的分区。合理使用分区可以有效减少查询数据文件的扫描范围。
  4. PRIMARY KEY:主键,声明后会按照主键字段生成一级索引,用于加速表查询(这里使用的是稀疏索引,在后面会讲到)。默认情况下主键与排序键相同。所以在一般情况下,我们不用可以去通过PRIMARY KEY声明,直接使用ORDER BY指定主键。在一般情况下,单个数据片段内,数据与一级索引以相同的规则升序排列。MergeTree主键允许存在重复数据(可以用ReplacingMergeTree去重)
  5. SAMPLE BY:抽样表达式,用于声明数据以何种标准进行采样。若使用了此配置选项则在主键的配置中也需要声明同样的表达式。抽样表达式需要配合SAMPLE BY子查询使用,这项功能对于选取抽样数据十分有用。
  6. TTL:指定表级别的数据存活策略。
  7. SETTINGS:
    配置项:
    index_granularity:默认8192,表示索引的粒度,即MergeTree的索引在默认情况下,每间隔8192行才生成一个索引。通常不需要修改此参数。
    index_granularity_bytes:默认10MB(1010241024)表示自适应间隔大小的特性,即根据每一批写入数据的体量大小,动态划分间隔大小。设置为0表示不启用自适应功能。

MergeTree的存储结构

MergeTree表引擎中的数据拥有物理存储,数据会按照分区目录的形式存储到磁盘上,物理结构如下:
在这里插入图片描述
解释:

  1. parition:分区目录,partition_n目录下的各类数据文件都是以分区形式被组织存放的,属于相同分区的数据最终会被合并到一个分区目录内。
  2. checksums.txt:校验文件,使用二进制存储,保存了各类文件的size大小和size的哈希值,用于快速校验文件的完整性和正确性。
  3. columns.txt:列信息文件,使用文本文件存储,用于保存分区下的列字段信息,例如:
$ cat columns.txt
columns format version: 1
4 columns:
'ID' String
'URL' String
'Code' String
'EventTime' Date
  1. count.txt:计数文件,文本文件存储,用于记录当前数据分区目录下数据的总行数。
$ cat count.txt 
8
  1. primary.idx:索引文件,使用二进制格式存储。用于存放稀疏索引,一张MergeTree表只能声明一次一级索引(通过order by或者primary key)。借助稀疏索引在数据查询的时候能够排除主键范围之外的数据文件,从而减少数据扫描范围,加速查询速度。
  2. [column].bin:数据文件,使用压缩格式存储,默认使用lz4压缩格式,用于存储某一列的数据。由于MergeTree采用列式存储,每个列字段都有独立的bin数据文件,并以列字段命名。
  3. [column].mrk:列字段标记,使用二进制格式存储。标记文件中保存了bin文件中数据的偏移量信息,标记文件与稀疏文件对齐,又与bin文件一一对应,所以MergeTree通过标记文件建立了primary.idx稀疏索引与bin数据文件的映射关系。
    映射步骤下:首先通过primary.idx找到对应数据的偏移量信息(.mrk),再通过偏移量直接从bin文件中读取数据。由于.mrk标记文件与.bin文件一一对应,所以MergeTree中的每个列字段都会拥有与其对应的.mrk文件。
  4. [column].mrk2:如使用了自适应大小的索引间隔,则标记文件会以.mrk2命名。工作原理和作用和.mrk标记文件相同。
  5. partition.dat和minmax_[column].idx:若使用了分区键则会额外生成partition.dat和minmax索引文件,均使用二进制格式存储。partition.dat用于保存当前分区下分区表达式最终生成值,minmax索引文件用于记录当前分区字段对应原始数据的最小值和最大值。
    在分区索引作用下,进行数据查询时候能够快速跳过不必要的数据分区目录,从而减少最终需要扫描的数据范围。
  6. skp_idx_[column].idx和skp_idx_[column].mrk:若在建表语句中声明了二级索引则会额外生成相应的二级索引与标记文件,它们同样用二进制存储。
    二级索引在clickhouse中又称之为跳数索引 ,目前拥有minmax,set,ngrambf_v1和tokenbf_v1四种类型。
    这些索引的目标和一级稀疏索引相同,为了进一步减少所需要扫描的数据范围,以加速整个查询过程。

数据分区

在MergeTree中,数据是以分区目录的形式进行组织的,每个分区独立分开存储。借助这种形式,在对MergeTree进行数据查询时,可以有效跳过无用的数据文件,只使用最小的分区目录子集。

分区规则

MergeTree数据分区规则由ID决定,而具体到每个数据分区所对应的ID则是由分区键的取值决定的,分区键支持使用任何一个或者一组字段表达式声明,其业务语义可以是年月日或者组织单位等任何一种规则,针对取值数据类型的不同,分区ID有四种规则:

  1. 不指定分区键:不使用partition by 声明任何分区表达式,则分区ID默认取名为all,所有数据写入all分区。
  2. 使用整型:若分区键取值属于整型(兼容Uint64包含有符号整型和无符号整型)且无法转换为日期类型YYYYMMDD格式,则直接按照整型的字符串形式输出,作为分区ID的取值。
  3. 使用日期类型:若分区键取值属于日期类型,或者可以转为YYYYMMDD格式的整型则按照使用YYYYMMDD进行格式化后输出,并作为分区ID的取值。
  4. 使用其他类型:若分区键取值不属于整型或者日期类型,如String,float则通过128位的Hash算法取其Hash值作为分区ID的取值。数据在写入时,会对照分区ID落入相应的数据分区。

分区目录命名规则

通过上面我们已经知道分区ID的生成规则。但是我们实际看到的分区目录可能是这样的202012_1_1_0。那这又是啥意思呢?
一个完整分区目录的命名公式如下:
PartitionID_MinBlockNum_MaxBlockNum_Level

  1. PartitionID:分区ID。上面说过的
  2. MinBlockNum和MaxBlockNum:最小数据块编码和最大数据块编码(这里的数据块编号很容易和下面介绍的压缩数据块混淆,它们没有任何关系),这里的BlockNum是一个整型的自增长编号。一个MergeTree表,在内部全局累加,从1开始每当新创建一个分区目录,计数器就加1,对一个新分区MinBlockNum和MaxBlockNum一样,当分区目录发生合并的时候,新产生的合并目录MinBlockNum和MaxBlockNum有另外的取值规则,下面会讲到。
  3. Level:合并的层级,也可以理解为某个分区被合并过的次数,数值越大则合并的次数越多。对于一个新创建的分区目录,初始值是0,从此以分区为单位若相同分区发生合并动作,则在相应分区内计数器加1。

示例:
202007_1_1_0

202007_3_3_0

202007_1_3_1

202008_2_2_0

分区目录的合并过程

  1. MergeTree的分区目录是在数据写入过程中被创建的。也就是一张新建的表,如果没有任何数据,那么也不会有任何的分区存在。
  2. MergeTree的分区目录伴随着每一批数据的写入(一次insert语句),mergetree都会生成一批新的分区目录,即便不同批次写入的数据属于相同分区,也会生成不同的分区目录
  3. 每次insert都会产生一个分区,那么就存在多个相同分区的情况,clickhouse会通过后台任务再将相同分区的多个目录合并成一个新的目录(写入10-15分钟,也可以手动执行optimize查询语句)。已经存在的旧目录并不会立即被删除,而是在之后的某个时刻通过后台被删除(默认8分钟)。
  4. 同属于一个分区的多个目录,在合并之后会形成一个全新的目录,目录中的索引和数据文件也会相应的进行合并。新目录名称的合并规则如下:
    MinBlockNum:取同一分区内所有目录中最小的MinBlockNum值
    MaxBlockNum:取同一分区内所有目录中最大的MaxBlockNum值
    Level:取同一分区内最大Level值并加1
    在这里插入图片描述

一级索引

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

注意:order by和primary key定义有差异的场景是SummingMergeTree引擎

稀疏索引

primary.idx文件内的一级索引采用稀疏索引实现。在稀疏索引中每一行索引标记对应的是一段数据,而不是一行。
这样做的好处就是只使用少量的索引标记就能够记录大量的数据,数据量越大这种优势就越明显。默认的索引粒度是8192行,也就是说ClickHouse只需要12208行索引标记就能为1亿行数剧记录提供索引。稀疏索引占用空间小,所以primary.idx内的索引会保存在内存中,速度极快。
就像这样:
在这里插入图片描述

索引粒度

我们前面已多次讲到index_granularity这个参数,它表示索引粒度。索引粒度对MergeTree而言是一个非常重要的概念。数据以index_granularity(8192)标记每隔多少行产生1个索引。MergeTree使用MarkRange表示一个具体的区间,并通过start和end表示其具体的范围。index_granularity不但作用于一级索引还会引响标记文件和数据文件。因为只有一级索引文件是无法完成查询工作的,需要借助标记来定位数据,所以一级索引和数据标记的间隔粒度相同,彼此对齐,而数据文件也会按照index_granularity的间隔粒度生成压缩数据块,后面会具体讲到。

索引生成规则

由于是稀疏索引,所以MergeTree需要间隔index_granularity行数据才会生成一条索引记录,其索引值会依据声明的主键字段获取。如图:
在这里插入图片描述
从上图可以看出,第0行userId为101,第8192行userId取值1688,第16384行,userId取值3266,最终索引数据将会是10116883842。这样的稀疏索引存储是非常紧凑的,索引值前后相连。可见ClickHouse对于每一处细节都拿捏的非常到位,不浪费任何一个字节空间。
如果有多个主键则会进行拼接,如图
在这里插入图片描述
索引值最终会被写入primary.idx文件进行保存。

MarkRange

MarkRange在ClickHouse中是用于标记区间的对象。前面介绍到ClickHouse会按照index_granularity的间隔粒度,将一段完整的数据划分成多个小的间隔数据段,那么一个数据段就对应一个MarkRange,MarkRange和索引编号对应,使用start和end两个属性表示其区间范围。
简单举个例子:
假设现在有一份测试数据,共192行记录。主键ID为string类型,从U000开始,依次为U001,U002直到U192。index_granularity设置为3,根据索引生成规则,primary.idx文件中就会有如下内容:
在这里插入图片描述
解释:192行的数据记录,根据index_granularity=3会分为64个小的MarkRange,两个相邻的MarkRange相距的步长为1,最大的MarkRange区间为[U000,+inf)表示所有数据。如图所示:
在这里插入图片描述

索引查询流程

在了解了索引的粒度,生成规则,MarkRange等等一些概念后,接着上面的例子,接下来讲讲ClickHouse是怎么运用这一套完成整个索引的查询过程。
索引查询其实就是两个数值区间的交集判断。其中,一个区间是由基于主键的查询条件转换而来的条件区间;而另一个区间是刚才所讲述的与MarkRange对应的数值区间。
整个索引查询过程大致可以分为3个步骤:
(1)将查询条件转为条件区间:

WHERE ID = 'U001'
['U001','U001']

WHERE ID > 'U000'
['U000',+inf)

WHERE ID LIKE 'U003'
['U003','U004')

WHERE ID < 'U189'
(-inf,'U189')

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

  • 如果不存在交集,则直接通过剪枝算法优化此整段MarkRange。
  • 如果存在交集,且MarkRange步长大于8(end-start),则将此区间进一步拆分成8个子区间(由merge_tree_coarse_index_granularity指定,默认是8),并重复此规则,继续做递归交集判断。
  • 如果存在交集,且MarkRange不可再分解(步长小于8),则记录MarkRange并返回。
    (3)合并MarkRange区间:将最终匹配的MarkRange聚在一起,合并它们的范围。

以查询WHERE ID='U003’为例,最终只需要读取[U000,U003]和[U003,U006]两个区间的数据,它们对应MarkRange(start:0,end:2)范围,其它无用的区间都被裁减掉。可能有人会问,这里为什么是两个区间,因为MarkRange转换的数值区间是闭区间,所以会额外匹配到邻近的一个区间。

完整流程如下图所示:
在这里插入图片描述

二级索引

MergeTree支持二级索引,也叫做跳数索引,是由数据的聚合信息构建而成。根据索引类型的不同,其聚合信息的内容也不同,但它的目的和一级索引是一致的,都是为了帮助查询时减少数据扫描范围。

在旧版本中跳数索引默认是关闭的,需要设置allow_experimental_data_skipping_indices=1(新版本中已经取消)。
二级索引需要在create语句中定义,支持用元组和表达式的形式声明,其完整的定义语法如下所示:

INDEX index_name expr TYPE index_type(...) GRANULARITY granularity

和一级索引一样,若在创建表语句中声明了二级索引则会生成相应的索引与标记文件(skpi_idx_[Column].idx与skip_idx_[Column].mrk)

granularity与index_granularity关系

不同的二级索引之间,除了他们自身独有的参数之外,还都共同拥有granularity参数。granularity定义了一行跳数索引能够跳过多少个index_granularity区间的数据。以minmax为例如下图所示
在这里插入图片描述
MergeTree从第0段分区开始,依次获取聚合信息。当获取到第3个分区时(granularity=3),则汇总并会生成第一行minmax索引(前3段minmax极值汇总后取值为[1,9])。

跳数索引的类型

目前跳数支持四种跳数索引,分别是minmax,set,ngrambf_v1,tokenbf_v1。一张表支持同时声明多个跳数索引。

minmax

minmax索引记录了一段数据内的最小值和最大值,其索引的作用类似于分区目录的minmax索引,能够快速跳过无用的数据区间。granularity=5表示计算涉及5个index_granularity区间的数据

set

set索引直接记录了声明字段或表达式的取值(唯一值,无重复),其完整形式为set(max_rows),其中的max_rows是一个阈值,表示在一个index_granularity内,索引最多记录的数据行数。若max_rows=0表示无限制。

例如:ix_length length(id) * 8 TYPE set(100) GRANULARITY 5表示set索引值会取ID的长度乘以8当作唯一值,每个索引内最多有100条记录

ngrambf_v1

ngrambf_v1(n, size_of_bloom_filter_in_bytes, number_of_hash_functions, random_seed)

存储一个包含数据块中所有 n元短语(ngram) 的 布隆过滤器 。只可用在字符串上。
可用于优化 equals , like ,in,notIn,equals,notEquals 的性能。

n:短语长度,依据n的长度将数据切割为token短语。
size_of_bloom_filter_in_bytes:布隆过滤器大小,单位字节。
number_of_hash_functions:布隆过滤器中使用的哈希函数的个数
random_seed:布隆过滤器的随机种子。

INDEX c(ID, Code) TYPE ngrambf_v1(3, 256, 2, 0) GRANULARITY 5
tokenbf_v1

tokenbf_v1(size_of_bloom_filter_in_bytes, number_of_hash_functions, random_seed)

它的作用跟ngrambf_v1类似,同样也是一种布隆过滤器索引,不同的是它会自动按照非字符的、数字的字符串分割token。

INDEX d ID TYPE tokenbf_v1(256, 2, 0) GRANULARITY 5

当我们定义了跳数索引,它会在我们前面讲到的通过主键索引筛选出来粗糙的MarkRange的基础上使用跳数索引进一步过滤。

好啦,ClickHouse关于MergeTree索引的原理今天就分享到这里,后面我们将会持续MergeTree原理系列,下一篇将分享关于MergeTree的数据存储原理

微信公众号:喜讯Xicent

image

  • 12
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值