clickhouse数据存储原理浅析

本文更好的阅读体验在笔者的个人博客中

引言

        最近做项目有个需求,想对clickhouse中的数据添加几个标签,但是总记着之前草草看过说clickhouse这种OLAP引擎的更新和删除数据操作是灾难性的,所以决定看看clickhouse的存储原理,然后再斟酌一下技术方案吧~
clickhouse数据存储原理浅析_第1张图片

简介

        首先要清楚一点,clickhouse是列式存储,列式存储一般来说更适合OLAP场景,查询分析性能上是要比行式存储要快的,为什么呢?这里简单的说一下吧…

  1. 我们都知道数据库里数据存储是按页来的,行式存储和列式存储顾名思义就是一个按照一行一行来存,一个按照一列一列来存,对比如下图所示:
    clickhouse数据存储原理浅析_第2张图片
            然后你想啊,你做查询分析的时候总不是把所有的列都一股脑的取出来吧?这些列都放在一起,你要拿到内存中的页也少了,寻址次数也少了,效率自然就上去了不是~
  2. 因为我们一列数据的数据类型是相同的,所以放在一起存储的时候就更方便做数据的压缩,这样我们在传输数据的时候网络时间也可以节约很多,所以也可以说是一方面的优势

clickhouse表引擎与建表

首先先来看一下clickhouse中的建表语句:

CREATE TABLE [IF NOT EXISTS] [db.]table_name [ON CLUSTER cluster]
(
    name1 [type1] [NULL|NOT NULL] [DEFAULT|MATERIALIZED|EPHEMERAL|ALIAS expr1],
    name2 [type2] [NULL|NOT NULL] [DEFAULT|MATERIALIZED|EPHEMERAL|ALIAS expr2],
    ...
) ENGINE = engine

基本上和MySQL建表一样对吧,这里需要注意几个地方:

  1. 在clickhouse中建表需要设定表引擎,clickhouse设定了合并数、外部存储、内存、文件、接口和其他6大类20多种表引擎,总有一种适合你
  2. clickhouse中设定默认值有这么几种关键字DEFAULT|MATERIALIZED|EPHEMERAL|ALIAS,其中最常用的就是DEFAULT。[在数据写入的时候只有DEFAULT类型的字段可以出现在INSERT中,在数据查询时只有DEFAULT类型的字段可以通过SELECT*选择,在数据存储时只有DEFAULT和MATERIALIZED类型的字段可以持久化]{.red}

合并树家族(MergeTree)

clickhouse数据存储原理浅析_第3张图片
        在生产环境中大多数都是使用的MergeTree表引擎家族:

  • 只有合并树系列的表引擎才支持主键索引、数据分区、数据副本和数据采样等特性
  • 只有此系列的表引擎支持ALTER相关操作
    然后我们就以MergeTree为例讲一下clickhouse存储那些事儿~
    clickhouse数据存储原理浅析_第4张图片

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] 
[SETTINGS name=value, 省略...] 

这里需要说一下一些主要参数:

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

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

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

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

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

MergeTree的存储结构

MergeTree表引擎中的数据是拥有物理存储的,数据会按照分区目录的形式保存到磁盘之上,其完整的存储结构如下图:
clickhouse数据存储原理浅析_第5张图片
可以看出,一张数据表的完整物理结构分为3个层级,依次是数据表目录、分区目录及各分区下具体的数据文件:

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

  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
  1. 计数文件:count.txt
    使用明文格式存储。用于记录当前数据分区目录下数据的总行数,例如:
$ cat count.txt 
8 
  1. 一级索引文件:primary.idx
    使用二进制格式存储,用于存放稀疏索引,一张MergeTree表只能声明一次一级索引(通过ORDER BY或者PRIMARY KEY)。借助稀疏索引,在数据查询的时能够排除主键条件范围之外的数据文件,从而有效减少数据扫描范围,加速查询速度。

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

  3. 列字段标记文件:[Column].mrk
    使用二进制格式存储,标记文件中保存了.bin文件中数据的偏移量信息。标记文件与稀疏索引对齐,又与.bin文件一一对应,所以MergeTree通过标记文件建立了primary.idx稀疏索引与.bin数据文件之间的映射关系。即[首先通过稀疏索引(primary.idx)找到对应数据的偏移量信息(.mrk),再通过偏移量直接从.bin文件中读取数据]{.red}。由于.mrk标记文件与.bin文件一一对应,所以MergeTree中的每个列字段都会拥有与其对应的.mrk标记文件(例如CounterID.mrk、EventDate.mrk等)。

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

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

  6. skp_idx_[Column].idx与skp_idx_[Column].mrk
    如果在建表语句中声明了二级索引,则会额外生成相应的二级索引与标记文件,它们同样也使用二进制存储。二级索引在ClickHouse中又称跳数索引,目前拥有minmax、set、ngrambf_v1和tokenbf_v1四种类型。这些索引的
    最终目标与一级稀疏索引相同,都是为了进一步减少所需扫描的数据范围,以加速整个查询过程。
    clickhouse数据存储原理浅析_第6张图片

数据分区

数据的分区规则

针对取值数据类型的不同,分区ID的生成逻辑目前拥有四种规则:

  1. 不指定分区键
    如果不使用分区键,即不使用PARTITION BY声明任何分区表达式,则分区ID默认取名为all,所有的数据都会被写入这个all分区。

  2. 使用整型
    如果分区键取值属于整型(兼容UInt64,包括有符号整型和无符号整型),且无法转换为日期类型YYYYMMDD格式,则直接按照该整型的字符形式输出,作为分区ID的取值。

  3. 使用日期类型
    如果分区键取值属于日期类型,或者是能够转换为YYYYMMDD格式的整型,则使用按照YYYYMMDD进行格式化后的字符形式输出,并作为分区ID的取值。

  4. 使用其他类型
    如果分区键取值既不属于整型,也不属于日期类型,例如String、Float等,则通过128位Hash算法取其Hash值作为分区ID的取值
    clickhouse数据存储原理浅析_第7张图片
    如果分区字段采用了多个,则会利用’-'符号一次拼接,例如使用PARTITION BY (length(Code),EventTime)来分区,则分区ID会是2-20190501的形式

分区目录的命名规则

完整的分区目录命名公式为:PartitionID_MinBlockNum_MaxBlockNum_Level,比如:
clickhouse数据存储原理浅析_第8张图片

  1. PartitionID:分区ID。

  2. MinBlockNum和MaxBlockNum
    顾名思义,最小数据块编号与最大数据块编号。这里简单来说就是说,你这里有一个全局的index变量,初始化为1,然后你每执行一次INSERT语句就会创建一个新的分区(MergeTree的特性),每创建一个分区这个index变量的值就会+1,每个分区一开始的MinBlockNumMaxBlockNum是相等的都为index。当进行分区合并的时候,就会取相同的PartitionID,相同分区中的最小MinBlockNum作为新的MinBlockNum,同理MaxBlockNum。每合并一次我们这个分区的level就自增一次

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

分区目录的合并过程

**每一批数据的写入(一次INSERT语句),MergeTree都会生成一批新的分区目录。**即便不同批次写入的数据属于相同分区,也会生成不同的分区目录。对于同一个分区而言,也会存在多个分区目录的情况。在之后的某个时刻(写入后的10~15分钟,也可以手动执行optimize查询语句),ClickHouse会通过后台任务再将属于相同分区的多个目录合并成一个新的目录。已经存在的旧分区目录并不会立即被删除,而是在之后的某个时刻通过后台任务被删除(默认8分钟)。属于同一个分区的多个目录,在合并之后会生成一个全新的目录,目录中的索引和数据文件也会相应地进行合并。
新目录名称的合并方式遵循以下规则:

  • MinBlockNum:取同一分区内所有目录中最小的MinBlockNum值
  • MaxBlockNum:取同一分区内所有目录中最大的MaxBlockNum值
  • Level:取同一分区内最大Level值并加1
    clickhouse数据存储原理浅析_第9张图片
    clickhouse数据存储原理浅析_第10张图片

一级索引

数据以index_granularity的粒度(默认固定索引粒度8192)被标记成多个小空间,其中每个空间最多8192行数据。这段空间的具体区间就是MarkRange,并且通过start和end表示具体的范围
clickhouse数据存储原理浅析_第11张图片{height=“450px”}

索引数据的生成规则

由于是稀疏索引,所以MergeTree需要间隔index_granularity行数据才会生成一条索引记录,其索引值会依据声明的主键字段获取。例如:hits_v1使用年月分区(PARTITION BYtoYYYYMM(EventDate)),所以2014年3月份的数据最终会被划分到同一个分区目录内。如果使用CounterID作为主键(ORDER BY CounterID),则每间隔8192行数据就会取一次CounterID的值作为索引值,索引数据最终会被写入primary.idx文件进行保存
clickhouse数据存储原理浅析_第12张图片
如果使用多个主键,例如ORDER BY (CounterID, EventDate),则每间隔8192行可以同时取CounterIDEventDate两列的值作为索引值:
clickhouse数据存储原理浅析_第13张图片

索引的查询过程

MergeTree按照index_granularity的间隔粒度,将一段完整的数据划分成多个小的间隔数据段,一个具体的数段就是MarkRangeMarkRange与索引编号对应,使用start和end表示具体的范围,通过start及end对应的索引编号取值,即能得到它所对应的数值区间。
索引查询其实就是两个数值区间的交集判断:
(1)一个区间是由基于主键的查询条件转换而来的条件区间
(2)一个区间是MarkRange对应的数值区间
索引查询过程:

  1. 生成查询条件区间:将查询条件转换为条件区间
  where ID = 'A003'			['A003','A003']
  where ID > 'A000'			('A000','+inf')
  where ID LIKE 'A006%'			['A006','A007')
  1. 递归交集判断:以递归的形式,依次对MarkRange的数值区间与条件区间做交集判断。
    • 如果不存在交集,则直接通过剪枝算法优化此整段MarkRange
    • 如果存在交集,且MarkRange步长大于8(end-start),则将此区间进一步拆分成8个子区间(由merge_tree_coarse_index_granularity指定,默认为8),并重复此规则,继续做递归交集判断
    • 如果存在交集,且MarkRange不可再分割(步长小于8),则记录MarkRange并返回
  2. 合并MarkRange区间:将最终匹配的MarkRange聚在一起,合并它们的范围
    clickhouse数据存储原理浅析_第14张图片

数据存储

各列独立存储

MergeTree中,数据按列存储。具体到每个列字段,每个列字段都拥有一个与之对应的.bin数据文件(物理存储)。.bin文件只会保存当前分区片段内的这一部分数据。大致流程是:数据压缩 -> 按照ORDER BY的声明排序 -> 数据以多个压缩数据块的形式被组织并写入.bin文件

压缩数据块

一个压缩数据块由头信息压缩数据两部分组成。头信息固定使用9位字节表示,具体由1个UInt8(1字节)整型和2个UInt32(4字节)整型组成,分别代表使用的压缩算法类型压缩后的数据大小压缩前的数据大小
clickhouse数据存储原理浅析_第15张图片
MergeTree在数据具体写入过程中,会按照索引粒度,按批次获取数据并进行处理。

  1. 多对一
    单个批次数据SIZE < 64KB;如果单个批次数据小于64KB,则继续获取下一批数据,直至累积到 SIZE >= 64KB 时,生成下一个压缩数据块;
  2. 一对一
    单个批次数据64KB <= SIZE <= 1MB:如果单个批次数据大小恰好在64KB与1MB之间,则直接生成下一个压缩数据块
  3. 一对多
    单个批次数据 SIZE > 1MB;如果单个批次数据直接超过1MB,则首先按照1MB大小截断并生成下一个数据块。剩余数据继续按照大小判断执行。
    clickhouse数据存储原理浅析_第16张图片

数据标记

数据标记的生成规则

clickhouse数据存储原理浅析_第17张图片
可以看出数据标记特征:

  1. 数据标记文件和索引区间是对齐的。都是按照index_granularity的粒度间隔划分。
  2. 数据标记文件和.bin文件也是一一对应。每一个列字段[column].bin文件都有一个对应的[column].mrk数据标记文件,用于记录数据在.bin文件中偏移量信息。
    一行标记数据使用元组表示,包含两个整型数据的偏移信息(压缩文件中偏移量,解压缩块中的偏移量)。标记数据与一级索引不同,它不能常驻内存,而是使用LRU(最近最少使用)缓存策略加快其取用速度。
    clickhouse数据存储原理浅析_第18张图片

数据标记的工作方式

在MergeTree读取数据时,必须通过标记数据的位置信息找到所需要的数据。查找过程大致分为读取压缩数据块读取数据两个步骤:
clickhouse数据存储原理浅析_第19张图片
MergeTree如何定位压缩数据块并读取数据:

  1. 读取压缩数据块:在查询某一列数据MergeTree无须一次性加载整个.bin文件。借助标记文件中的压缩文件偏移量加载指定的数据压缩块。
  2. 读取数据:解压后的数据,MergeTree并不需要一次性扫描整段解压数据,借住标记文件中保存的数据块中偏移量以index_granularity的粒度加载特定一小段

看了这么多是不是晕乎乎的,这几个之间到底什么关系,clickhouse到底从头到尾的存储流程是什么样的
clickhouse数据存储原理浅析_第20张图片
那来捋一捋~

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

写入过程

  1. 生成分区目录(伴随每一次insert操作,生成一个新的分区目录);
  2. 在后续的某个时刻,合并相同分区的目录;
  3. 按照index_granularity索引粒度,分别生成primary.idx索引文件、二级索引、每一列字段的.mrk数据标记和.bin压缩数据文件。
    clickhouse数据存储原理浅析_第21张图片

查询过程

索引的查询步骤在上面提到过,是利用MarkRange来完成,递归来查询,直到找到最终的范围。找到了范围之后我们就需要直到怎么取出这部分数据,这时候就用到了.mrk标记文件和.bin数据压缩文件,因为标记文件和索引区间是一一对应的,所以在找出了要用到哪些MarkRange之后,就相当于是找到了.mrk标记文件中要用到哪些数据元组,利用压缩文件偏移量找到要哪些压缩快加载到内存中,然后利用解压缩块的偏移量找到对应的数据。
clickhouse数据存储原理浅析_第22张图片
索引文件,标记文件,压缩快之间的关系如下图所示:

  1. 多对一:当一个index_granularity内的数据大小size小于64KB时会出现这种关系
    clickhouse数据存储原理浅析_第23张图片
  2. 一对一:当一个index_granularity内的数据大小size大于等于64KB小于等于1MB时会出现这种关系
    clickhouse数据存储原理浅析_第24张图片
  3. 一对多:当一个index_granularity内的数据大小size大于1MB时会出现这种关系
    clickhouse数据存储原理浅析_第25张图片

你可能感兴趣的:(大数据,大数据,clickhouse)