六、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,每次新建目录累计加1。
level 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文件进行保存。
例如第0(81920)行CounterID取值57,第8192(81921)行CounterID取值1635,而第16384(8192*2)行CounterID取值3266,最终索引数据将会是5716353266。
从图中也能够看出,MergeTree对于稀疏索引的存储是非常紧凑的,索引值前后相连,按照主键字段顺序紧密地排列在一起。不仅此处,ClickHouse中很多数据结构都被设计得非常紧凑,比如其使用位读取替代专门的标志位或状态码,可以不浪费哪怕一个字节的空间。以小见大,这也是ClickHouse为何性能如此出众的深层原因之一。
如果使用多个主键,例如ORDER BY (CounterID, EventDate),则每间隔8192行可以同时取CounterID与EventDate两列的值作为索引值,具体如图所示。
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]),如图所示。
6.4.6 数据存储
各列独立存储
MergeTree中,数据按列存储。具体到每个列字段,每个列字段都拥有一个与之对应的.bin数据文件(物理存储)。
.bin文件只会保存当前分区片段内的这一部分数据。
首先,数据是经过压缩,(目前支持:LZ4,ZSTD、Multiple和Delta几种算法);
其次,数据会事先按照ORDER BY 的声明排序;
最后,数据以多个压缩数据块的形式被组织并写入.bin文件中的。
压缩数据块
一个压缩数据块由头信息和压缩数据两部分组成。头信息固定使用9位字节表示,具体由1个UInt8(1字节)整型和2个UInt32(4字节)整型组成,分别代表使用的压缩算法类型、压缩后的数据大小和压缩前的数据大小。
从图所示中能够看到,.bin压缩文件是由多个压缩数据块组成的,而每个压缩数据块的头信息则是基于CompressionMethod_CompressedSize_UncompressedSize公式生成的。
MergeTree在数据具体写入过程中,会按照索引粒度,按批次获取数据并进行处理。如下图:
多对一 1.单个批次数据SIZE < 64KB;如果单个批次数据小于64KB,则继续获取下一批数据,直至累积到SIZE>=64KB时,生成下一个压缩数据块;
一对一 2.单个批次数据64KB<=SIZE<=1MB:如果单个批次数据大小恰好在64KB与1MB之间,则直接生成下一个压缩数据块
一对多 3.单个批次数据SIZE>1MB;如果单个批次数据直接超过1MB,则首先按照1MB大小截断并生成下一个数据块。剩余数据继续按照大小判断执行。
总结:一个.bin文件由1至多个压缩数据块组成,每个压缩块大小在64KB~1MB之间。多个压缩块之间,按顺序写入首尾相接。
.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压缩数据文件。
索引和标记区间对应,标记区间与压缩块区间不同,生成一对一,一对多,多对一的三种关系。
根据分区目录:201403_1_34_3得知:
该分区的N行数据,34次分批写入,合并3次。
6.6.2 查询过程
1.minmax.idx (分区索引)
2.primary.idx (一级索引)
3.skp_idx.idx (二级索引)
4…mrk (标记文件)
5…bin (数据压缩文件)
查询语句中没有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行数据为一个数据压缩块)
一对一:一个数据标记对应一个数据压缩块。一个index_granularity的未压缩64KB<= SIZE <= 1MB
假设URLHash字段数据类型UInt64,大小为8B,则一个默认间隔的数据大小为8*8192=65536B,正好是64KB。此时的标记数据和压缩数据是一对一的关系。
一对多:一个数据标记对应多个数据压缩块。一个index_granularity的未压缩SIZE> 1MB
假设URL字段类型为String,内容正好4.8MB,那么一个数据标记文件对应5个数据压缩块。
更多精彩内容,请关注微信公众号获取