Clickhouse架构与实践:MergeTree原理

76 阅读21分钟

MergeTree原理

Clickhouse 中最强大的表引擎当属 MergeTree (合并树)引擎及该系列(*MergeTree)中的其他引擎。

MergeTree 系列的引擎被设计用于插入极大量的数据到一张表当中。数据可以以数据片段的形式一个接着一个的快速写入,数据片段在后台按照一定的规则进行合并。相比在插入时不断修改(重写)已存储的数据,这种策略会高效很多。

只有合并树系列的表引擎才支持主键索引、数据分区、数据副本和数据采样这些特性,同时也只有此系列的表引擎支持 ALTER 相关操作 。

合并树的变种很多,但 MergeTree 表引擎才是根基 。 其具体变种后续文章会进行详细叙述

合并书家族有:

合并树结构.png

MergeTree 创建方式和储存结构

MergeTree 创建

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

CREATE TABLE [IF NOT EXISTS] [db_name.]table_name (name1 [type] [DEFAULT|MATERIALIZED|ALIAS expr],name2 [type] [DEFAULT|MATERIALIZED|ALIAS expr],
省略... 
) ENGINE = MergeTree() 
[PARTITION BY expr] 
[ORDER BY expr] 
[PRIMARY KEY expr] 
[SAMPLE BY expr] 
[SETTINGS name=value, 省略...] 

MergeTree 表引擎除了常规参数之外,还拥有一些独有的配置选项 。 接下来会着重介绍 其中几个重要的参数。

( 1 ) PARTITION BY [选填]: 分区键,用于指定表数据以何种标准进行分区 。** 分区键 既可以是单个列字段,也可以通过元组的形式使用多个列宇段,同时它也支持使用列表达 式** 。 如果不声明分区键,则 ClickHouse 会生成一个名为 all 的分区 。 合理使用数据分区,可以有效减少查询时数据文件的扫描范围

( 2 ) ORDER BY [必填]: 排序键 , 用于指定在 一 个数据片段内,数据以何种标准排序 。 默认情况下主键( PRIMARY 阻Y)与排序键相同 。 排序键既可以是单个列字段,例如 ORDER BY CounterID ,也可以通过元组的形式使用多个列字段,例如 ORDER BY (CounterID,EventDate ) 。 当使用多个列字段排序时,以 ORDER BY(CounterID,EventDate )为例,在单个数据片段内,数据首先会以 CounterID 排序,相同 CounterID 的数据再按 EventDate排序 。

(3) PRIMARY KEY[选填]:主键,顾名思义,声明后会依照主键字段生成一级索 引,用于加速表查询。默认情况下,主键与排序键(ORDERBY)相同,所以通常直接使用 ORDERBY代为指定主键,无须刻意通过PRIMARY KEY声明。所以在一般情况下,在单 个数据片段内,数据与一级索引以相同的规则升序排列。与其他数据库不同,MergeTree主键允许存在重复数据(ReplacingMergeTree可以去重)。

( 4 ) SAMPLE BY [选填 ]:抽样表达式,用于声明数据以何种标准进行采样 。 如果使 用了此配置项,那么在主键的配置中也需要声明同样的表达式,例如 :

) ENGINE = MergeTree()
ORDER BY (CounterID , EveηtDate , intHash32(UserID)
SAMPLE BY intHash32(UserID)

抽样表达式需要配合 SAMPLE 子查询使用,这项功能对于选取抽样数据十分有用

(5) SETTINGS : index_granularity [选填 ]:index_granularity 对于 MergeTree 而言是一项非常重要的参数,它表示索引的粒度,默认值为 8192 。 也就是说, MergeTree 的索引在默认情况下, 每间隔 8192 行数据才生成一条索引

) ENGINE = MergeTree()
省略 ...
SETTINGS index_granularity = 8192;  

(6) SETTINGS: index_granularity_bytes [选填]: 根据每一批次写入数据的体量大小,动态划分间隔大小 。 而数据的体量大小,正是由 index_granularity_bytes 参数控制的, 默认为 lOM(lO × 1024 × 1024),设置为 0 表示不启动自适应功能 。

(7) SETTINGS: enable_mixed_granularity_parts [选填]:设置是否开启自适应索引间 隔的功能, 默认开启 。

(8 ) SETTINGS: merge_with_ttl_timeout [选填 ]: 从 19.6 版本开始, MergeTre巳提供了数据 TTL 的功能。 (9) SETTINGS: storage_policy [选填 ]: 从 19.15 版本开始, MergeTree 提供了多路径 的存储策略。

MergeTree 的存储结构

MergeTree 表引 擎 中的数据是拥有物理存储的,数据会按照分区目录的形式保存到磁盘 之上,其完整的存储结构如图

clickhouse储存结构.png

可以看出,一张数据表的完整物理结构分为3个层级,依次是数据表目录、分区目录及各分区下具体的数据文件。接下来就逐一介绍它们的作用。

(1) partition:分区目录,余下各类数据文件(primary.idx、 [Column].mrk、[Column].bin等)都是以分区目录的形式被组织存放的,属于相同分区的数据,最终会被合并到同一个分区目录,而不同分区的数据,永远不会被合并在一起。更多关于数据分区的细节会在6.2节阐述。

(2) checksums.txt:校验文件,使用二进制格式存储。它保存了余下各类文件(primary.idx、count.txt等)的size大小及size的哈希值,用于快速校验文件的完整性和正确性。

(3) columns.txt:列信息文件,使用明文格式存储。用于保存此数据分区下的列字段信息,例如:

$ cat columns.txt columns format version: 1 
4 columns: 
'ID' String 
'URL' String 
'Code' String 
'EventTime' Date

(4) count.txt:计数文件,使用明文格式存储。用于记录当前数据分区目录下数据的总行数,例如:

$ cat count.txt 

(5) primary.idx:一级索引文件,使用二进制格式存储。用于存放稀疏索引,一张MergeTree表只能声明一次一级索引(通过ORDER BY或者PRIMARY KEY)。借助稀疏索引,在数据查询的时能够排除主键条件范围之外的数据文件,从而有效减少数据扫描范围,加速查询速度。

(6)[Column].bin:数据文件,使用压缩格式存储,默认为LZ4压缩格式,用于存储某一列的数据。由于MergeTree采用列式存储,所以每一个列字段都拥有独立的.bin数据文件,并以列字段名称命名(例如CounterID.bin、EventDate.bin等)

(7) [Column].mrk:列字段标记文件,使用二进制格式存储。标记文件中保存了.bin文件中数据的偏移量信息。标记文件与稀疏索引对齐,又与.bin文件一一对应,所以MergeTree通过标记文件建立了 primary.idx稀疏索引与.bin数据文件之间的映射关系。

即首先通过稀疏索引(primary.idx)找到对应数据的偏移量信息(.mrk),再通过偏移量直接从.bin文件中读取数据。由于.mrk标记文件与.bin文件一一对应,所以MergeTree中的每个列字段都会拥有与其对应的.mrk标记文件(例如CounterID.mrk、EventDate.mrk等)

(8) [Column].mrk2:如果使用了自适应大小的索引间隔,则标记文件会以.mrk2命名。它的工作原理和作用与.mrk标记文件相同。

(9) partition.dat与minmax_[Column].idx:如果使用了分区键,例如PARTITION BY EventTime,则会额外生成partition.dat与 minmax索引文件,它们均使用二进制格式存储。**partition.dat用于保存当前分区下分区表达式最终生成的值;而minmax索引用于记录当前分区下分区字段对应原始数据的最小和最大值。**例如EventTime字段对应的原始数据为2019-05-01、2019-05-05,分区表达式为PARTITION BY toYYYYMM(EventTime)。partition.dat中保存的值将会是2019-05,而minmax索引中保存的值将会是2019-05-12019-05-05。

在这些分区索引的作用下,进行数据查询时能够快速跳过不必要的数据分区目录,从而减少最终需要扫描的数据范围。

(10)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在不同规则下的一些示例。

不同规则下的分区示例.png

如果通过元组的方式使用多个分区字段,则分区ID依旧是根据上述规则生成的,**只是多个ID之间通过“-”符号依次拼接。**例如按照上述表格中的例子,使用两个字段分区:

PARTITION BY (length(Code) , EventTime)  

则最终的分区 ID 会是下面 : 2-20190501 2-20190611

分区目录的命名规则

对于MergeTree而言,它最核心的特点是其分区目录的合并动作。

下图是命名公式与样例数据的对照关系

201905表示分区目录的ID;1_1分别表示最小的数据块编号与最大的数据块编号;而最后的_0则表示目前合并的层级。接下来开始分别解释它们的含义:

(1)PartitionID:分区ID,无须多说,关于分区ID的规则在上一小节中已经做过详细阐述了。

(2)MinBlockNum和MaxBlockNum:顾名思义,最小数据块编号与最大数据块编号。ClickHouse在这里的命名似乎有些歧义,很容易让人与稍后会介绍到的数据压缩块混淆。但是本质上它们毫无关系,**这里的BlockNum是一个整型的自增长编号。如果将其设为n的话,那么计数n在单张MergeTree数据表内全局累加,n从1开始,每当新创建一个分区目录时,计数n就会累积加1。**对于一个新的分区目录而言,MinBlockNum与MaxBlockNum取值一样,同等于n,例如 201905_1_1_0、201906_2_2_0以此类推。但是也有例外,当分区目录发生合并时,对于新产生的合并目录MinBlockNum与MaxBlockNum有着另外的取值规则。对于合并规则,我们留到下一小节再详细讲解。

(3)Level:**合并的层级,可以理解为某个分区被合并过的次数,或者这个分区的年龄。**数值越高表示年龄越大。Level计数与BlockNum有所不同,它并不是全局累加的。对于每一个新创建的分区目录而言,其初始值均为0。之后,以分区为单位,如果相同分区发生合并动作,则在相应分区内计数累积加1。

分区命名公式与样例数据的对照关系.png

MergeTree的分区目录并不是在数据表被创建之后就存在的,而是在数据写入过程中被创建的。 也就是说如果一张数据表没有任何数据,那么也不会有任何分区目录存在。其次,它的分区目录在建立之后也并不是一成不变的。在其他某些数据库的设计中,追加数据后目录自身不会发生变化,只是在相同分区目录中追加新的数据文件。**而MergeTree完全不同,伴随着每一批数据的写入(一次INSERT语句),MergeTree都会生成一批新的分区目录。即便不同批次写入的数据属于相同分区,也会生成不同的分区目录。**也就是说,对于同一个分区而言,也会存在多个分区目录的情况。在之后的某个时刻(写入后的10~15分钟,也可以手动执行optimize查询语句),ClickHouse会通过后台任务再将属于相同分区的多个目录合并成一个新的目录。已经存在的旧分区目录并不会立即被删除,而是在之后的某个时刻通过后台任务被删除(默认8分钟)。

属于同一个分区的多个目录,在合并之后会生成一个全新的目录,目录中的索引和数据文件也会相应地进行合并。新目录名称的合并方式遵循以下规则,其中:

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

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

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

分区名称变化过程.png

INSERT INTO partition_v5 VALUES (A, c1, '2019-05-01') 

INSERT INTO partition_v5 VALUES (B, c1, '2019-05-02') 

INSERT INTO partition_v5 VALUES (C, c1, '2019-06-01') 

按照目录规,上述代码会创建3个分区目录。分区目录的名称由PartitionID、MinBlockNum、MaxBlockNum和Level组成,其中PartitionID根据上节介绍的生成规则,3个分区目录的ID依次为 201905、201905和201906。而对于每个新建的分区目录而言,它们的MinBlockNum与MaxBlockNum取值相同,均来源于表内全局自增的BlockNum。BlockNum初始为1,每次新建目录后累计加1

所以,3个分区目录的MinBlockNum与MaxBlockNum依次为0_0、1_1和2_2。

最后是Level层级,每个新建的分区目录初始Level都是0。所以3个分区目录的最终名称分别是201905_1_1_0、201905_2_2_0和201906_3_3_0。

假设在T1时刻,MergeTree的合并动作开始了,那么属于同一分区的201905_1_1_0与201905_2_2_0目录将发生合并。从图所示程中可以发现,合并动作完成后,生成了一个新的分区201905_1_2_1。

根据本节所述的合并规则,其中:

  • MinBlockNum 取同一分区内所有目录中最小的MinBlockNum值,所以是1;
  • MaxBlockNum取同一分区内所有目录中最大的MaxBlockNum值,所以是2;
  • 而Level则取同一分区内,最大Level值加1,所以是1。

而后续T2时刻的合并规则,只是在重复刚才所述的过程而已。

另外,分区目录在发生合并之后,旧的分区目录并没有被立即删除,而是会存留一段时间。但是旧的分区目录已不再是激活状态(active=0),所以在数据查询时,它们会被自动过滤掉。

一级索引

MergeTree的主键使用PRIMARY KEY定义,待主键定义之后,MergeTree会依据index_granularity间隔(默认8192行),为数据表生成一级索引并保存至primary.idx文件内,索引数据按照PRIMARY KEY排序。

相比使用PRIMARY KEY定义,更为常见的简化形式是通过ORDER BY指代主键。在此种情形下,PRIMARY KEY与 ORDER BY定义相同,所以索引(primary.idx)和数据(.bin)会按照完全相同的规则排序。

稀疏索引

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

稀疏索引与稠密索引的区别.png

简单来说,在稠密索引中每一行索引标记都会对应到一行具体的数据记录。

而在稀疏索引中,每一行索引标记对应的是一段数据,而不是一行。

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

索引粒度

在先前的篇幅中已经数次出现过index_granularity这个参数了,它表示索引的粒度。

在新版本中,ClickHouse提供了自适应粒度大小的特性。为了便于理解,仍然会使用固定的索引粒度(默认 8192)进行讲解。

索引粒度就如同标尺一般,会丈量整个数据的长度,并依照刻度对数据进行标注,最终将数据标记成多个间隔的小段。

MergeTree按照索引粒度.png

数据以index_granularity的粒度(默认8192)被标记成多个小的区间,其中每个区间最多8192行数据。MergeTree使用MarkRange表示一个具体的区间,并通过start和end表示其具体的范围。

index_granularity的命名虽然取了索引二字,但它不单只作用于一级索引(.idx),同时也会影响数据标记(.mrk)和数据文件(.bin)。因为仅有一级索引自身是无法完成查询工作的,它需要借助数据标记才能定位数据,所以一级索引和数据标记的间隔粒度相同(同为index_granularity行),彼此对齐。而数据文件也会依照index_granularity的间隔粒度生成压缩数据块。

索引的查询过程

假如现在有一份测试数据,共192行记录。其中,主键ID为String类型,ID的取值从A000开始,后面依次为A001、A002……直至A192为止。MergeTree的索引粒度index_granularity=3,根据索引的生成规则,primary.idx文件内的索引数据会如图6-10所示。

行ID索引的物理存储示意.png

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

在引出了数值区间的概念之后,对于索引的查询过程就很好解释了。索引查询其实就是两个数值区间的交集判断。其中,一个区间是由基于主键的查询条件转换而来的条件区间;而另一个区间是刚才所讲述的与MarkRange对应的数值区间。

64个MarkRange与其数值区间范围的示意图.png

整个索引查询过程可以大致分为3个步骤。

(1)生成查询条件区间:首先,将查询条件转换为条件区间。即便是单个值的查询条件,也会被转换成区间的形式,例如下面的例子。

WHERE ID = 'A003' 
['A003', 'A003']

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

 WHERE ID < '188' 
(-inf, 'A188') 

 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聚在一起,合并它们的范围。

完整逻辑的示意如图所示。

索引查询完整过程的逻辑示意图.png

MergeTree通过递归的形式持续向下拆分区间,最终将MarkRange定位到最细的粒度,以帮助在后续读取数据的时候,能够最小化扫描数据的范围。

以上图所示为例,当查询条件WHEREID='A003'的时候,最终只需要读取[A000,A003]和[A003,A006]两个区间的数据,它们对应MarkRange(start:0,end:2)范围,而其他无用的区间都被裁剪掉了。因为MarkRange转换的数值区间是闭区间,所以会额外匹配到临近的一个区间。

二级索引

除了一级索引之外, MergeTree 同样支持二级索引 。 二级索引又称跳数索引,由数据的 聚合信息构建而成 。 根据索引类型的不同,其聚合信息的内容也不同 。 跳数索引的目的与 一级索引一样 ,也是帮助查询时减少数据扫描的范围 。

跳数索引在默认情况下是关闭的,需要设置

allow_experimental_data_skipping_indices(该参数在新版本中已被取消)才能使用:

SET allow_experimental_data_skipping_indices = 1

跳数索引需要在CREATE语句内定义,它支持使用元组和表达式的形式声明,其完整的定义语法如下所示:

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

与一级索引一样,如果在建表语句中声明了跳数索引,则会额外生成相应的索引与标记文件(skp_idx_[Column].idx与 skp_idx_[Column].mrk)

granularity与index_granularity的关系

不同的跳数索引之间,除了它们自身独有的参数之外,还都共同拥有granularity参数。初次接触时,很容易将granularity与 index_granularity的概念弄混淆。对于跳数索引而言,

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次区间时,则汇总并生成一行跳数索引数据。

跳数索引granularity与index_granularity的关系.png

跳数索引的类型

目前,MergeTree共支持4种跳数索引,分别是minmax、set、ngrambf_v1和tokenbf_v1。

(1)minmax:minmax索引记录了一段数据内的最小和最大极值,其索引的作用类似分区目录的minmax索引,能够快速跳过无用的数据区间。

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

(3)ngrambf_v1:ngrambf_v1索引记录的是数据短语的布隆表过滤器,只支持String和FixedString数据类型。ngrambf_v1只能够提升in、notIn、like、equals和notEquals查询的性能,其完整形式为ngrambf_v1(n,size_of_bloom_filter_in_bytes,number_of_hash_functions,random_seed)。这些参数是一个布隆过滤器的标准输入。

(4)tokenbf_v1:tokenbf_v1索引是ngrambf_v1的变种,同样也是一种布隆过滤器索引。tokenbf_v1除了短语token的处理方法外,其他与ngrambf_v1是完全一样的。tokenbf_v1会自动按照非字符的、数字的字符串分割token。

数据存储

各列独立存储

在MergeTree中,数据按列存储。而具体到每个列字段,数据也是独立存储的,每个列字段都拥有一个与之对应的.bin数据文件。也正是这些.bin文件,最终承载着数据的物理存储。数据文件以分区目录的形式被组织存放,所以在.bin文件中只会保存当前分区片段内的这一部分数据。

而对应到存储的具体实现方面,MergeTree也并不是一股脑地将数据直接写入.bin文件,而是经过了一番精心设计:

  • 首先,数据是经过压缩的,目前支持LZ4、ZSTD、Multiple和Delta几种算法,默认使用LZ4算法;
  • 其次,数据会事先依照ORDER BY的声明排序;
  • 最后,数据是以压缩数据块的形式被组织并写入.bin文件中的

压缩数据块

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

压缩数据块示意图.png

每个压缩数据块的体积,按照其压缩前的数据字节大小,都被严格控制在64KB~1MB,其上下限分别由min_compress_block_size (默认65536)与max_compress_block_size(默认1048576)参数指定。而一个压缩数据块最终的大小,则和一个间隔(index_granularity)内数据的实际大小相关。

MergeTree在数据具体的写入过程中,会依照索引粒度(默认情况下,每次取8192行),按批次获取数据并进行处理。如果把一批数据的未压缩大小设为size,则整个写入过程遵循以下规则:

(1) 单个批次数据size<64KB :如果单个批次数据小于64KB,则继续获取下一批数据,直至累积到size>=64KB时,生成下一个压缩数据块。

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

(3) 单个批次数据size>1MB :如果单个批次数据直接超过1MB,则首先按照1MB大小截断并生成下一个压缩数据块。剩余数据继续依照上述规则执行。此时,会出现一个批次数据生成多个压缩数据块的情况。

切割压缩数据块的逻辑示意图.png

在.bin文件中引入压缩数据块的目的至少有以下两个:

  • 其一,虽然数据被压缩后能够有效减少数据大小,降低存储空间并加速数据传输效率,但数据的压缩和解压动作,其本身也会带来额外的性能损耗。所以需要控制被压缩数据的大小,以求在性能损耗和压缩率之间寻求一种平衡。
  • 其二,在具体读取某一列数据时(.bin文件),首先需要将压缩数据加载到内存并解压,这样才能进行后续的数据处理。通过压缩数据块,可以在不读取整个.bin文件的情况下将读取粒度降低到压缩数据块级别,从而进一步缩小数据读取的范围。

读取粒度精确到压缩数据块.png

数据标记

如果把MergeTree比作一本书,primary.idx一级索引好比这本书的一级章节目录,.bin文件中的数据好比这本书中的文字,那么数据标记(.mrk)会为一级章节目录和具体的文字之间建立关联。

通过索引下标编号找到对应的数据标记。

数据标记文件也与.bin文件一一对应。即每一个列字段[Column].bin文件都有一个与之对应的[Column].mrk数据标记文件,用于记录数据在.bin文件中的偏移量信息。

通过索引下标编号找到对应的数据标记.png

数据标记的工作方式

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

所以,从图所示中能够看到,其左侧的标记数据中,8行数据的压缩文件偏移量都是相同的,因为这8行标记都指向了同一个压缩数据块。而在这8行的标记数据中,它们的解压缩数据块中的偏移量,则依次按照8192B(每行数据1B,每一个批次8192行数据)累加,当累加达到 65536(64KB)时则置0。因为根据规则,此时会生成下一个压缩数据块。

(1)读取压缩数据块: 在查询某一列数据时,MergeTree无须一次性加载整个.bin文件,而是可以根据需要,只加载特定的压缩数据块。而这项特性需要借助标记文件中所保存的压缩文件中的偏移量。

(2)读取数据: 在读取解压后的数据时,MergeTree并不需要一次性扫描整段解压数据,它可以根据需要,以index_granularity的粒度加载特定的一小段。为了实现这项特性,需要借助标记文件中保存的解压数据块中的偏移量。

avaEnable字段的标记文件和压缩数据文件的对应关系.png

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

写入过程

数据写入的第一步是生成分区目录,伴随着每一批数据的写入,都会生成一个新的分区目录。在后续的某一时刻,属于相同分区的目录会依照规则合并到一起;接着,按照index_granularity索引粒度,会分别生成primary.idx一级索引(如果声明了二级索引,还会创建二级索引文件)、每一个列字段的.mrk数据标记和.bin压缩数据文件。

分区目录、索引、标记和压缩数据的生成过程示意.png

查询过程

数据查询的本质,可以看作一个不断减小数据范围的过程。

如果一条查询语句没有指定任何WHERE条件,或是指定了WHERE条件,但条件没有匹配到任何索引(分区索引、一级索引和二级索引),那么MergeTree就不能预先减小数据范围。在后续进行数据查询时,它会扫描所有分区目录,以及目录内索引段的最大区间。虽然不能减少数据范围,但是MergeTree仍然能够借助数据标记,以多线程的形式同时读取多个压缩数据块,以提升性能。

将扫描数据范围最小化的过程.png

文章内容引自ClickHouse原理解析与应用实践