ClickHouse中最常用也最强大的表引擎就是合并树,即MergeTree,以及该合并树家族下的系列引擎(*MergeTree)。下面对其基础的使用设置及其数据存储与索引一探究竟。

1. clickhouse表引擎

1.1 表引擎定义

        clickhouse的表引擎也可以称为表的类型。

        引擎是一张表的基础属性,它决定了数据的存储方式和位置、数据需要写入哪里、数据需要从哪里读取等。它也确定了表支持哪些查询方式和语句,同时对于数据的访问也可以约束,如并发访问、是否可以执行多线程请求。如果表引擎存在索引,那么也决定了索引的使用。数据的复制也被其定义。

1.2 表引擎分类

1.MergeTree系列

        该系列是适用于高负载任务的最通用和功能最强大的表引擎。这些引擎的共同特点是可以快速插入数据并进行后续的后台数据处理。MergeTree系列引擎支持数据复制,分区和一些其他引擎不支持的其他功能。

2.日志系列

        该系列具有最小功能的轻量级引擎。当您需要快速写入许多小表(最多约100万行)并在以后整体读取它们时,该类型的引擎是最有效的。

3.集成引擎

        用于与其他的数据存储与处理系统集成的引擎。clickhouse支持与kafka、mysql、odbc、jdbc、hdfs等存储对象交互。

4.特定功能引擎

        clickhouse还支持其他的特定功能类的引擎。比如支持分布式表的distributed引擎,支持字典的Dictionary引擎,等等。

1.3 MergeTree

        MergeTree 合并树表引擎及该系列(*MergeTree)中的其他引擎,可以称为Clickhouse 中最强大的表引擎。

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

        在写入这一批数据时,数据以数据片段的形式写入磁盘,且数据片段不可修改。为避免片段过多,ck后台会定期合并片段,同分区片段合并成新片段。

1.MergeTree参数设置

  • PARTITION BY [选填]:
    分区键,用于指定表数据以何种标准进行分区。不填则是all分区。合理划分减少查询扫描数据,提升性能。
  • ORDER BY [必填]:
    排序键,用于指定在一个数据片段内,数据以何种标准排。
  • PRIMARY KEY [选填]:
    主键,声明后会依照主键字段生成一级索引,用于加速表查询。默认主键与排序键(ORDER BY)相同,所以通常直接使用ORDER BY代为指定主键。
  • SAMPLE BY [选填]:
    抽样表达式,用于声明数据以何种标准进行采样。
  • SETTINGS: index_granularity [选填]:
    index_granularity对于MergeTree而言是一项非常重要的参数。它表示索引的粒度,默认值为8192。也就是说,MergeTree的索引在默认情况下,每间隔8192行数据才生成一条索引。
  • SETTINGS: index_granularity_bytes [选填]:
    在之前,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 [选填]:
    多路径的存储策略

2.MergeTree物理存储结构

  • partition
    分区目录
  • checksums.txt
    校验文件,使用二进制格式存储
  • columns.txt
    列信息文件,使用明文格式存储
  • count.txt:
    计数文件,使用明文格式存储
  • primary.idx:
    一级索引文件,使用二进制格式存储
  • [Column].bin:
    数据文件,使用压缩格式存储,默认为LZ4压缩格式,用于存储某一列的数据
  • [Column].mrk:
    列字段标记文件,使用二进制格式存储
  • [Column].mrk2:
    如果使用了自适应大小的索引间隔,则标记文件会以.mrk2命名
  • partition.dat与minmax_[Column].idx:
    如果使用了分区键,例如PARTITION BY EventTime,则会额外生成partition.dat与minmax索引文件,它们均使用二进制格式存储
  • skp_idx_[Column].idx与skp_idx_[Column].mrk:
    如果在建表语句中声明了二级索引,则会额外生成相应的二级索引与标记文件,它们同样也使用二进制存储。也成为跳数索引。

2. MergeTree索引

基于以上的物理存储结构,MergeTree表在进行数据查询时,首先通过稀疏索引(primary.idx)找到对应数据的偏移量信息(.mrk),再通过偏移量直接从.bin文件中读取数据。

2.1 数据分区

1.分区规则

        影响数据分区的设置有partition。分区规则如下:

  • 不指定分区键:
    如果不使用分区键,即不使用PARTITION BY声明任何分区表达式,则分区ID默认取名为all,所有的数据都会被写入这个all分区。
  • 使用整型:
    如果分区键取值属于整型(兼容UInt64,包括有符号整型和无符号整型),且无法转换为日期类型YYYYMMDD格式,则直接按照该整型的字符形式输出,作为分区ID的取值。
  • 使用日期类型:
    如果分区键取值属于日期类型,或者是能够转换为YYYYMMDD格式的整型,则使用按照YYYYMMDD进行格式化后的字符形式输出,并作为分区ID的取值。
  • 使用其他类型:
    如果分区键取值既不属于整型,也不属于日期类型,例如String、Float等,则通过128位Hash算法取其Hash值作为分区ID的取值。

2.分区目录命名规则

        命名公式:PartitionID_MinBlockNum_MaxBlockNum_Level

  • PartitionID:
    分区ID。
  • MinBlockNum和MaxBlockNum:
    最小数据块编号与最大数据块编号。
  • Level:
    合并的层级,可以理解为某个分区被合并过的次数,或者这个分区的年龄。数值越高表示年龄越大。

3.分区目录合并过程 

        同分区中,目录是数据写入一批生成一批,10~15后后台执行合并任务。旧分区不立即删除,默认8分钟后后台执行删除任务。合并后生成全新目录,索引及数据文件相应合并。

4.新目录命名规则

        分区合并后关键参数的命名规则如下:

  • MinBlockNum:
    取同一分区内所有目录中最小的MinBlockNum。
  • MaxBlockNum:
    取同一分区内所有目录中最大的MaxBlockNum值。
  • Level:
    取同一分区内最大Level值并加1。

2.2 一级索引

1.索引数据

        保存在primary.idx文件内,索引数据按照PRIMARY KEY排序。

通过ORDER BY指代主键,PRIMARY KEY与ORDER BY定义相同,所以索引(primary.idx)和数据(.bin)会按照完全相同的规则排序。

2.稀疏索引

        每一行索引标记对应的是一段数据。稀疏索引仅使用少量的索引标记就能够记录大量数据的区间位置信息,且数据量越大优势越为明显。

        以clickhouse默认的索引粒度(8192)为例,MergeTree只需要12208行索引标记就能为1亿行数据记录提供索引。由于稀疏索引占用空间小,所以primary.idx内的索引数据常驻内存,取用速度自然极快。这也是clickhouse查询速度极快的原因之一。

*    稠密索引:

与稀疏索引相对的就是稠密索引。在稠密索引中每一行索引标记都会对应到一行具体的数据记录。

3.索引粒度(index_granularity)

        数据以index_granularity的粒度(默认8192)被标记成多个小的区间,其中每个区间最多8192行数据。

        该粒度的大小可以根据具体的数据特性,在建表时进行设置。

4.索引数据生成规则

        每隔index_granularity行取一次主键(或组合主键)作为索引值。存储非常紧凑,索引值前后相连,按照主键字段顺序紧密地排列在一起。

*    结构紧凑:

ClickHouse中很多数据结构都被设计得非常紧凑,比如其使用位读取替代专门的标志位或状态码。

5.索引查询过程

        MarkRange用于定义标记区间的对象。

        MergeTree按照index_granularity的间隔粒度,将一段完整的数据划分成了多个小的间隔数据段,一个具体的数据段即是一个MarkRange。MarkRange与索引编号对应,使用start和end两个属性表示其区间范围。

        索引查询其实就是两个数值区间的交集判断。一个区间是由基于主键的查询条件转换而来的条件区间;而另一个区间是刚才所讲述的与MarkRange对应的数值区间。MergeTree通过递归的形式持续向下拆分区间,最终将MarkRange定位到最细的粒度,最小化扫描数据范围。

        具体的操作细节如下:

  • 查询条件转换为条件区间,单值也转换。
  • 递归交集判断:
    以递归的形式,依次对MarkRange的数值区间与条件区间做交集判断。
  • 不存在交集,剪枝算法优化整段MarkRange;
  • 存在交集:
    ①且MarkRange步长大于8(end - start),则将此区间进一步拆分成8个子区间(由merge_tree_coarse_index_granularity指定,默认值为8),并重复此规则,继续做递归交集判断。
    ②且MarkRange不可再分解(步长小于8),则记录MarkRange并返回。
  • 合并MarkRange区间:
    将最终匹配的MarkRange聚在一起,合并它们的范围。

2.3 二级索引

        二级索引也被称为跳数索引。

1.定义及原理

        由数据聚合信息构建,减少查询时数据扫描范围。额外生成索引和标记文件(skp_idx_[Column].idx与skp_idx_[Column].mrk)。

granularity定义聚合信息汇总的粒度,一行跳数索引能够跳过多少个index_granularity区间的数据。

        二级索引的具体生成过程为:一级索引将数据分成index_granularity分段,在每个分段中进行一次聚合汇总,每granularity个聚合汇总再次汇总生成一个二次索引。

2.类型

        MergeTree共支持4种跳数索引,分别是minmax、set、ngrambf_v1和tokenbf_v1。一张数据表支持同时声明多个跳数索引。

  • minmax:
    数据段的最大和最小值。

# index a ID type minmax granularity 5 

ID字段的极值,每5个index_granularity生成一个索引

  • set:
    声明字段或表达式的取值(唯一值,无重复)。
# index b (length(ID)) * 8) type set(100) granularity 5 
ID的长度 * 8后的取值,每个index_granularity内最多记录100条。
  • ngrambf_v1:
    数据短语的布隆表过滤器,只支持String和FixedString数据类型。目的在于提升in、notIn、like、equals和notEquals查询的性能,ngrambf_v1(n,size_of_bloom_filter_in_bytes, number_of_hash_functions, random_seed)。其中的n为token长度,依据n的长度将数据切割为token短语。size_of_bloom_filter_in_bytes为布隆过滤器的大小。number_of_hash_functions为布隆过滤器中使用Hash函数的个数。random_seed为Hash函数的随机种子。
# index c (ID,Code) type ngrambf_v1(3,256,2,0) granularity 5 
依照3的粒度将数据切割成短语token,token会经过2个Hash函数映射后再被写入,布隆过滤器大小为256字节
  • tokenbf_v1:
    除了短语token的处理方法外,与ngrambf_v1是一致。tokenbf_v1会自动按照非字符的、数字的字符串分割token。
# index d ID type tokenbf_v1(256,2,0) granularity 5

2.4 数据存储

1.列式存储

        MergeTree中数据按列存储,每列一个.bin文件。使用列式存储的优势在于数据压缩,可以最小化数据扫描范围。

2.压缩数据块

        组成:头信息和压缩数据,头信息9字节,即1个UInt8和2个UInt32.

        头信息含义:压缩算法、压缩后数据大小和压缩前大小。(LZ4、ZSTD、Multiple、Delta),clickhouse-compressor工具可查。

        体积:压缩前大小在64KB - 1M,压缩块大小在64KB~1MB之间。首尾相接,紧密排列。

        过程:依照索引粒度(默认情况下,每次取8192行),按批次获取数据并进行压缩处理。批次数据小于64KB,继续获取下一批,累计至大于64则压缩;批次数据在64KB-1M间,直接压缩;批次数据大于1M,按1M截断压缩,剩下的重复执行。

3.压缩设计目的

首先是为了减小存储与传输成本。其次,在查询时,压缩数据获取后在内存解压,块状排列则减少数据扫描范围。

2.5 数据标记

        数据标记是为了衔接一级索引和数据。

        数据标记和索引区间对齐,下标相同。一行间隔文件一行标记。每个列的bin文件都有一个[Column].mrk数据标记文件,记录数据偏移量。

        数据标记是由一行标记由元祖构成,其中包括区间编号、压缩文件的起始偏移量、未压缩文件的起始偏移量。

        数据标记在使用时是当作非常驻内存的,使用LRU(最近最少使用)缓存策略。

        数据标记在mergeTree定位读取压缩数据中的作用:

  • 读取压缩数据块:
    MergeTree根据标记文件中的压缩数据偏移量精确加载压缩数据块,而非整个.bin文件
  • 读取解压数据:
    MergeTree根据标记文件中的解压数据偏移量精确加载特定一小段的解压数据,而非整个解压数据

2.6 数据流程

        综合以上数据的存储结构,我们可以得出数据在数据库中更为清晰的处理流程。

  • 写入
    数据写入生成新的分区目录,同分区目录合并,根据index_granularity索引粒度生成一级索引、标记文件、压缩文件。
  • 查询
    MergeTree依次借助分区索引、一级索引和二级索引,将数据扫描范围缩至最小。是一个不断减小数据范围的过程。即使如果sql没有匹配任何索引(分区、一级、二级),依然可以借助数据标记,多线程读取多个压缩数据块,提升性能。

*    数据标记和压缩块的关系

一个索引对应一个间隔数据,对应一行数据标记,一个压缩块包含部分、一个或多个数据段。

  • 多对一
    多个数据标记对应一个压缩块,数据段小于64KB。
  • 一对一
    一个数据标记对应一个压缩块,数据段在64KB - 1M之间。
  • 一对多
    一个数据标记对应多个压缩块,数据段大于1M。

*    下期将深入MergeTree系列表引擎的使用和原理,一窥其强大和魅力。

参考资料:

[1] Yandex.clickhouse官方文档[EB/OL]:https://clickhouse.tech/docs/en/

[2] 朱凯.ClickHouse原理解析与应用实践[M]