在众多存储引擎中,只有MergeTree系列引擎才支持主键索引、数据分区、数据副本和数据采样这些特性,同时也只有此系列的表引擎支持ALTER相关操作。
6.1 MergeTree的创建方式与存储结构
MergeTree在写入一批数据时,数据总会以数据片段的形式写入磁盘,且数据片段不可修改。为了避免片段过多,CK会通过后台线程,定期合并这些数据片段,属于相同分区的数据片段会被合成一个新的片段。这种数据片段往复合并的特点,也正是合并树名称的由来。
6.1.1 MergeTree的创建方式
创建MergeTree数据表的方法,与第4章介绍的定义数据表的方法大致相同,但需要将ENGINE参数声明为MergeTree():
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][SETTING name=value, ...]
MergeTree有如下一些独有配置:
PARTITION BY:用于指定表数据以何种标准进行分区。分区键既可以时单个列字段,也可以通过元组的形式使用多个列字段,同时它也支持使用列表达式。如果不声明分区键,则CK会自动生成一个名为all的分区。合理使用数据分区,可以有效减少查询时数据文件的扫描范围。ORDER BY:用于指定在一个数据片段内,数据以何种标准排序。默认情况下主键与排序键相同。排序键既可以时单个列字段,例如ORDER BY CounterID,也可以通过元组的形式使用多个列字段,例如ORDER BY (CounterID,EventDate),先按CounterID排序,在按EventDate排序。PRIMARY KEY:主键,声明后悔按照主键字段生成一级索引,用于加速表查询。默认情况下,主键与排序键相同,所以通常直接使用ORDER BY代为指定主键,无需刻意通过PRIMARY KEY声明。与其他数据库不同,MergeTree主键允许存在重复数据(ReplacingMergeTree可以去重)。SAMPLE BY:用于声明数据以何种标准进行抽样。如果使用了此配置项,那么在主键的配置中也需要声明同样的表达式。抽样表达式需要配合SAMPLE子查询使用,这项功能对于选取抽样数据十分有用。SETTINGS:index_granularity:index_granularity表示索引的粒度,默认值是8192.也就是说,MergeTree的索引在默认情况下,每隔8192行数据才生成一条索引。SETTINGS:index_granularity_bytes:根据每一批次写入数据的体量大小,动态划分索引间隔。默认为10M。设置为0表示不启动自适应功能。SETTINGS:enable_mixed_granularity_parts:设置是否开启自适应索引间隔功能,默认开启。SETTINGS:merge_with_ttl_timeout:TTL配置。SETTINGS:storage_policy:存储策略配置。6.1.2 MergeTree的存储结构
MergeTree表引擎中的数据是拥有物理存储的,数据会按照分区目录的形式保存到磁盘上。
一张数据表的完整物理结构分为三个层级:- 数据表目录(以表名为目录名)
- 分区目录
- 分区下具体的数据文件
默认情况下,CK的数据存放在/var/lib/clickhouse/data/
/
- 分区下具体的数据文件
- 分区目录
- 数据表目录(以表名为目录名)
(1)partition:分区目录,各类数据文件都是以分区目录的形式被组织存放的,属于相同分区的数据,最终会被合并到一个分区目录,而不同分区的数据,永远不会被合并在一起。
(2)checksum.txt:校验文件,使用二进制格式存储。它确保了余下各类文件的size大小及size的哈希值,用于快速校验文件的完整性和正确性。
(3)columns.txt:列信息文件,使用明文格式存储。用于保存此数据分区下的列字段信息。
(4)count.txt:计数文件,使用明文格式存储。用于记录当前数据分区目录下数据的总行数。
(5)primary.idx:一级索引文件,使用二进制格式存储。用于存放稀疏索引,一张MergeTree表只能声明一次一级索引(通过ORDER BY或者PRIMARY KEY)。借助稀疏索引,在数据查询的时候能够排除主键条件范围之外的数据文件,从而有效减少数据扫描范围,加快查询速度。
(6)[列名].bin:数据文件,使用压缩格式存储,用于存储某一列的所有数据。由于MergeTree采用列式存储,所以每一个列字段都拥有独立的.bin数据文件,并以列字段名称命名。
(7)[列名].mrk:列字段标记文件,使用二进制格式存储。标记文件中保存了.bin文件中数据的偏移量信息。标记文件与稀疏索引对齐,又与.bin文件一一对应,所以MergeTree通过标记文件建立了primary.idx稀疏索引与.bin数据文件之间的映射关系。
(8)[列名].mrk2:如果使用了自适应大小的索引间隔,则标记文件会以.mrk2命名。它的工作原理与.mrk标记文件相同。
(9)partition.dat与minmax_[列名].idx:如果使用了分区键,例如PARTITION BY EventTime,则会额外生成partition.dat与minmax索引文件,它们均使用二进制格式存储。partition.dat用于保存当前分区下分区表达式最终生成的值(分区键值);而minmax索引用于记录当前分区下分区字段对应原始数据的最小和最大值。在这些分区索引的的作用下,进行数据查询时能够快速跳过不必要的数据分区目录,从而减少最终需要扫描的数据范围。
(10)skpidx[列名].idx与skpidx[列名].mrk:如果在建表语句中声明了二级索引,则会额外生成相应的二级索引与标志文件,它们同样也使用二进制存储。二级索引在ClickHouse中又称为跳数索引,目前拥有minmax、set、ngrambf_v1和tokenbf_v1四种类型。
6.2 数据分区
6.2.1 数据的分区规则
MergeTree数据分区的规则由分区ID决定,而具体到每个数据分区所对应的ID,则是由分区键的取值决定的。分区键支持使用任何一个或一组字段(元组)表达式声明,其业务语义可以是年、月、日或者组织单位等任何一种规则。
6.2.2 分区目录的命名规则
一个完整分区目录的命名公式如下所示:
PartitionID_MinBlockNum_MaxBlockNum_Level
(1)PartitionID:分区ID。
(2)MinBlockNum和MaxBlockNum:最小的数据块编号和最大的数据块编号。BlockNum是一个整型的自增长编号,在单张MergeTree数据表内全局累加。每当新建一个分区目录时,计数加1。对于一个新的分区目录,MinBlockNum和MaxBlockNum取值一样。当分区目录发生合并时,对于新产生的合并目录,取值有着专门的规则。见下面。
(3)Level:目前合并的层级,可以理解为某个分区被合并过的次数,或者这个分区的年龄。数值越高表示年龄越大。
6.2.3 分区目录的合并过程
MergeTree的分区目录并不是在数据表被创建之后就存在的,而是在数写入过程中被创建的。
伴随着每一批数据的写入(一次INSERT语句),MergeTree都会生成一批新的分区目录。即便不同批次写入的数据属于相同的分区,也会生成不同的分区目录。也就是说,对于同一个分区而言,也会存在多个分区目录的情况。在之后的某个时刻(写入后的10~15分钟,也可以手动执行optimize查询语句),CK会通过后台任务再将属于相同分区的多个目录合并成一个新的目录。已经存在的旧分区目录并不会立即删除,而是在之后的某个时刻通过后台任务删除(默认8分钟)。
属于同一个分区的多个目录,在合并之后会生成一个全新的目录,目录中的索引和数据文件也会相应地进行合并。新目录名称的合并方式遵循以下规则:
- MinBlockNum:取同一分区内所有目录中最小的MinBlockNum值。
- MaxBlockNum:取统一分区内所有目录中最大的MaxBlockNum值。
- Level:去同一分区内最大的Level值并加1。
6.3 一级索引
MergeTree的主键使用PRIMARY KEY定义,待主键定义之后,MergeTree会依据index_granularity间隔(默认8192行),为数据表生成一级索引并保存至primary.idx文件内,索引内数据按照PRIMARY KEY排序。
6.3.1 稀疏索引
primary.idx文件内的一级索引采用稀疏索引实现。
简单来说,在稠密索引中每一行索引标记都会对应到一行具体的数据记录。而在稀疏索引中,每一行索引标记对应的是一段数据,而不是一行。举例来说,如果把MergeTree比作一本书,那么稀疏索引就好比这本书的章节目录。章节目录只记录每个章节的起始页码,而不会具体到每个字的位置。
稀疏索引的有点是体积小,可以常驻内存。
