3.ClickHouse的MergeTree原理解析

六、MergeTree原理解析

6.1 MergeTree创建方式

​ MergeTree在写入数据时,数据总会以数据片段的形式写入磁盘,且数据片段不可修改。为了避免片段较多,clickhouse通过后台进程,定期合并这些数据片段,属于相同分区的数据片段会被合成一个新的片段。

​ MergeTree支持主键索引,数据分区,数据副本和数据采用,支持ALTER操作。

创建方式

CREATE TABLE [IF NOT EXISTS] [db.]table_name [ON CLUSTER cluster]

(

name1 [type1] [DEFAULT|MATERIALIZED|ALIAS expr1] [TTL expr1],

name2 [type2] [DEFAULT|MATERIALIZED|ALIAS expr2] [TTL expr2],

...

INDEX index_name1 expr1 TYPE type1(...) GRANULARITY value1,

INDEX index_name2 expr2 TYPE type2(...) GRANULARITY value2

) ENGINE = MergeTree()

ORDER BY expr

[PARTITION BY expr]

[PRIMARY KEY expr]

[SAMPLE BY expr]

[TTL expr [DELETE|TO DISK 'xxx'|TO VOLUME 'xxx'], ...]

[SETTINGS name=value, ...]


PARTITION BY 			
			分区键:表示表数据会以何种标准进行分区;默认all分区。
    		分区方式:单列、元组形式使用多列或者使用列表达式。
    		合理使用数据分区,可以有效减少查询时数据文件的扫描范围。

ORDER BY	
			排序键:用于指定在一个数据片段内,数据以何种标准排序;默认情况和主键相同。
			排序方式:单列、元组形式使用多列。ORDER BY (counterID,EventDate)为例,在单个数据片段中,数据首先以counterID排序,相同的counterID,在按照EventDate排序。

PAIMARY KEY
			主键:会按照主键字段生成一级索引,用于加速表查询;默认情况下,主键个ORDER BY相同。

SAMPLE BY
			抽样表达式:用于声明数据以何种标砖进行采样。

SETTINGS:index_granularity 
			index_granularity对于MergeTree表示索引粒度,默认值8192.(每隔8192行数据生成一条索引)

SETTINGS:index_granularity_bytes
			19.11前:clickhouse只支持固定大小的索引间隔,由index_granularity控制,默认8192。
			在新版本:自适应间隔大小。根据每一批次写入数据体量大小,动态划分间隔大小。数据体量由index_granularity_bytes控制,默认10M(10*1024*1024),设置为0不启动自适应功能。

SETTINGS:enable_mixed_granularity_parts
			是否开启自适应索引间隔,默认开启

SETTINGS:merge_with_ttl_timeout			数据TTL功能
			
SETTINGS:storage_policy					多路径存储策略
```



CREATE TABLE test20(ID String,Price Int32,Val Float64,EventTime Date) engine = MergeTree() PARTITION BY toYYYYMM(EventTime) ORDER BY ID

create table test (id UInt8,name String,age UInt8,shijian Date) engine = MergeTree() partition by toYYYYMM(shijian) order by id

6.2 MergeTree存储结构

MergeTree表引擎中数据拥有物理存储,数据会按分区目录的形式保存到磁盘中

在这里插入图片描述

[root@postgresql test 08:51:37]# tree test20

test20

├── 202005_1_3_1				分区目录

│   ├── checksums.txt			校验文件,保存余下各类文件的size大小及size的哈希值,校验数据完整性

│   ├── columns.txt				列信息文件。明文格式存储列字段名称和数据类型。

│   ├── count.txt				计数文件。明文记录当前数据分区目录下的数据总行数

│   ├── EventTime.bin			

│   ├── EventTime.mrk2

│   ├── ID.bin					数据文件。使用压缩格式存储(默认LZ4),存储某一列数据

│   ├── ID.mrk2

│   ├── minmax_EventTime.idx	分区键的索引文件,记录当前分区下分区字段对应原始数据的最小和最大值

│   ├── partition.dat			分区键(使用了PARTITION BY),保存前分区下分区表达式最终生成的值

│   ├── Price.bin				

│   ├── Price.mrk2				使用了自适应大小索引间隔的列标记文件,二进制存储,保存.bin文件中数据的偏移量信息

│   ├── primary.idx				一级索引文件,二进制格式存储。一张MergeTree()表只能声明一次一级索引(primary key或者order  by)

│   ├── Val.bin				

│   └── Val.mrk2

├── detached

└── format_version.txt

2 directories, 15 files

6.3 数据分区

数据分区:针对本地数据,对数据一种纵向切分

数据分片:针对CK集群,以横向切割数据。

6.3.1 数据分区规则

MergeTree数据分区的规则由ID决定,而具体到每个数据分区所对应的ID,则是由分区键的取值决定的。

分区ID生成逻辑四种规则:

1.不指定分区键			分区ID默认为all

2.使用整型				 直接按照该整形的字符形式输出作为分区ID的取值

3.使用日期类型			按照YYYYMMDD格式化后字符形式输出作为分区ID取值

4.使用其他类型			分区键取值不属于整型,也不属于日期,例如String、Float则会通过128位Hash算法取其Hash值作为分区ID

PartitionID在不同规则下示例

类型 样例数据 分区表达式 分区ID
无分区键 all
整型 18,19,20 PARTITION BY Age 分区1:18;分区2:19;分区3:20
整型 ‘A0’,‘A1’,‘A3’ PARTITION BY length(Code) 分区1:2
日期 2019-02-01,019-06-11 PARTITION BY EventTime 分区1:20190201;分区2:20190611
日期 2019-05-01,2019-06-11 PARTITION BY toYYYYMM(EventTime) 分区1:201905;分区2:201906
其他 ‘www.oldba.cn’ PARTITION BY URL 分区1:15r515rs15gr15615wg5e5h5548h3045h

6.3.2 数据分区目录命名规则

举例说明:

202005_1_3_1					此目录直观来看,采用时间年月作为分区ID,分三次插入到同一分区,并且三次插入完成之后的某个时刻进行了一次数据合并。

202005		PartitionID			分区目录ID

1			MinBlockNum			最小数据块编号 (默认和MaxBlockNum从1开始)

3			MaxBlockNum			最大数据块编号 (发生合并时取合并时的最大数据块编号)

1			Level				合并的层级,某个分区被合并过的次数或者这个分区的年龄。(每次合并自增+1)

6.3.3 数据分区合并过程

MergeTree分区目录创建:数据写入的过程中创建;创建之后在写入数据或者合并,目录也会变化。

​ 也就是说:一张表没有任何数据,那不会有任何分区目录存在。

MergeTree分区目录合并过程:

​ 伴随每次写入数据(insert),MergeTree都会生成一批新的分区目录(即使不同批次写入的数据属于相同分区,也会生成不同的分区目录)。在写入后的某个时刻,ClickHouse会通过后台任务再将属于相同分区的多个目录合并成一个新目录。已经存在的旧分区并不会立即删除,而是在之后的某个时刻通过后台任务删除(默认8分钟)。

新目录名称的合并规则:

​ MinBlockNum:取同一分区内所有目录中最小的MinBlockNum值。

​ MaxBlockNum:取同一分区内所有目录中最大的MaxBlockNum值。

​ Level:取同一分区内最大Level值并加1。

create table test(id UInt32,name String,age UInt8,shijian DateTime) engine = MergeTree() PARTITION BY toYYYYMM(shijian) ORDER BY id

insert into test values (1,'张三',18,'2020-12-08')					t1时刻

insert into test values (2,'李四',19,'2020-12-08')					t2时刻
	
insert into test values (3,'王五',22,'2021-01-03')					t3时刻

insert into test values (2,'李四',19,now())							t4时刻

SELECT now()

┌───────────────now()─┐
│ 2020-12-08 11:36:42 │
└─────────────────────┘

		
按照上述规则未合并时的目录:
PARTITIONID 		202012
MinBlockNum			1
MaxBlockNmu			1
							对于新建分区,它们的值一样(来源表内全局自增的BlockNum),初始值为1,每次新建目录累计加1level				0				
    
    	202012_1_1_0												t1时刻的目录

		202012_2_2_0												t2时刻的目录

		202101_3_3_0												t3时刻的目录

		202012_4_4_0												t4时刻的目录

按照上述规则合并时的目录:

假设在t2~t3时刻之间发生了合并,那么此时只有一个目录:202012_1_2_1

假设在t3~t4时刻之间发生了合并,那么此时肯有两个目录:202012_1_2_1,202101_3_3_0

假设在t4时刻之后发生了合并,那么此时也肯定有两个目录:202012_1_4_2,202101_3_3_0

注意:
在创建完成之后的某个时刻进行合并,必须是相同分区才会合并,生成新的分区,同时将旧分区目录状态设置为非激活,然后在默认8分钟之后,删除非激活状态的分区目录。

6.4 一级索引

MergerTree 指定主键方式:

​ 1.PRIMARY KEY MergerTree会根据index_granularity间隔(默认8192行)为数据表生成一级索引保存在primary.idx文件中,根据主键排序

​ 2.ORDER BY .bin 文件按完全相同PRIMARY KEY的规则排序

6.4.1 稀疏索引

primary.idx文件内的一级索引采用稀疏索引实现

​ 稠密索引:每一行索引标记对应一行具体的数据记录

​ 稀疏索引:每一行索引标记对应一段具体的数据记录

​ 两者比较:

​ a 稀疏索引占用的索引存储空间比较小,但是查找时间较长; 数据量大场景,利用primary.idx内的索引数据常驻内存

		  b  稠密索引查找时间较短,索引存储空间较大。			  		  			数据量小场景

在这里插入图片描述

6.4.2 索引粒度

​ 数据以index_granularity的粒度(默认固定索引粒度8192)被标记成多个小空间,其中每个空间最多8192行数据。这段空间的具体区间就是MarkRange,并且通过start和end表示具体的范围。

在这里插入图片描述

6.4.3 索引数据生成规则

​ 由于是稀疏索引,所以MergeTree需要间隔index_granularity行数据才会生成一条索引记录,其索引值会依据声明的主键字段获取。图6-8所示是对照测试表hits_v1中的真实数据具象化后的效果。hits_v1使用年月分区(PARTITION BYtoYYYYMM(EventDate)),所以2014年3月份的数据最终会被划分到同一个分区目录内。如果使用CounterID作为主键(ORDER BY CounterID),则每间隔8192行数据就会取一次CounterID的值作为索引值,索引数据最终会被写入primary.idx文件进行保存。

preview

​ 例如第0(81920)行CounterID取值57,第8192(81921)行CounterID取值1635,而第16384(8192*2)行CounterID取值3266,最终索引数据将会是5716353266。

 从图中也能够看出,MergeTree对于稀疏索引的存储是非常紧凑的,索引值前后相连,按照主键字段顺序紧密地排列在一起。不仅此处,ClickHouse中很多数据结构都被设计得非常紧凑,比如其使用位读取替代专门的标志位或状态码,可以不浪费哪怕一个字节的空间。以小见大,这也是ClickHouse为何性能如此出众的深层原因之一。

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

preview

6.4.4 索引查询过程

MergeTree按照index_granularity的间隔粒度,将一段完整的数据划分成多个小的间隔数据段,一个具体的数段就是MarkRange。

MarkRange与索引编号对应,使用start和end表示具体的范围。

通过start及end对应的索引编号取值,即能得到它所对应的数值区间。

索引查询其实就是两个数值区间的交集判断:

​ 1.一个区间是由基于主键的查询条件转换而来的条件区间;

​ 2.一个区间是MarkRange对应的数值区间。

索引查询过程:

1.生成查询条件区间:将查询条件转换为条件区间

	where ID = 'A003'			['A003','A003']

	where ID > 'A000'			('A000','+inf')

	where ID LIKE 'A006%'		['A006','A007')

2.递归交集判断:以递归的形式,依次对MarkRange的数值区间与条件区间做交集判断。

	如果不存在交集,则直接通过剪枝算法优化此整段MarkRange

	如果存在交集,且MarkRange步长大于8(end-start),则将此区间进一步拆分成8个子区间(由merge_tree_coarse_index_granularity指定,默认为8),并重复此规则,继续做递归交集判断

	如果存在交集,且MarkRange不可再分割(步长小于8),则记录MarkRange并返回

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

在这里插入图片描述

​ 索引查询完整过程图示

6.4.5 二级索引(跳数索引)

由数据的聚合信息构建而成。不同的索引类型,聚合信息内容也不同。

MergeTree支持跳数索引类型:minmax、set、ngrambf_v1和tokenbf_v1。一张表同时支持声明多个跳数索引。

跳数索引默认情况是关闭的,需要设置set allow_experimental_data_skipping_indiced = 1

对于跳数索引,index_granularity定义了数据的粒度,而granularity定义了聚合信息汇总的粒度。

granularity定义了一行跳数索引能够跳过多少个index_granularity区间的数据。

要解释清楚granularity的作用,就要从跳数索引的数据生成规则说起,其规则大致是这样的:首先,按照index_granularity粒度间隔将数据划分成n段,总共有[0 ,n-1]个区间(n = total_rows / index_granularity,向上取整)。接着,根据索引定义时声明的表达式,从0区间开始,依次按index_granularity粒度从数据中获取聚合信息,每次向前移动1步(n+1),聚合信息逐步累加。最后,当移动granularity次区间时,则汇总并生成一行跳数索引数据。

​ 以minmax索引为例,它的聚合信息是在一个index_granularity区间内数据的最小和最大极值。以下图为例,假设index_granularity=8192且granularity=3,则数据会按照index_granularity划分为n等份,MergeTree从第0段分区开始,依次获取聚合信息。当获取到第3个分区时(granularity=3),则汇总并会生成第一行minmax索引(前3段minmax极值汇总后取值为[1 , 9]),如图所示。

preview

6.4.6 数据存储

各列独立存储

MergeTree中,数据按列存储。具体到每个列字段,每个列字段都拥有一个与之对应的.bin数据文件(物理存储)。

.bin文件只会保存当前分区片段内的这一部分数据。

​ 首先,数据是经过压缩,(目前支持:LZ4,ZSTD、Multiple和Delta几种算法);

​ 其次,数据会事先按照ORDER BY 的声明排序;

​ 最后,数据以多个压缩数据块的形式被组织并写入.bin文件中的。

压缩数据块

一个压缩数据块由头信息和压缩数据两部分组成。头信息固定使用9位字节表示,具体由1个UInt8(1字节)整型和2个UInt32(4字节)整型组成,分别代表使用的压缩算法类型、压缩后的数据大小和压缩前的数据大小。

preview

从图所示中能够看到,.bin压缩文件是由多个压缩数据块组成的,而每个压缩数据块的头信息则是基于CompressionMethod_CompressedSize_UncompressedSize公式生成的。

​ MergeTree在数据具体写入过程中,会按照索引粒度,按批次获取数据并进行处理。如下图:

preview

​ 多对一 1.单个批次数据SIZE < 64KB;如果单个批次数据小于64KB,则继续获取下一批数据,直至累积到SIZE>=64KB时,生成下一个压缩数据块;

​ 一对一 2.单个批次数据64KB<=SIZE<=1MB:如果单个批次数据大小恰好在64KB与1MB之间,则直接生成下一个压缩数据块

​ 一对多 3.单个批次数据SIZE>1MB;如果单个批次数据直接超过1MB,则首先按照1MB大小截断并生成下一个数据块。剩余数据继续按照大小判断执行。

总结:一个.bin文件由1至多个压缩数据块组成,每个压缩块大小在64KB~1MB之间。多个压缩块之间,按顺序写入首尾相接。

preview

.bin文件引入压缩块的目的:

​ 1.数据被压缩后能有效减少数据大小,降低存储空间,加速数据传输效率;但是压缩、解压效率也会影响性能。

​ 2.再具体读取某一列数据时(.bin文件),首先需要将压缩数据加载到内存中解压读取。那就是通过压缩块(64KB~1MB)可以不读取整个.bin文件的情况下将读取粒度降低到压缩块级别。

6.5 数据标记

6.5.1 数据标记生成规则

primary.idx 一级索引

.bin 数据文件

.mrk为一级索引和数据文件之间建立关联。主要记录两个信息:

​ 1.一级索引对应的页码信息;

​ 2.一段文字在某一页中的起始位置。

在这里插入图片描述

数据标记特征:1.数据标记文件和索引区间是对齐的。都是按照index_granularity的粒度间隔划分。

​ 2.数据标记文件和.bin文件也是一一对应。每一个列字段[column].bin文件都有一个对应的[column].mrk数据标记文件,用于记录数据在.bin文件中偏移量信息。

一行标记数据使用元组表示,包含两个整型数据的偏移信息(压缩文件中偏移量,解压缩块中的偏移量)

每一行标记数据都表示了一个片段的数据(默认8192行)在.bin压缩文件中的读取位置信息

标记数据与一级索引不同,它不能常驻内存,而是使用LRU(最近最少使用)缓存策略加快其取用速度。

6.5.2 数据标记的工作方式

在MergeTree读取数据时,必须通过标记数据的位置信息找到所需要的数据。

查找过程大致分为读取压缩数据块和读取数据两个步骤。

在这里插入图片描述

​ JavaEnable字段的数据类型为UInt8,所以每行数据占用1字节。

​ 数据表的index_granularity粒度为8192,所以每一个索引片段大小正是8192B。

​ 按照数据压缩块规则,8192B<64KB,当等于64KB压缩为下一个数据块。(64KB/8192B=8,也就是8行数据为一个数据压缩块)

MergeTree如何定位压缩数据块并读取数据:

​ 1.读取压缩数据块:在查询某一列数据MergeTree无须一次性加载整个.bin文件。借住标记文件中的压缩文件偏移量加载指定的数据压缩块。

​ 2.读取数据:解压后的数据,MergeTree并不需要一次性扫描整段解压数据,借住标记文件中保存的数据块中偏移量以index_granularity的粒度加载特定一小段

6.6 对于分区、索引、标记和压缩数据的协同总结

6.6.1 写入过程

​ 1.生成分区目录(伴随每一次insert操作,生成一个新的分区目录);

​ 2.在后续的某个时刻,合并相同分区的目录;

​ 3.按照index_granularity索引粒度,分别生成primary.idx索引文件、二级索引、每一列字段的.mrk数据标记和.bin压缩数据文件。

​ 索引和标记区间对应,标记区间与压缩块区间不同,生成一对一,一对多,多对一的三种关系。

preview

根据分区目录:201403_1_34_3得知:

​ 该分区的N行数据,34次分批写入,合并3次。

6.6.2 查询过程

​ 1.minmax.idx (分区索引)

​ 2.primary.idx (一级索引)

​ 3.skp_idx.idx (二级索引)

​ 4…mrk (标记文件)

​ 5…bin (数据压缩文件)

preview

查询语句中没有where条件,1,2,3步骤不走;先扫描所有分区目录,及目录内索引段的最大区间,MergeTree借住数据标记,多线程的形式读取多个压缩块。

6.6.3 数据标记与压缩数据块的对应关系

压缩块的划分:

​ 索引粒度(index_granularity)的大小,及压缩块的三种规则决定数据块的大小在64KB~1MB。

​ 而一个索引间隔的数据,产生一行数据标记。

多对一:多个数据标记对应一个数据压缩块。一个index_granularity的未压缩SIZE<64KB

​ 假设JavaEnable字段的数据类型为UInt8,所以每行数据占用1字节。数据表的index_granularity粒度为8192,所以每一个索引片段大小正是8192B。按照数据压缩块规则,8192B<64KB,当等于64KB压缩为下一个数据块。(64KB/8192B=8,也就是8行数据为一个数据压缩块)

preview

一对一:一个数据标记对应一个数据压缩块。一个index_granularity的未压缩64KB<= SIZE <= 1MB

​ 假设URLHash字段数据类型UInt64,大小为8B,则一个默认间隔的数据大小为8*8192=65536B,正好是64KB。此时的标记数据和压缩数据是一对一的关系。

preview

一对多:一个数据标记对应多个数据压缩块。一个index_granularity的未压缩SIZE> 1MB

​ 假设URL字段类型为String,内容正好4.8MB,那么一个数据标记文件对应5个数据压缩块。

preview
更多精彩内容,请关注微信公众号获取

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/weixin_45320660/article/details/112761790