概述
ClickHouse 是一个用于联机分析处理(OLAP)的列式数据库管理系统(Columnar DBMS)。
分区、索引、标记和压缩数据,这些组件配合在一起给 ClickHouse 数据库带来非常高效的查询性能。
一切皆是映射。光剑
本文先简单介绍一下这几个组件。然后就分别从写入过程、查询过程,以及数据标记与压缩数据块的三种对应关系的角度展开介绍。
分区、索引、标记和压缩数据核心组件介绍
MergeTree引擎存储结构
MergeTree的存储结构
- partition:分区目录,余下各类数据文件(primary.idx、[Column].mrk、[Column]. bin等)都是以分区目录的形式被组织存放的,属于相同分区的数据,最终会被合并到同一个分区目录,而不同分区的数据,永远不会被合并在一起。
- checksums:校验文件,使用二进制格式存储。它保存了余下各类文件(primary. idx、count.txt等)的size大小及size的哈希值,用于快速校验文件的完整性和正确性。
- columns.txt:列信息文件,使用明文格式存储,用于保存此数据分区下的列字段信息。
- count.txt:计数文件,使用明文格式存储,用于记录当前数据分区目录下数据的总行数。
- primary.idx:一级索引文件,使用二进制格式存储。用于存放稀疏索引,一张MergeTree表只能声明一次一级索引。借助稀疏索引,在数据查询的时能够排除主键条件范围之外的数据文件,从而有效减少数据扫描范围,加速查询速度。
- [Column].bin:数据文件,使用压缩格式存储,用于存储某一列的数据。由于MergeTree采用列式存储,所以每一个列字段都拥有独立的.bin数据文件,并以列字段名称命名。
- [Column].mrk:使用二进制格式存储。标记文件中保存了.bin文件中数据的偏移量信息。标记文件与稀疏索引对齐,又与.bin文件一一对应,所以MergeTree通过标记文件建立了primary.idx稀疏索引与.bin数据文件之间的映射关系。即首先通过稀疏索引(primary.idx)找到对应数据的偏移量信息(.mrk),再通过偏移量直接从.bin文件中读取数据。由于.mrk标记文件与.bin文件一一对应,所以MergeTree中的每个列字段都会拥有与其对应的.mrk标记文件
- [Column].mrk2:如果使用了自适应大小的索引间隔,则标记文件会以.mrk2命名。它的工作原理和作用与.mrk标记文件相同。
- partition.dat与minmax_[Column].idx:如果使用了分区键,例如PARTITION BY EventTime,则会额外生成partition.dat与minmax索引文件,它们均使用二进制格式存储。partition.dat用于保存当前分区下分区表达式最终生成的值;而minmax索引用于记录当前分区下分区字段对应原始数据的最小和最大值。
- skp_idx_[Column].idx与skp_idx_[Column].mrk:如果在建表语句中声明了二级索引,则会额外生成相应的二级索引与标记文件,它们同样也使用二进制存储。二级索引在ClickHouse中又称跳数索引。
分区
在MergeTree中,数据是以分区目录的形式进行组织的,每个分区独立分开存储: Partition_1, Partition_2, Partition_3, Partition_4, .....
借助这种形式,在对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落入相应的数据分区
partition:分区目录,里面的各类数据文件(primary.idx、data.mrk、data.bin 等等)都是以分区目录的形式被组织存放的,属于相同分区的数据,最终会被合并到同一个分区目录,而不同分区的数据永远不会被合并在一起。
分区目录的命名规则是:PartitionID_MinBlockNum_MaxBlockNum_Level
下面来解释一下这几个部分:
1)PartitionID:分区 ID,这个应该无需多说。
2)MinBlockNum、MaxBlockNum:最小数据块编号和最大数据块编号,这里的命名很容易让人联想到后面要说的数据压缩块,甚至产生混淆,但实际上这两者没有任何关系。这里的 BlockNum 是一个自增的整数,从 1 开始,每当创建一个新的分区时就会自增 1,并且对于一个新的分区目录而言,它的 MinBlockNum 和 MaxBlockNum 是相等的。比如 202005_1_1_0、202006_2_2_0、202007_3_3_0,以此类推。但是也有例外,当分区目录发生合并的时候,那么其 MinBlockNum 和 MaxBlockNum 会有另外的规则,一会儿细说。
3)Level:合并的层级,可以理解为某个分区被合并的次数,这里的 Level 和 BlockNum 不同,它不是全局累加的。对于每个新创建的目录而言,其初始值都为 0,之后以分区为单位,如果相同分区发生合并动作,则该分区对应的 Level 加 1。可能有人不是很理解这里的 "相同分区发生合并" 到底是什么意思,我们下面就来介绍。
分区目录的合并过程
MergeTree 的分区目录和其它传统意义上数据库有所不同,首先 MergeTree 的分区目录并不是在数据表被创建之后就存在的,而是在数据写入的过程中被创建的,如果一张表中没有任何数据,那么也就不会有任何的分区目录。也很好理解,因为分区目录的命名与分区 ID 有关,而分区 ID 又和分区键对应的值有关,而表中连数据都没有,那么何来分区目录呢。
其次,MergeTree 的分区目录也不是一成不变的,在其它数据库的设计中,追加数据的时候目录自身不会改变,只是在相同分区中追加数据文件。而 MergeTree 完全不同,伴随着每一次数据的写入,MergeTree 都会生成一批新的分区目录,即使不同批次写入的数据属于相同的分区,也会生成不同的分区目录。也就是说对于同一个分区而言,会存在对应多个分区目录的情况。而在之后的某个时刻(一般 10 到 15 分钟),ClickHouse 会通过后台任务将属于相同分区的多个目录合并(Merge)成一个新的目录,当然也可以通过 optimize TABLE table_name FINAL 语句立即合并,至于合并之前的旧目录会在之后的某个时刻(默认 8 分钟)被删除。
属于同一个分区的多个目录,在合并之后会生成一个全新的目录,目录中的索引和数据文件也会相应地进行合并。而新目录的名称的生成方式遵循如下规则:
1.PartitionID:不变
2.MinBlockNum:取同一分区内所有目录中最小的 MinBlockNum
3.MaxBlockNum:取同一分区内所有目录中最大的 MaxBlockNum
4.Level:取同一分区内最大 Level 值并加 1
这里有一点需要明确,在 ClickHouse中,数据分区(partition)和数据分片(shard)是完全不同的概念。数据分区是针对本地数据而言的,是对数据的一种纵向切分。MergeTree并不能依靠分区的特性,将一张表的数据分布到多个ClickHouse服务节点。而横向切分是数据分片(shard)的能力。
索引
一级索引
primary.idx:一级索引文件,使用二进制格式存储,用于存储稀疏索引,一张 MergeTree 表只能声明一次一级索引(通过 ORDER BY 或 PRIMARY KEY)。借助稀疏索引,在查询数据时能够排除主键条件范围之外的数据文件,从而有效减少数据扫描范围,加速查询速度。
一级索引底层采用了稀疏索引来实现,从下图我们可以看出它和稠密索引的区别。
稀疏索引与稠密索引的对比图
对于稠密索引而言,每一行索引标记都会对应到具体的一行记录上。而在稀疏索引中,每一行索引标记对应的一大段数据,而不是具体的一行(他们之间的区别就有点类似mysql中innodb的聚集索引与非聚集索引)。
稀疏索引的优势是显而易见的,它只需要使用少量的索引标记就能够记录大量数据的区间位置信息,并且数据量越大优势愈发明显。例如我们使用默认的索引粒度(8192)时,MergeTree只需要12208行索引标记就能为1亿行数据记录提供索引。由于稀疏索引占用空间小,所以primary.idx内的索引数据能够常驻内存,取用速度自然极快。
索引粒度 index_granularity
索引粒度就如同标尺一般,会丈量整个数据的长度,并依照刻度对数据进行标注,最终将数据标记成多个间隔的小段。数据以index_granularity的粒度(老版本默认8192,新版本实现了自适应粒度)被标记成多个小的区间,其中每个区间最多8192行数据,MergeTree使用MarkRange表示一个具体的区间,并通过start和end表示其具体的范围。如下图所示。
索引粒度是建表的时候,在 SETTINGS 里面指定 index_granularity 控制的,虽然 ClickHouse 提供了自适应粒度大小的特性,但是为了便于理解,我们会使用固定的索引粒度进行介绍(8192)。索引粒度对于 MergeTree 而言是一个非常重要的概念,它就如同一把标尺,会丈量整个数据的长度,并依照刻度对数据进行标注,最终将数据标记成多个间隔的小段。
数据以 index_granularity 的粒度(默认 8192)被标记成多个小的区间,其中每个区间最多 8192 行数据,MergeTree 使用 MarkRange 表示一个具体的区间,并通过 start 和 end 表示其具体的范围。index_granularity 的名字虽然取了索引二字,但它不单单只作用于一级索引,同时还会影响数据标记文件(data.mrk)和数据文件(data.bin)。因为只有一级索引是无法完成查询工作的,它需要借助标记文件中的偏移量才能定位数据,所以一级索引和数据标记的间隔粒度(同为 index_granularity 行)相同,彼此对齐,而数据文件也会按照 index_granularity 的间隔粒度生成压缩数据块。
二级索引
skp_idx_[IndexName].idx 和 skp_idx_[IndexName].mrk3:如果在建表语句中指定了二级索引,则会额外生成相应的二级索引文件与标记文件,它们同样使用二进制存储。二级索引在 ClickHouse 中又被称为跳数索引,目前拥有 minmax、set、ngrambf_v1 和 token_v1 四种类型,这些种类的跳数索引的目的和一级索引都相同,都是为了进一步减少数据的扫描范围,从而加速整个查询过程。
标记
如果把MergeTree比作一本书,primary.idx 一级索引好比这本书的一级章节目录,.bin文件中的数据好比这本书中的文字,那么数据标记(.mrk) 会为一级章节目录和具体的文字之间建立关联 ( 书签 )。对于数据标记而言,它记录了两点重要信息:
其一,是一级章节对应的页码信息;
其二,是一段文字在某一页中的起始位置信息。
这样一来,通过数据标记就能够很快地从一本书中立即翻到关注内容所在的那一页,并知道从第几行开始阅读。
data.mrk:标记文件
使用二进制格式存储,标记文件中保存了 data.bin 文件中数据的偏移量信息,并且标记文件与稀疏索引对齐,因此 MergeTree 通过标记文件建立了稀疏索引(primary.idx)与数据文件(data.bin)之间的映射关系。而在读取数据的时候,首先会通过稀疏索引(primary.idx)找到对应数据的偏移量信息(data.mrk),因为两者是对齐的,然后再根据偏移量信息直接从 data.bin 文件中读取数据。
data.mrk3:如果使用了自适应大小的索引间隔,则标记文件会以 data.mrk3 结尾,但它的工作原理和 data.mrk 文件是相同的。
数据标记作为衔接一级索引和数据的桥梁,像极了书签,而且书本总每一个章节目录都有各自的书签。
从图中我们可以看到,数据标记和索引区间是对齐的,均按照 index_granularity 的粒度间隔,如此一来只需要简单通过索引下标编号即可直接找到对应的数据标记。并且为了能够与数据衔接,.bin 文件和数据标记文件是一一对应的,即每一个 [Column].bin 文件都有一个 [Column].mrk 数据标记文件与之对应,用于记录数据在 .bin 文件中的偏移量信息。
一行标记数据使用一个元组表示,元组内包含两个整型数值的偏移量信息,分别表示在此段数据区间内:
1\. 对应 .bin 压缩文件中,压缩数据块的起始偏移量
2\. 将该数据块解压缩后,未压缩数据的起始偏移量
一行标记数据使用一个元组表示,元组内包含两个整型数值的偏移量信息。它们分别表示在此段数据区间内,在对应的.bin压缩文件中,压缩数据块的起始偏移量;以及将该数据压缩块解压后,其未压缩数据的起始偏移量。图所示是.mrk文件内标记数据的示意。
每一行标记数据都表示了一个片段的数据(默认8192行)在.bin压缩文件中的读取位置信息。标记数据与一级索引数据不同,它并不能常驻内存,而是使用LRU(最近最少使用)缓存策略加快其取用速度。
压缩数据
数据量比较少,每一列数据的大小不是很大,因此每一列只用一个压缩数据块即可存储。如果数据量再多一些,一个压缩数据块存储不下,那么就会对应多个压缩数据块。
Column1 压缩数据块0
Column2 压缩数据块0
Column3 压缩数据块0
......
ColumnN 压缩数据块0
Column1 压缩数据块1
Column2 压缩数据块1
Column3 压缩数据块1
......
ColumnN 压缩数据块1
Column1 压缩数据块2
Column2 压缩数据块2
Column3 压缩数据块2
......
ColumnN 压缩数据块2
Column1 压缩数据块3
Column2 压缩数据块3
Column3 压缩数据块3
......
压缩数据块
一个压缩数据块由头信息和压缩数据两部分组成。头信息固定使用9位字节表示,具体由1个UInt8(1字节)整型和2个UInt32(4字节)整型组成,分别代表使用的压缩算法类型、压缩后的数据大小和压缩前的数据大小。
从图所示中能够看到,.bin压缩文件是由多个压缩数据块组成的,而每个压缩数据块的头信息则是基于CompressionMethod_CompressedSize_UncompressedSize公式生成的。通过ClickHouse提供的clickhouse-compressor工具,能够查询某个.bin文件中压缩数据的统计信息。
一个 .bin 文件是由1至多个压缩数据块组成的,每个压缩块大小在64KB~1MB之间。多个压缩数据块之间,按照写入顺序首尾相接,紧密地排列在一起。
在 .bin 文件中引入压缩数据块的目的至少有以下两个:
其一,虽然数据被压缩后能够有效减少数据大小,降低存储空间并加速数据传输效率,但数据的压缩和解压动作,其本身也会带来额外的性能损耗。所以需要控制被压缩数据的大小,以求在性能损耗和压缩率之间寻求一种平衡。
其二,在具体读取某一列数据时(.bin文件),首先需要将压缩数据加载到内存并解压,这样才能进行后续的数据处理。通过压缩数据块,可以在不读取整个.bin文件的情况下将读取粒度降低到压缩数据块级别,从而进一步缩小数据读取的范围。
分区索引 minmax_[Column].idx
partition.dat 和 minmax_[Column].idx:如果使用了分区键,例如上面的 PARTITION BY toYYYYMM(JoinTime),则会额外生成 partition.dat 与 minmax_JoinTime.idx 索引文件,它们均使用二进制格式存储。
partition.dat 用于保存当前分区下分区表达式最终生成的值,而 minmax_[Column].idx 则负责记录当前分区下分区字段对应原始数据的最小值和最大值。
数据Partitioning
ClickHouse支持PARTITION BY子句,在建表时可以指定按照任意合法表达式进行数据分区操作,比如通过toYYYYMM()将数据按月进行分区、toMonday()将数据按照周几进行分区、对Enum类型的列直接每种取值作为一个分区等。
数据Partition在ClickHouse中主要有两方面应用:
- 在partition key上进行分区裁剪,只查询必要的数据。灵活的partition expression设置,使得可以根据SQL Pattern进行分区设置,最大化的贴合业务特点。
- 对partition进行TTL管理,淘汰过期的分区数据。
数据TTL
在分析场景中,数据的价值随着时间流逝而不断降低,多数业务出于成本考虑只会保留最近几个月的数据,ClickHouse通过TTL提供了数据生命周期管理的能力。
ClickHouse支持几种不同粒度的TTL:
1) 列级别TTL:当一列中的部分数据过期后,会被替换成默认值;当全列数据都过期后,会删除该列。
2)行级别TTL:当某一行过期后,会直接删除该行。
3)分区级别TTL:当分区过期后,会直接删除该分区。
数据写入过程
分区目录、索引、标记和压缩数据的生成过程示意图如下:
生成分区目录
数据写入的第一步是生成分区目录,伴随着每一批数据的写入,都会生成一个新的分区目录。在后续的某一时刻,属于相同分区的目录会依照规则合并到一起。
生成索引
按照index_granularity索引粒度,会分别生成primary.idx主键索引(如果声明了二级索引,还会创建二级索引文件)。
生成标记和数据压缩文件
按照index_granularity索引粒度,分别生成每一个列字段的.mrk数据标记和.bin压缩数据文件。
ClickHouse 数据查询流程
数据查询概述
数据查询的本质,可以看作一个不断减小数据范围的过程。在最理想的情况下,MergeTree首先可以依次借助分区索引、一级索引和二级索引,将数据扫描范围缩至最小。然后再借助数据标记,将需要解压与计算的数据范围缩至最小。
如果一条查询语句没有指定任何WHERE条件,或是指定了WHERE条件,但条件没有匹配到任何索引(分区索引、一级索引和二级索引),那么MergeTree就不能预先减小数据范围。
在后续进行数据查询时,它会扫描所有分区目录,以及目录内索引段的最大区间。虽然不能减少数据范围,但是MergeTree仍然能够借助数据标记,以多线程的形式同时读取多个压缩数据块,以提升性能。
索引的查询过程
索引查询其实就是两个数值区间的交集判断。
其中,一个区间是由基于主键的查询条件转换而来的条件区间;而另一个区间是刚才所讲述的与MarkRange对应的数值区间。下图简要描述了 Id 字段的索引过程。
整个索引的查询过程可以分为三大步骤
1.生成查询条件区间:将查询条件转换为条件区间。即便是单个值的查询条件,也会被转换成区间的形式。
WHERE ID = 'A000'
= ['A000', 'A000']
WHERE ID > 'A000'
= ('A000', '+inf')
WHERE ID < 'A000'
= ('-inf', 'A000')
WHERE ID LIKE 'A000%'
= ['A000', 'A001')
2.递归交集判断:以递归的形式,依次对MarkRange的数值区间与条件区间做交集判断。从最大的区间[A000 , +inf)开始。
(1)如果不存在交集,则直接通过剪枝算法优化此整段MarkRange
(2)如果存在交集,且MarkRange步长大于N,则将这个区间进一步拆分为N个子区间,并重复此规则,(3)继续做递归交集判断(N由merge_tree_coarse_index_granularity指定,默认值为8), 如果存在交集,且MarkRange不可再分解,则记录MarkRange并返回.
3.合并MarkRange区间:将最终匹配的MarkRange聚在一起,合并它们的范围。
总结
分区、索引、标记和压缩数据的协同工作总结
分区、索引、标记和压缩数据,就类似于 MergeTree 的一套组合拳,使用恰当的话威力无穷。那么在依次介绍了各自的特点之后,现在将它们聚在一起总结一下。
写入过程
数据写入的第一步是生成分区目录,伴随着每一批数据的写入,都会生成一个新的分区目录。在后续的某一时刻,属于相同分区的分区目录会被合并到一起。紧接着按照 index_granularity 索引粒度,会分别生成 primary.idx 一级索引(如果声明了二级索引,还会创建二级索引文件)、每一个列字段的压缩数据文件(.bin)和数据标记文件(.mrk),如果数据量不大,则是 data.bin 和 data.mrk 文件。
下面的示意图展示了 MergeTree 表在写入数据时,它的分区目录、索引、标记和压缩数据的生成。
从分区目录 202006_1_34_3 能够得知,该分区数据总共分 34 批写入,期间发生过 3 次合并。在数据写入的过程中,依据 index_granularity 的粒度,依次为每个区间的数据生成索引、标记和压缩数据块。其中索引和标记区间是对齐的,而标记与压缩块则是根据区间大小的不同,会生成多对一、一对一、一对多的关系。
查询过程
数据查询的本质可以看做是一个不断减少数据范围的过程,在最理想的情况下,MergeTree 首先可以借助分区索引、一级索引和二级索引将数据扫描范围缩至最小。然后再借助数据标记,将需要解压与计算的数据范围缩至最小。以下图为例,该图展示了在最优的情况下,经过层层过滤,最终获取最小数据范围的过程。
如果一条查询语句没有指定任何 WHERE 条件,或者指定了 WHERE 条件、但是没有匹配到任何的索引(分区索引、一级索引、二级索引),那么 MergeTree 就不能预先减少数据范围。在后续进行数据查询时,它会扫描所有分区目录,以及目录内索引段的最大区间。不过虽然不能减少数据范围,但 MergeTree 仍然能够借助数据标记,以多线程的形式同时读取多个压缩数据块,以提升性能。
数据标记与压缩数据块的对应关系
由于压缩数据块的划分,与一个间隔(index_granularity)内的数据大小相关,每个压缩数据块的体积都被严格控制在 64KB ~ 1MB 之间,而一个间隔(index_granularity)的数据,又只会产生一行数据标记。那么根据一个间隔内数据的实际字节大小,数据标记和压缩数据块之间会产生三种不同的对应关系:
1)多对一
多个数据标记对应一个压缩数据块,当一个间隔(index_granularity)内数据的未压缩大小小于 64KB 时,会出现这种对应关系。
2)一对一
一个数据标记对应一个压缩数据块,当一个间隔(index_granularity)内数据的未压缩大小大于等于 64KB 并小于等于 1MB 时,会出现这种对应关系。
3)一对多
一个数据标记对应多个压缩数据块,当一个间隔(index_granularity)内数据的未压缩大小大于 1MB 时,会出现这种对应关系。
以上就是 MergeTree 的工作原理,首先我们了解了 MergeTree 的基础属性和物理存储结构;接着,依次介绍了数据分区、一级索引、二级索引、数据存储和数据标记的重要特性;最后总结了 MergeTree 上述特性一起协同时工作过程。掌握了 MergeTree 即掌握了合并树系列表引擎的精髓,因为 MergeTree 本身也是一种表引擎。后面我们会介绍 MergeTree 家族中其它常见表引擎的使用方法,以及它们都有哪些特点、使用方式是什么。
参考资料
https://blog.csdn.net/Night_ZW/article/details/112845684