本文更好的阅读体验在笔者的个人博客中
最近做项目有个需求,想对clickhouse中的数据添加几个标签,但是总记着之前草草看过说clickhouse这种OLAP引擎的更新和删除数据操作是灾难性的,所以决定看看clickhouse的存储原理,然后再斟酌一下技术方案吧~
首先要清楚一点,clickhouse是列式存储,列式存储一般来说更适合OLAP场景,查询分析性能上是要比行式存储要快的,为什么呢?这里简单的说一下吧…
首先先来看一下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建表一样对吧,这里需要注意几个地方:
DEFAULT|MATERIALIZED|EPHEMERAL|ALIAS
,其中最常用的就是DEFAULT。[在数据写入的时候只有DEFAULT类型的字段可以出现在INSERT中,在数据查询时只有DEFAULT类型的字段可以通过SELECT*选择,在数据存储时只有DEFAULT和MATERIALIZED类型的字段可以持久化]{.red}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, 省略...]
这里需要说一下一些主要参数:
PARTITION BY [选填]
分区键,用于指定表数据以何种标准进行分区。分区键既可以是单个列字段,也可以通过元组的形式使用多个列字段,同时它也支持使用列表达式。[如果不声明分区键,则ClickHouse会生成一个名为all的分区]{.blue}。合理使用数据分区,可以有效减少查询时数据文件的扫描范围
ORDER BY [必填]
排序键,用于指定在一个数据片段内,数据以何种标准排序。默认情况下主键(PRIMARY KEY)与排序键相同。排序键既可以是单个列字段,例如ORDER BY CounterID,也可以通过元组的形式使用多个列字段,例如ORDER BY(CounterID,EventDate)。当使用多个列字段排序时,以ORDER BY(CounterID,EventDate)为例,在单个数据片段内,数据首先会以CounterID排序,相同CounterID的数据再按EventDate排序。
PRIMARY KEY [选填]
主键,顾名思义,声明后会依照主键字段生成一级索引,用于加速表查询。默认情况下,主键与排序键
(ORDER BY)相同,所以通常直接使用ORDER BY代为指定主键,无须刻意通过PRIMARY KEY声明。所以在一般情况下,在单个数据片段内,数据与一级索引以相同的规则升序排列。与其他数据库不同,MergeTree主键允许存在重复数据(ReplacingMergeTree可以去重)
SAMPLE BY [选填]
抽样表达式,用于声明数据以何种标准进行采样。如果使用了此配置项,那么在主键的配置中也需要声明同样的表达式
SETTINGS:index_granularity [选填]
index_granularity对于MergeTree而言是一项非常重要的参数,它表示索引的粒度,默认值为8192。也就是说,MergeTree的索引在默认情况下,每间隔8192行数据才生成一条索引
MergeTree表引擎中的数据是拥有物理存储的,数据会按照分区目录的形式保存到磁盘之上,其完整的存储结构如下图:
可以看出,一张数据表的完整物理结构分为3个层级,依次是数据表目录、分区目录及各分区下具体的数据文件:
分区目录:partition
余下各类数据文件(primary.idx、 [Column].mrk、[Column].bin等)都是以分区目录的形式被组织存放的,属于相同分区的数据,最终会被合并到同一个分区目录,而不同分区的数据,永远不会被合并在一起。
校验文件:checksums.txt
使用二进制格式存储。它保存了余下各类文件(primary.idx、count.txt等)的size大小及size的哈希值,用于快速校验文件的完整性和正确性。
列信息文件:columns.txt
使用明文格式存储。用于保存此数据分区下的列字段信息,例如:
$ cat columns.txt
columns format version: 1
4 columns:
'ID' String
'URL' String
'Code' String
'EventTime' Date
$ cat count.txt
8
一级索引文件:primary.idx
使用二进制格式存储,用于存放稀疏索引,一张MergeTree表只能声明一次一级索引(通过ORDER BY
或者PRIMARY KEY
)。借助稀疏索引,在数据查询的时能够排除主键条件范围之外的数据文件,从而有效减少数据扫描范围,加速查询速度。
数据文件:[Column].bin
使用压缩格式存储,默认为LZ4压缩格式,用于存储某一列的数据。由于MergeTree采用列式存储,所以每一个列字段都拥有独立的.bin数据文件,并以列字段名称命名(例如CounterID.bin、EventDate.bin等)。
列字段标记文件:[Column].mrk
使用二进制格式存储,标记文件中保存了.bin文件中数据的偏移量信息。标记文件与稀疏索引对齐,又与.bin文件一一对应,所以MergeTree通过标记文件建立了primary.idx稀疏索引与.bin数据文件之间的映射关系。即[首先通过稀疏索引(primary.idx)找到对应数据的偏移量信息(.mrk),再通过偏移量直接从.bin文件中读取数据]{.red}。由于.mrk标记文件与.bin文件一一对应,所以MergeTree中的每个列字段都会拥有与其对应的.mrk标记文件(例如CounterID.mrk、EventDate.mrk等)。
[Column].mrk2
如果使用了自适应大小的索引间隔,则标记文件会以.mrk2命名。它的工作原理和作用与.mrk标记文件相同。
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-012019-05-05。
skp_idx_[Column].idx与skp_idx_[Column].mrk
如果在建表语句中声明了二级索引,则会额外生成相应的二级索引与标记文件,它们同样也使用二进制存储。二级索引在ClickHouse中又称跳数索引,目前拥有minmax、set、ngrambf_v1和tokenbf_v1四种类型。这些索引的
最终目标与一级稀疏索引相同,都是为了进一步减少所需扫描的数据范围,以加速整个查询过程。
针对取值数据类型的不同,分区ID的生成逻辑目前拥有四种规则:
不指定分区键
如果不使用分区键,即不使用PARTITION BY
声明任何分区表达式,则分区ID默认取名为all,所有的数据都会被写入这个all分区。
使用整型
如果分区键取值属于整型(兼容UInt64,包括有符号整型和无符号整型),且无法转换为日期类型YYYYMMDD格式,则直接按照该整型的字符形式输出,作为分区ID的取值。
使用日期类型
如果分区键取值属于日期类型,或者是能够转换为YYYYMMDD格式的整型,则使用按照YYYYMMDD进行格式化后的字符形式输出,并作为分区ID的取值。
使用其他类型
如果分区键取值既不属于整型,也不属于日期类型,例如String、Float等,则通过128位Hash算法取其Hash值作为分区ID的取值
如果分区字段采用了多个,则会利用’-'符号一次拼接,例如使用PARTITION BY (length(Code),EventTime)
来分区,则分区ID会是2-20190501
的形式
完整的分区目录命名公式为:PartitionID_MinBlockNum_MaxBlockNum_Level
,比如:
PartitionID:分区ID。
MinBlockNum和MaxBlockNum
顾名思义,最小数据块编号与最大数据块编号。这里简单来说就是说,你这里有一个全局的index变量,初始化为1,然后你每执行一次INSERT语句就会创建一个新的分区(MergeTree的特性),每创建一个分区这个index变量的值就会+1,每个分区一开始的MinBlockNum
和MaxBlockNum
是相等的都为index。当进行分区合并的时候,就会取相同的PartitionID
,相同分区中的最小MinBlockNum
作为新的MinBlockNum
,同理MaxBlockNum
。每合并一次我们这个分区的level
就自增一次
Level
合并的层级,可以理解为某个分区被合并过的次数,或者这个分区的年龄。数值越高表示年龄越大。对于每一个新创建的分区目录而言,其初始值均为0。之后,以分区为单位,如果相同分区发生合并动作,则在相应分区内计数累积加1。
**每一批数据的写入(一次INSERT语句),MergeTree都会生成一批新的分区目录。**即便不同批次写入的数据属于相同分区,也会生成不同的分区目录。对于同一个分区而言,也会存在多个分区目录的情况。在之后的某个时刻(写入后的10~15分钟,也可以手动执行optimize
查询语句),ClickHouse会通过后台任务再将属于相同分区的多个目录合并成一个新的目录。已经存在的旧分区目录并不会立即被删除,而是在之后的某个时刻通过后台任务被删除(默认8分钟)。属于同一个分区的多个目录,在合并之后会生成一个全新的目录,目录中的索引和数据文件也会相应地进行合并。
新目录名称的合并方式遵循以下规则:
MinBlockNum
:取同一分区内所有目录中最小的MinBlockNum值MaxBlockNum
:取同一分区内所有目录中最大的MaxBlockNum值Level
:取同一分区内最大Level值并加1数据以index_granularity
的粒度(默认固定索引粒度8192)被标记成多个小空间,其中每个空间最多8192行数据。这段空间的具体区间就是MarkRange
,并且通过start和end表示具体的范围
{height=“450px”}
由于是稀疏索引,所以MergeTree需要间隔index_granularity行数据才会生成一条索引记录,其索引值会依据声明的主键字段获取。例如:hits_v1使用年月分区(PARTITION BYtoYYYYMM(EventDate))
,所以2014年3月份的数据最终会被划分到同一个分区目录内。如果使用CounterID
作为主键(ORDER BY CounterID)
,则每间隔8192行数据就会取一次CounterID的值作为索引值,索引数据最终会被写入primary.idx
文件进行保存
如果使用多个主键,例如ORDER BY (CounterID, EventDate)
,则每间隔8192行可以同时取CounterID
与EventDate
两列的值作为索引值:
MergeTree按照index_granularity
的间隔粒度,将一段完整的数据划分成多个小的间隔数据段,一个具体的数段就是MarkRange
。MarkRange
与索引编号对应,使用start和end表示具体的范围,通过start及end对应的索引编号取值,即能得到它所对应的数值区间。
索引查询其实就是两个数值区间的交集判断:
(1)一个区间是由基于主键的查询条件转换而来的条件区间
(2)一个区间是MarkRange对应的数值区间
索引查询过程:
where ID = 'A003' ['A003','A003']
where ID > 'A000' ('A000','+inf')
where ID LIKE 'A006%' ['A006','A007')
MarkRange
步长大于8(end-start),则将此区间进一步拆分成8个子区间(由merge_tree_coarse_index_granularity
指定,默认为8),并重复此规则,继续做递归交集判断MarkRange
不可再分割(步长小于8),则记录MarkRange
并返回MergeTree
中,数据按列存储。具体到每个列字段,每个列字段都拥有一个与之对应的.bin
数据文件(物理存储)。.bin
文件只会保存当前分区片段内的这一部分数据。大致流程是:数据压缩 -> 按照ORDER BY
的声明排序 -> 数据以多个压缩数据块的形式被组织并写入.bin
文件
一个压缩数据块由头信息和压缩数据两部分组成。头信息固定使用9位字节表示,具体由1个UInt8(1字节)整型和2个UInt32(4字节)整型组成,分别代表使用的压缩算法类型、压缩后的数据大小和压缩前的数据大小。
MergeTree在数据具体写入过程中,会按照索引粒度,按批次获取数据并进行处理。
SIZE < 64KB
;如果单个批次数据小于64KB,则继续获取下一批数据,直至累积到 SIZE >= 64KB 时,生成下一个压缩数据块;64KB <= SIZE <= 1MB
:如果单个批次数据大小恰好在64KB与1MB之间,则直接生成下一个压缩数据块SIZE > 1MB
;如果单个批次数据直接超过1MB,则首先按照1MB大小截断并生成下一个数据块。剩余数据继续按照大小判断执行。index_granularity
的粒度间隔划分。在MergeTree读取数据时,必须通过标记数据的位置信息找到所需要的数据。查找过程大致分为读取压缩数据块和读取数据两个步骤:
MergeTree如何定位压缩数据块并读取数据:
index_granularity
的粒度加载特定一小段看了这么多是不是晕乎乎的,这几个之间到底什么关系,clickhouse到底从头到尾的存储流程是什么样的
那来捋一捋~
index_granularity
索引粒度,分别生成primary.idx
索引文件、二级索引、每一列字段的.mrk
数据标记和.bin
压缩数据文件。索引的查询步骤在上面提到过,是利用MarkRange
来完成,递归来查询,直到找到最终的范围。找到了范围之后我们就需要直到怎么取出这部分数据,这时候就用到了.mrk
标记文件和.bin
数据压缩文件,因为标记文件和索引区间是一一对应的,所以在找出了要用到哪些MarkRange
之后,就相当于是找到了.mrk
标记文件中要用到哪些数据元组,利用压缩文件偏移量找到要哪些压缩快加载到内存中,然后利用解压缩块的偏移量找到对应的数据。
索引文件,标记文件,压缩快之间的关系如下图所示: