查看原文
其他

ClickHouse全面进阶指南.xmind

The following article is from 数据仓库与Python大数据 Author 空空

点击上方蓝色字体,关注我们

目前30000+人已关注加入我们

       

       

Tips: 文末戳阅读原文,直达实时数仓专题系列

前文导读:ClickHouse全面学习指南.xmind

本文:ClickHouse进阶指南


注:全文较长,建议pc端观看或收藏阅读。也可直接滑到文末,领取完整版xmind导图。

一、架构与设计

核心特性

DBMS

DDL

DML

权限控制

数据备份与恢复

分布式管理

列式存储与数据压缩

架构与设计

Column与Field

DataType

Block与Block流

Table

Parser与Interpreter


二、MergeTree原理

MergeTree的创建方式与存储结构

MergeTree的创建方式

ReplacingMergeTree

删除重复数据的特性

SummingMergeTree

按照排序键自动聚合数据

Replicated***

如果给合并 

树系列的表引擎加上Replicated前缀,又会得到一组支持数据副本的表 

引擎,例如ReplicatedMergeTree、ReplicatedReplacingMergeTree、 

ReplicatedSummingMergeTree等

MergeTree的创建方式与存储结构

MergeTree在写入一批数据时,数据总会以数据片段的形式写入磁 

盘,且数据片段不可修改。为了避免片段过多,ClickHouse会通过后台 

线程,定期合并这些数据片段,属于相同分区的数据片段会被合成一个 

新的片段。这种数据片段往复合并的特点,也正是合并树名称的由来。

PARTITION BY [选填]

分区键,用于指定表数据以何种标 

准进行分区。分区键既可以是单个列字段,也可以通过元组的形式使用 

多个列字段,同时它也支持使用列表达式。如果不声明分区键,则 

ClickHouse会生成一个名为all的分区。合理使用数据分区,可以有效减 

少查询时数据文件的扫描范围

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 [选填]

抽样表达式,用于声明数据以何种标准 进行采样。如果使用了此配置项,那么在主键的配置中也需要声明同样 的表达式,例如:省略... ) ENGINE = MergeTree() ORDER BY (CounterID, EventDate, intHash32(UserID) SAMPLE BY intHash32(UserID) 抽样表达式需要配合SAMPLE子查询使用,

SETTINGS:index_granularity [选填]

index_granularity [选填]:index_granularity对于 MergeTree而言是一项非常重要的参数,它表示索引的粒度,默认值为 8192。也就是说,MergeTree的索引在默认情况下,每间隔8192行数据 才生成一条索引,其具体声明方式如下所示:省略... ) ENGINE = MergeTree() 省略... SETTINGS index_granularity = 8192; 8192是一个神奇的数字,在ClickHouse中大量数值参数都有它的影 子,可以被其整除(例如最小压缩块大小 min_compress_block_size:65536)。通常情况下并不需要修改此参数, 但理解它的工作原理有助于我们更好地使用MergeTree。

SETTINGS:index_granularity_bytes [选填]

在19.11版本之 前,ClickHouse只支持固定大小的索引间隔,由index_granularity控制, 默认为8192。在新版本中,它增加了自适应间隔大小的特性,即根据每 一批次写入数据的体量大小,动态划分间隔大小。而数据的体量大小, 正是由index_granularity_bytes参数控制的,默认为

10M(10×1024×1024),设置为0表示不启动自适应功能。

SETTINGS:enable_mixed_granularity_parts [选填]

设置是否 开启自适应索引间隔的功能,默认开启。

SETTINGS:merge_with_ttl_timeout [选填]

从19.6版本开 始,MergeTree提供了数据TTL的功能

SETTINGS:storage_policy [选填]

从19.15版本开始, MergeTree提供了多路径的存储策略

MergeTree的存储结构

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

count.txt

计数文件,使用明文格式存储。用于记录当前数据 分区目录下数据的总行数,例如:$ cat count.txt 8

primary.idx

一级索引文件,使用二进制格式存储。用于存放 稀疏索引,一张MergeTree表只能声明一次一级索引(通过ORDER BY 或者PRIMARY KEY)。借助稀疏索引,在数据查询的时能够排除主键 条件范围之外的数据文件,从而有效减少数据扫描范围,加速查询速 度。

[Column].bin

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

[Column].mrk

[Column].mrk:列字段标记文件,使用二进制格式存储。标 记文件中保存了.bin文件中数据的偏移量信息。标记文件与稀疏索引对 齐,又与.bin文件一一对应,所以MergeTree通过标记文件建立了 primary.idx稀疏索引与.bin数据文件之间的映射关系。即首先通过稀疏 索引(primary.idx)找到对应数据的偏移量信息(.mrk),再通过偏移 量直接从.bin文件中读取数据。由于.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四种类型。这些索引的最 终目标与一级稀疏索引相同,都是为了进一步减少所需扫描的数据范 围,以加速整个查询过程。

数据分区

数据是以分区目录的形 式进行组织的,每个分区独立分开存储。借助这种形式,在对 MergeTree进行数据查询时,可以有效跳过无用的数据文件,只使用最 小的分区目录子集。这里有一点需要明确,在ClickHouse中,数据分区 (partition)和数据分片(shard)是完全不同的概念。数据分区是针对 本地数据而言的,是对数据的一种纵向切分。MergeTree并不能依靠分 区的特性,将一张表的数据分布到多个ClickHouse服务节点。而横向切 分是数据分片(shard)的能力,

数据的分区规则

数据的分区规则

MergeTree数据分区的规则由分区ID决定,而具体到每个数据分区 所对应的ID,则是由分区键的取值决定的。分区键支持使用任何一个或 一组字段表达式声明,其业务语义可以是年、月、日或者组织单位等任 何一种规则。针对取值数据类型的不同,分区ID的生成逻辑目前拥有四 种规则:

不指定分区键

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

使用整型

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

使用日期类型

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

使用其他类型

如果分区键取值既不属于整型,也不属于日 期类型,例如String、Float等,则通过128位Hash算法取其Hash值作为分 区ID的取值。

如果通过元组的方式使用多个分区字段,则分区ID依旧是根据上述 规则生成的,只是多个ID之间通过“-”符号依次拼接。例如按照上述表 格中的例子,使用两个字段分区:PARTITION BY (length(Code),EventTime) 则最终的分区ID会是下面的模样:2-20190501 2-20190611

分区目录的命名规则

通过上一小节的介绍,我们已经知道了分区ID的生成规则。但是如 果进入数据表所在的磁盘目录后,会发现MergeTree分区目录的完整物 理名称并不是只有ID而已,在ID之后还跟着一串奇怪的数字,例如 201905_1_1_0。那么这些数字又代表着什么呢?众所周知,对于MergeTree而言,它最核心的特点是其分区目录的 合并动作。但是我们可曾想过,从分区目录的命名中便能够解读出它的 合并逻辑。在这一小节,我们会着重对命名公式中各分项进行解读,而 关于具体的目录合并过程将会留在后面小节讲解。一个完整分区目录的 命名公式如下所示:PartitionID_MinBlockNum_MaxBlockNum_Level

PartitionID

分区ID,无须多说

MinBlockNum和MaxBlockNum

顾名思义,最小数据块编号 与最大数据块编号。ClickHouse在这里的命名似乎有些歧义,很容易让 人与稍后会介绍到的数据压缩块混淆。但是本质上它们毫无关系,这里 的BlockNum是一个整型的自增长编号。如果将其设为n的话,那么计数 n在单张MergeTree数据表内全局累加,n从1开始,每当新创建一个分区 目录时,计数n就会累积加1。对于一个新的分区目录而言, MinBlockNum与MaxBlockNum取值一样,同等于n,例如 201905_1_1_0、201906_2_2_0以此类推。但是也有例外,当分区目录发 生合并时,对于新产生的合并目录MinBlockNum与MaxBlockNum有着 另外的取值规则

Level

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

分区目录的合并过程

MergeTree的分区目录和传统意义上其他数据库有所不同。首先, MergeTree的分区目录并不是在数据表被创建之后就存在的,而是在数 据写入过程中被创建的。也就是说如果一张数据表没有任何数据,那么 也不会有任何分区目录存在。其次,它的分区目录在建立之后也并不是 一成不变的。在其他某些数据库的设计中,追加数据后目录自身不会发 生变化,只是在相同分区目录中追加新的数据文件。而MergeTree完全 不同,伴随着每一批数据的写入(一次INSERT语句),MergeTree都会 生成一批新的分区目录。即便不同批次写入的数据属于相同分区,也会 生成不同的分区目录。也就是说,对于同一个分区而言,也会存在多个 分区目录的情况。在之后的某个时刻(写入后的10~15分钟,也可以手 动执行optimize查询语句),ClickHouse会通过后台任务再将属于相同 分区的多个目录合并成一个新的目录。已经存在的旧分区目录并不会立 即被删除,而是在之后的某个时刻通过后台任务被删除(默认8分 钟)。属于同一个分区的多个目录,在合并之后会生成一个全新的目录, 目录中的索引和数据文件也会相应地进行合并。新目录名称的合并方式 遵循以下规则,其中:

MinBlockNum:取同一分区内所有目录中最小的MinBlockNum 值。

MaxBlockNum:取同一分区内所有目录中最大的MaxBlockNum 值。

·Level:取同一分区内最大Level值并加1。

一级索引

MergeTree的主键使用PRIMARY KEY定义,待主键定义之后, MergeTree会依据index_granularity间隔(默认8192行),为数据表生成 一级索引并保存至primary.idx文件内,索引数据按照PRIMARY KEY排 序。相比使用PRIMARY KEY定义,更为常见的简化形式是通过 ORDER BY指代主键。在此种情形下,PRIMARY KEY与ORDER BY定 义相同,所以索引(primary.idx)和数据(.bin)会按照完全相同的规 则排序。对于PRIMARY KEY与ORDER BY定义有差异的应用场景在 SummingMergeTree引

稀疏索引

primary.idx文件内的一级索引采用稀疏索引实现。此时有人可能会 问,既然提到了稀疏索引,那么是不是也有稠密索引呢?还真有!稀疏 索引和稠密索引

简单来说,在稠密索引中每一行索引标记都会对应到一行具体的数 据记录。而在稀疏索引中,每一行索引标记对应的是一段数据,而不是 一行。用一个形象的例子来说明:如果把MergeTree比作一本书,那么 稀疏索引就好比是这本书的一级章节目录。一级章节目录不会具体对应 到每个字的位置,只会记录每个章节的起始页码。稀疏索引的优势是显而易见的,它仅需使用少量的索引标记就能够 记录大量数据的区间位置信息,且数据量越大优势越为明显。以默认的 索引粒度(8192)为例,MergeTree只需要12208行索引标记就能为1亿

行数据记录提供索引。由于稀疏索引占用空间小,所以primary.idx内的 索引数据常驻内存,取用速度自然极快。

索引粒度

在先前的篇幅中已经数次出现过index_granularity这个参数了,它表 示索引的粒度。虽然在新版本中,ClickHouse提供了自适应粒度大小的 特性,但是为了便于理解,仍然会使用固定的索引粒度(默认8192)进 行讲解。索引粒度对MergeTree而言是一个非常重要的概念,因此很有 必要对它做一番深入解读。索引粒度就如同标尺一般,会丈量整个数据 的长度,并依照刻度对数据进行标注,最终将数据标记成多个间隔的小 段

数据以index_granularity的粒度(默认8192)被标记成多个小的区 间,其中每个区间最多8192行数据。MergeTree使用MarkRange表示一个 具体的区间,并通过start和end表示其具体的范围。index_granularity的 命名虽然取了索引二字,但它不单只作用于一级索引(.idx),同时也 会影响数据标记(.mrk)和数据文件(.bin)。因为仅有一级索引自身 是无法完成查询工作的,它需要借助数据标记才能定位数据,所以一级 索引和数据标记的间隔粒度相同(同为index_granularity行),彼此对 齐。而数据文件也会依照index_granularity的间隔粒度生成压缩数据块。

索引数据的生成规则

由于是稀疏索引,所以MergeTree需要间隔index_granularity行数据 才会生成一条索引记录,其索引值会依据声明的主键字段获取。图6-8 所示是对照测试表hits_v1中的真实数据具象化后的效果。hits_v1使用年 月分区(PARTITION BY toYYYYMM(EventDate)),所以2014年3月份 的数据最终会被划分到同一个分区目录内。如果使用CounterID作为主 键(ORDER BY CounterID),则每间隔8192行数据就会取一次 CounterID的值作为索引值,索引数据最终会被写入primary.idx文件进行 保存。

例如第0(8192*0)行CounterID取值57,第8192(8192*1)行CounterID 取值1635,而第16384(8192*2)行CounterID取值3266,最终索引数据将 会是5716353266。从图6-8中也能够看出,MergeTree对于稀疏索引的存储是非常紧凑 的,索引值前后相连,按照主键字段顺序紧密地排列在一起。不仅此 处,ClickHouse中很多数据结构都被设计得非常紧凑,比如其使用位读 取替代专门的标志位或状态码,可以不浪费哪怕一个字节的空间。以小 见大,这也是ClickHouse为何性能如此出众的深层原因之一。如果使用多个主键,例如ORDER BY(CounterID,EventDate),则每

间隔8192行可以同时取CounterID与EventDate两列的值作为索引值

索引的查询过程

在介绍了上述关于索引的一些概念之后,接下来说明索引具体是如 何工作的。首先,我们需要了解什么是MarkRange。MarkRange在 ClickHouse中是用于定义标记区间的对象。通过先前的介绍已知, MergeTree按照index_granularity的间隔粒度,将一段完整的数据划分成 了多个小的间隔数据段,一个具体的数据段即是一个MarkRange。MarkRange与索引编号对应,使用start和end两个属性表示其区间范围。通过与start及end对应的索引编号的取值,即能够得到它所对应的数值区 间。而数值区间表示了此MarkRange包含的数据范围。如果只是这么干巴巴地介绍,大家可能会觉得比较抽象,下面用一 份示例数据来进一步说明。假如现在有一份测试数据,共192行记录。其中,主键ID为String类型,ID的取值从A000开始,后面依次为A001、 A002……直至A192为止。MergeTree的索引粒度index_granularity=3,根 据索引的生成规则,primary.idx文件内的索引数据会如图6-10所示。

根据索引数据,MergeTree会将此数据片段划分成192/3=64个小的 MarkRange,两个相邻MarkRange相距的步长为1。其中,所有 MarkRange(整个数据片段)的最大数值区间为[A000,+inf),其完整的

在引出了数值区间的概念之后,对于索引的查询过程就很好解释 了。索引查询其实就是两个数值区间的交集判断。其中,一个区间是由 基于主键的查询条件转换而来的条件区间;而另一个区间是刚才所讲述 的与MarkRange对应的数值区间

生成查询条件区间

首先,将查询条件转换为条件区间。即 便是单个值的查询条件,也会被转换成区间的形式,例如下面的例子。WHERE ID = 'A003' ['A003', 'A003'] WHERE ID > 'A000' ('A000', +inf) WHERE ID < 'A188' (-inf, 'A188') WHERE ID LIKE 'A006%' ['A006', 'A007')

递归交集判断

以递归的形式,依次对MarkRange的数值区 间与条件区间做交集判断。从最大的区间[A000,+inf)开始:·如果不存在交集,则直接通过剪枝算法优化此整段MarkRange。·如果存在交集,且MarkRange步长大于8(end-start),则将此区间进 一步拆分成8个子区间(由merge_tree_coarse_index_granularity指定,默 认值为8),并重复此规则,继续做递归交集判断。·如果存在交集,且MarkRange不可再分解(步长小于8),则记录MarkRange并返回。

合并MarkRange区间

将最终匹配的MarkRange聚在一起,合 并它们的范围。完整逻辑的示意如图6-12所示。MergeTree通过递归的形式持续向下拆分区间,最终将MarkRange定 位到最细的粒度,以帮助在后续读取数据的时候,能够最小化扫描数据 的范围。以图6-12所示为例,当查询条件WHERE ID='A003'的时候,最 终只需要读取[A000,A003]和[A003,A006]两个区间的数据,它们对应 MarkRange(start:0,end:2)范围,而其他无用的区间都被裁剪掉了。因为 MarkRange转换的数值区间是闭区间,所以会额外匹配到临近的一个区 间。

二级索引

granularity与index_granularity的关系

对于跳数索引而言,index_granularity定义了数据的粒 度,而granularity定义了聚合信息汇总的粒度

granularity定义 了一行跳数索引能够跳过多少个index_granularity区间的数据

granularity的作用

就要从跳数索引的数据生成规则说 起,其规则大致是这样的:首先,按照index_granularity粒度间隔将数据 划分成n段,总共有[0,n-1]个区间(n=total_rows/index_granularity,向上 取整)。接着,根据索引定义时声明的表达式,从0区间开始,依次按 index_granularity粒度从数据中获取聚合信息,每次向前移动1步(n+1), 聚合信息逐步累加。最后,当移动granularity次区间时,则汇总并生成 一行跳数索引数据。以minmax索引为例,它的聚合信息是在一个index_granularity区间 内数据的最小和最大极值。以下图为例,假设index_granularity=8192且 granularity=3,则数据会按照index_granularity划分为n等份,MergeTree 从第0段分区开始,依次获取聚合信息。当获取到第3个分区时 (granularity=3),则汇总并会生成第一行minmax索引(前3段minmax 极值汇总后取值为[1,9])

跳数索引的类型

目前,MergeTree共支持4种跳数索引,分别是minmax、set、 ngrambf_v1和tokenbf_v1。一张数据表支持同时声明多个跳数索引,例 如:CREATE TABLE skip_test ( 

        ID String

        , URL String

        , Code String

        , EventTime Date

       , INDEX a ID TYPE minmax GRANULARITY 5

       , INDEX b(length(ID) * 8) TYPE set(2) GRANULARITY 5

        , INDEX c(ID,Code) TYPE ngrambf_v1(3, 256, 2, 0) GRANULARITY 5

         , INDEX d ID TYPE tokenbf_v1(256, 2, 0) GRANULARITY 5 

) ENGINE = MergeTree() 省略... 接下来,就借助上面的例子逐个介绍这几种跳数索引的用法:

minmax

minmax索引记录了一段数据内的最小和最大极值, 其索引的作用类似分区目录的minmax索引,能够快速跳过无用的数据 区间,示例如下所示:INDEX a ID TYPE minmax GRANULARITY 5 上述示例中minmax索引会记录这段数据区间内ID字段的极值。极 值的计算涉及每5个index_granularity区间中的数据。

INDEX a ID TYPE minmax GRANULARITY 5

set

set索引直接记录了声明字段或表达式的取值(唯一值, 无重复),其完整形式为set(max_rows),其中max_rows是一个阈值,表 示在一个index_granularity内,索引最多记录的数据行数。如果 max_rows=0,则表示无限制,例如:INDEX b(length(ID) * 8) TYPE set(100) GRANULARITY 5 上述示例中set索引会记录数据中ID的长度*8后的取值。其中,每个index_granularity内最多记录100条。

INDEX b(length(ID) * 8) TYPE set(100) GRANULARITY 5

ngrambf_v1

ngrambf_v1索引记录的是数据短语的布隆表过 滤器,只支持String和FixedString数据类型。ngrambf_v1只能够提升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

tokenbf_v1

tokenbf_v1索引是ngrambf_v1的变种,同样也是 一种布隆过滤器索引。tokenbf_v1除了短语token的处理方法外,其他与 ngrambf_v1是完全一样的。tokenbf_v1会自动按照非字符的、数字的字 符串分割token

INDEX d ID TYPE tokenbf_v1(256, 2, 0) GRANULARITY 5

数据存储

各列独立存储

在MergeTree中,数据按列存储。而具体到每个列字段,数据也是 独立存储的,每个列字段都拥有一个与之对应的.bin数据文件。也正是 这些.bin文件,最终承载着数据的物理存储。数据文件以分区目录的形 式被组织存放,所以在.bin文件中只会保存当前分区片段内的这一部分 数据,其具体组织形式已经在图6-2中展示过。按列独立存储的设计优 势显而易见:一是可以更好地进行数据压缩(相同类型的数据放在一 起,对压缩更加友好),二是能够最小化数据扫描的范围。

而对应到存储的具体实现方面,MergeTree也并不是一股脑地将数 据直接写入.bin文件,而是经过了一番精心设计:首先,数据是经过压 缩的,目前支持LZ4、ZSTD、Multiple和Delta几种算法,默认使用LZ4 算法;其次,数据会事先依照ORDER BY的声明排序;最后,数据是以 压缩数据块的形式被组织并写入.bin文件中的。

压缩数据块

一个压缩数据块由头信息和压缩数据两部分组成。头信息固定使用 9位字节表示,具体由1个UInt8(1字节)整型和2个UInt32(4字节)整 型组成,分别代表使用的压缩算法类型、压缩后的数据大小和压缩前的 数据大小

.bin压缩文件是由多个压缩数据块组成 的,而每个压缩数据块的头信息则是基于 CompressionMethod_CompressedSize_UncompressedSize公式生成的。通过ClickHouse提供的clickhouse-compressor工具,能够查询某 个.bin文件中压缩数据的统计信息。以测试数据集hits_v1为例,执行下 面的命令:clickhouse-compressor --stat < /chbase/ /data/default/hits_v1/201403_1_34_3/JavaEnable.bin

65536 12000 65536 14661 65536 4936 65536 7506 省略…

其中每一行数据代表着一个压缩数据块的头信息,其分别表示该压 缩块中未压缩数据大小和压缩后数据大小(打印信息与物理存储的顺序 刚好相反)。每个压缩数据块的体积,按照其压缩前的数据字节大小,都被严格 控制在64KB~1MB,其上下限分别由min_compress_block_size(默认 65536)与max_compress_block_size(默认1048576)参数指定。而一个 压缩数据块最终的大小,则和一个间隔(index_granularity)内数据的实 际大小相关(是的,没错,又见到索引粒度这个老朋友了)。MergeTree在数据具体的写入过程中,会依照索引粒度(默认情况 下,每次取8192行),按批次获取数据并进行处理。如果把一批数据的 未压缩大小设为size,则整个写入过程遵循以下规则:

(1)单个批次数据size<64KB

如果单个批次数据小于64KB,则 继续获取下一批数据,直至累积到size>=64KB时,生成下一个压缩数据 块。

(2)单个批次数据64KB<=size<=1MB

如果单个批次数据大小恰 好在64KB与1MB之间,则直接生成下一个压缩数据块。

(3)单个批次数据size>1MB

如果单个批次数据直接超过1MB, 则首先按照1MB大小截断并生成下一个压缩数据块。剩余数据继续依照 上述规则执行。此时,会出现一个批次数据生成多个压缩数据块的情 况。

经过上述的介绍后我们知道,一个.bin文件是由1至多个压缩数据块 组成的,每个压缩块大小在64KB~1MB之间。多个压缩数据块之间, 按照写入顺序首尾相接,紧密地排列在一起,如图6-16所示。在.bin文件中引入压缩数据块的目的至少有以下两个:其一,虽然 数据被压缩后能够有效减少数据大小,降低存储空间并加速数据传输效 率,但数据的压缩和解压动作,其本身也会带来额外的性能损耗。所以 需要控制被压缩数据的大小,以求在性能损耗和压缩率之间寻求一种平 衡。其二,在具体读取某一列数据时(.bin文件),首先需要将压缩数 据加载到内存并解压,这样才能进行后续的数据处理。通过压缩数据 块,可以在不读取整个.bin文件的情况下将读取粒度降低到压缩数据块 级别,从而进一步缩小数据读取的范围。

数据标记

如果把MergeTree比作一本书,primary.idx一级索引好比这本书的一 级章节目录,.bin文件中的数据好比这本书中的文字,那么数据标记 (.mrk)会为一级章节目录和具体的文字之间建立关联。对于数据标记而 言,它记录了两点重要信息:其一,是一级章节对应的页码信息;其 二,是一段文字在某一页中的起始位置信息。这样一来,通过数据标记 就能够很快地从一本书中立即翻到关注内容所在的那一页,并知道从第 几行开始阅读。

数据标记的生成规则

数据标记作为衔接一级索引和数据的桥梁,其像极了做过标记小抄 的书签,而且书本中每个一级章节都拥有各自的书签。它们之间的关系

从图6-17中一眼就能发现数据标记的首个特征,即数据标记和索引 区间是对齐的,均按照index_granularity的粒度间隔。如此一来,只需简 单通过索引区间的下标编号就可以直接找到对应的数据标记。

为了能够与数据衔接,数据标记文件也与.bin文件一一对应。即每 一个列字段[Column].bin文件都有一个与之对应的[Column].mrk数据标记 文件,用于记录数据在.bin文件中的偏移量信息。

一行标记数据使用一个元组表示,元组内包含两个整型数值的偏移 量信息。它们分别表示在此段数据区间内,在对应的.bin压缩文件中, 压缩数据块的起始偏移量;以及将该数据压缩块解压后,其未压缩数据 的起始偏移量。图6-18所示是.mrk文件内标记数据的示意。

如图6-18所示,每一行标记数据都表示了一个片段的数据(默认 8192行)在.bin压缩文件中的读取位置信息。标记数据与一级索引数据 不同,它并不能常驻内存,而是使用LRU(最近最少使用)缓存策略加 快其取用速度

数据标记的工作方式

MergeTree在读取数据时,必须通过标记数据的位置信息才能够找 到所需要的数据。整个查找过程大致可以分为读取压缩数据块和读取数 据两个步骤。为了便于解释,这里继续使用测试表hits_v1中的真实数据 进行说明。图6-19所示为hits_v1测试表的JavaEnable字段及其标记数据 与压缩数据的对应关系。

首先,对图6-19所示左侧的标记数据做一番解释说明。JavaEnable 字段的数据类型为UInt8,所以每行数值占用1字节。而hits_v1数据表的 index_granularity粒度为8192,所以一个索引片段的数据大小恰好是 8192B。按照6.5.2节介绍的压缩数据块的生成规则,如果单个批次数据 小于64KB,则继续获取下一批数据,直至累积到size>=64KB时,生成 下一个压缩数据块。因此在JavaEnable的标记文件中,每8行标记数据对 应1个压缩数据块(1B*8192=8192B,64KB=65536B,65536/8192=8)。所 以,从图6-19所示中能够看到,其左侧的标记数据中,8行数据的压缩 文件偏移量都是相同的,因为这8行标记都指向了同一个压缩数据块。而在这8行的标记数据中,它们的解压缩数据块中的偏移量,则依次按 照8192B(每行数据1B,每一个批次8192行数据)累加,当累加达到 65536(64KB)时则置0。因为根据规则,此时会生成下一个压缩数据块。

(1)读取压缩数据块:

在查询某一列数据时,MergeTree无须一次

性加载整个.bin文件,而是可以根据需要,只加载特定的压缩数据块。而这项特性需要借助标记文件中所保存的压缩文件中的偏移量。在图6-19所示的标记数据中,上下相邻的两个压缩文件中的起始偏 移量,构成了与获取当前标记对应的压缩数据块的偏移量区间。由当前 标记数据开始,向下寻找,直到找到不同的压缩文件偏移量为止。此时 得到的一组偏移量区间即是压缩数据块在.bin文件中的偏移量。例如在 图6-19所示中,读取右侧.bin文件中[0,12016]字节数据,就能获取第0 个压缩数据块。细心的读者可能会发现,在.mrk文件中,第0个压缩数据块的截止 偏移量是12016。而在.bin数据文件中,第0个压缩数据块的压缩大小是 12000。为什么两个数值不同呢?其实原因很简单,12000只是数据压缩 后的字节数,并没有包含头信息部分。而一个完整的压缩数据块是由头 信息加上压缩数据组成的,它的头信息固定由9个字节组成,压缩后大 小为8个字节。所以,12016=8+12000+8,其定位方法如图6-19右上角所 示。压缩数据块被整个加载到内存之后,会进行解压,在这之后就进入 具体数据的读取环节了。

(2)读取数据:

在读取解压后的数据时,MergeTree并不需要一次 性扫描整段解压数据,它可以根据需要,以index_granularity的粒度加载 特定的一小段。为了实现这项特性,需要借助标记文件中保存的解压数 据块中的偏移量。同样的,在图6-19所示的标记数据中,上下相邻两个解压缩数据块 中的起始偏移量,构成了与获取当前标记对应的数据的偏移量区间。通 过这个区间,能够在它的压缩块被解压之后,依照偏移量按需读取数 据。例如在图6-19所示中,通过[0,8192]能够读取压缩数据块0中的第 一个数据片段。

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

分区、索引、标记和压缩数据,就好比是MergeTree给出的一套组 合拳,使用恰当时威力无穷。那么,在依次介绍了各自的特点之后,现 在将它们聚在一块进行一番总结。接下来,就分别从写入过程、查询过 程,以及数据标记与压缩数据块的三种对应关系的角度展开介绍。

写入过程

数据写入的第一步是生成分区目录,伴随着每一批数据的写入,都 会生成一个新的分区目录。在后续的某一时刻,属于相同分区的目录会 依照规则合并到一起;接着,按照index_granularity索引粒度,会分别生 成primary.idx一级索引(如果声明了二级索引,还会创建二级索引文 件)、每一个列字段的.mrk数据标记和.bin压缩数据文件。图6-20所示 是一张MergeTree表在写入数据时,它的分区目录、索引、标记和压缩 数据的生成过程。

从分区目录201403_1_34_3能够得知,该分区数据共分34批写入, 期间发生过3次合并。在数据写入的过程中,依据index_granularity的粒 度,依次为每个区间的数据生成索引、标记和压缩数据块。其中,索引 和标记区间是对齐的,而标记与压缩块则根据区间数据大小的不同,会 生成多对一、一对一和一对多三种关系。

查询过程

数据查询的本质,可以看作一个不断减小数据范围的过程。在最理 想的情况下,MergeTree首先可以依次借助分区索引、一级索引和二级 索引,将数据扫描范围缩至最小。然后再借助数据标记,将需要解压与 计算的数据范围缩至最小。以图6-21所示为例,它示意了在最优的情况 下,经过层层过滤,最终获取最小范围数据的过程。

如果一条查询语句没有指定任何WHERE条件,或是指定了WHERE 条件,但条件没有匹配到任何索引(分区索引、一级索引和二级索 引),那么MergeTree就不能预先减小数据范围。在后续进行数据查询 时,它会扫描所有分区目录,以及目录内索引段的最大区间。虽然不能 减少数据范围,但是MergeTree仍然能够借助数据标记,以多线程的形 式同时读取多个压缩数据块,以提升性能。

数据标记与压缩数据块的对应关系

由于压缩数据块的划分,与一个间隔(index_granularity)内的数据 大小相关,每个压缩数据块的体积都被严格控制在64KB~1MB。而一 个间隔(index_granularity)的数据,又只会产生一行数据标记。那么根 据一个间隔内数据的实际字节大小,数据标记和压缩数据块之间会产生 三种不同的对应关系。接下来使用具体示例做进一步说明,对于示例数 据,仍然是测试表hits_v1,其中index_granularity粒度为8192,数据总量 为8873898行。

1.多对一

多个数据标记对应一个压缩数据块,当一个间隔 (index_granularity)内的数据未压缩大小size小于64KB时,会出现这种 对应关系。以hits_v1测试表的JavaEnable字段为例。JavaEnable数据类型为 UInt8,大小为1B,则一个间隔内数据大小为8192B。所以在此种情形 下,每8个数据标记会对应同一个压缩数据块,

2.一对一

一个数据标记对应一个压缩数据块,当一个间隔 (index_granularity)内的数据未压缩大小size大于等于64KB且小于等于 1MB时,会出现这种对应关系。以hits_v1测试表的URLHash字段为例。URLHash数据类型为 UInt64,大小为8B,则一个间隔内数据大小为65536B,恰好等于 64KB。所以在此种

3.一对多

一个数据标记对应多个压缩数据块,当一个间隔 (index_granularity)内的数据未压缩大小size直接大于1MB时,会出现 这种对应关系。以hits_v1测试表的URL字段为例。URL数据类型为String,大小根 据实际内容而定。如图6-24所示,编号45的标记对应了2个压缩数据 块。



三、MergeTree系列表引擎

别是合并树、外部存储、内存、文件、接口和其他,每一个系列的表 引擎都有着独自的特点与使用场景。在它们之中,最为核心的当属 MergeTree系列,因为它们拥有最为强大的性能和最广泛的使用场合。经过上一章的介绍,大家应该已经知道了MergeTree有两层含义:其一,表示合并树表引擎家族;其二,表示合并树家族中最基础的 MergeTree表引擎。而在整个家族中,除了基础表引擎MergeTree之外, 常用的表引擎还有ReplacingMergeTree、SummingMergeTree、 AggregatingMergeTree、CollapsingMergeTree和 VersionedCollapsingMergeTree。每一种合并树的变种,在继承了基础 MergeTree的能力之后,又增加了独有的特性。其名称中的“合并”二字 奠定了所有类型MergeTree的基因,它们的所有特殊逻辑,都是在触发 合并的过程中被激活的。在本章后续的内容中,会逐一介绍它们的特点 以及使用方法。

MergeTree

MergeTree作为家族系列最基础的表引擎,提供了数据分区、一级 索引和二级索引等功能。对于它们的运行机理,在上一章中已经进行了 详细介绍。本节将进一步介绍MergeTree家族独有的另外两项能力—— 数据TTL与存储策略

数据TTL

TTL即Time To Live,顾名思义,它表示数据的存活时间。在 MergeTree中,可以为某个列字段或整张表设置TTL。当时间到达时, 如果是列字段级别的TTL,则会删除这一列的数据;如果是表级别的 TTL,则会删除整张表的数据;如果同时设置了列级别和表级别的 TTL,则会以先到期的那个为主。无论是列级别还是表级别的TTL,都需要依托某个DateTime或Date 类型的字段,通过对这个时间字段的INTERVAL操作,来表述TTL的过 期时间,例如:TTL time_col + INTERVAL 3 DAY 上述语句表示数据的存活时间是time_col时间的3天之后。又例如:TTL time_col + INTERVAL 1 MONTH 上述语句表示数据的存活时间是time_col时间的1月之后。INTERVAL完整的操作包括SECOND、MINUTE、HOUR、DAY、 WEEK、MONTH、QUARTER和YEAR。

1.列级别TTL

如果想要设置列级别的TTL,则需要在定义表字段的时候,为它们 声明TTL表达式,主键字段不能被声明TTL。以下面的语句为例:CREATE TABLE ttl_table_v1( id String, create_time DateTime, code String TTL create_time + INTERVAL 10 SECOND, type UInt8 TTL create_time + INTERVAL 10 SECOND )ENGINE = MergeTree PARTITION BY toYYYYMM(create_time) ORDER BY id

其中,create_time是日期类型,列字段code与type均被设置了TTL, 它们的存活时间是在create_time的取值基础之上向后延续10秒。现在写入测试数据,其中第一行数据create_time取当前的系统时 间,而第二行数据的时间比第一行增加10分钟:INSERT INTO TABLE ttl_table_v1 VALUES('A000',now(),'C1',1), ('A000',now() + INTERVAL 10 MINUTE,'C1',1) SELECT * FROM ttl_table_v1 ┌─id───┬─────create_time──┬─code─┬─type─┐ │ A000 │ 2019-06-12 22:49:00 │ C1 │ 1 │ │ A000 │ 2019-06-12 22:59:00 │ C1 │ 1 │ └────┴───────────────┴────┴─────┘ 接着心中默数10秒,然后执行optimize命令强制触发TTL清理:optimize TABLE ttl_table_v1 FINAL 再次查询ttl_table_v1则能够看到,由于第一行数据满足TTL过期条 件(当前系统时间>=create_time+10秒),它们的code和type列会被还原 为数据类型的默认值:┌─id───┬───────create_time─┬─code─┬─type─┐ │ A000 │ 2019-06-12 22:49:00 │ │ 0 │ │ A000 │ 2019-06-12 22:59:00 │ C1 │ 1 │ └─────┴───────────────┴─────┴─────┘ 如果想要修改列字段的TTL,或是为已有字段添加TTL,则可以使 用ALTER语句,示例如下:ALTER TABLE ttl_table_v1 MODIFY COLUMN code String TTL create_time + INTERVAL 1 DAY 目前ClickHouse没有提供取消列级别TTL的方法。

2.表级别TTL

如果想要为整张数据表设置TTL,需要在MergeTree的表参数中增 加TTL表达式,例如下面的语句:

CREATE TABLE ttl_table_v2( id String, create_time DateTime, code String TTL create_time + INTERVAL 1 MINUTE, type UInt8 )ENGINE = MergeTree PARTITION BY toYYYYMM(create_time) ORDER BY create_time TTL create_time + INTERVAL 1 DAY ttl_table_v2整张表被设置了TTL,当触发TTL清理时,那些满足过 期时间的数据行将会被整行删除。同样,表级别的TTL也支持修改,修 改的方法如下:ALTER TABLE ttl_table_v2 MODIFY TTL create_time + INTERVAL 3 DAY 表级别TTL目前也没有取消的方法

3.TTL的运行机理

在知道了列级别与表级别TTL的使用方法之后,现在简单聊一聊 TTL的运行机理。如果一张MergeTree表被设置了TTL表达式,那么在写 入数据时,会以数据分区为单位,在每个分区目录内生成一个名为ttl.txt 的文件。以刚才示例中的ttl_table_v2为例,它被设置了列级别TTL:code String TTL create_time + INTERVAL 1 MINUTE 同时被设置了表级别的TTL:TTL create_time + INTERVAL 1 DAY 那么,在写入数据之后,它的每个分区目录内都会生成ttl.txt文件:# pwd /chbase/data/data/default/ttl_table_v2/201905_1_1_0 # ll total 60 …省略 -rw-r-----. 1 clickhouse clickhouse 38 May 13 14:30 create_time.bin -rw-r-----. 1 clickhouse clickhouse 48 May 13 14:30 create_time.mrk2

-rw-r-----. 1 clickhouse clickhouse 8 May 13 14:30 primary.idx -rw-r-----. 1 clickhouse clickhouse 67 May 13 14:30 ttl.txt …省略进一步查看ttl.txt的内容:cat ./ttl.txt ttl format version: 1 {"columns":[{"name":"code","min":1557478860,"max":1557651660}],"table": {"min":1557565200,"max":1557738000}} 通过上述操作会发现,原来MergeTree是通过一串JSON配置保存了 TTL的相关信息,其中:·columns用于保存列级别TTL信息;·table用于保存表级别TTL信息;·min和max则保存了当前数据分区内,TTL指定日期字段的最小 值、最大值分别与INTERVAL表达式计算后的时间戳。如果将table属性中的min和max时间戳格式化,并分别与create_time 最小与最大取值对比:SELECTtoDateTime('1557565200') AS ttl_min, toDateTime('1557738000') AS ttl_max, ttl_min - MIN(create_time) AS expire_min, ttl_max - MAX(create_time) AS expire_max FROM ttl_table_v2 ┌─────ttl_min────┬────ttl_max────┬─expire_min┬─expire_max─┐ │ 2019-05-11 17:00:00 │ 2019-05-13 17:00:00 │ 86400 │ 86400 │ └─────────────┴─────────────┴────────┴────────┘ 则能够印证,ttl.txt中记录的极值区间恰好等于当前数据分区内 create_time最小与最大值增加1天(1天=86400秒)所表示的区间,与 TTL表达式create_time+INTERVAL 1 DAY的预期相符。在知道了TTL信息的记录方式之后,现在看看它的大致处理逻辑。

(1)MergeTree以分区目录为单位,通过ttl.txt文件记录过期时间,并将其作为后续的判断依据。

(2)每当写入一批数据时,都会基于INTERVAL表达式的计算结 果为这个分区生成ttl.txt文件。

(3)只有在MergeTree合并分区时,才会触发删除TTL过期数据的 逻辑。

(4)在选择删除的分区时,会使用贪婪算法,它的算法规则是尽 可能找到会最早过期的,同时年纪又是最老的分区(合并次数更多, MaxBlockNum更大的)。

(5)如果一个分区内某一列数据因为TTL到期全部被删除了,那 么在合并之后生成的新分区目录中,将不会包含这个列字段的数据文件 (.bin和.mrk)。

这里还有几条TTL使用的小贴士。

(1)TTL默认的合并频率由MergeTree的merge_with_ttl_timeout参 数控制,默认86400秒,即1天。它维护的是一个专有的TTL任务队列。有别于MergeTree的常规合并任务,如果这个值被设置的过小,可能会 带来性能损耗。

(2)除了被动触发TTL合并外,也可以使用optimize命令强制触发 合并。例如,触发一个分区合并:optimize TABLE table_name 触发所有分区合并:optimize TABLE table_name FINAL

(3)ClickHouse目前虽然没有提供删除TTL声明的方法,但是提供 了控制全局TTL合并任务的启停方法:SYSTEM STOP/START TTL MERGES

虽然还不能做到按每张MergeTree数据表启停,但聊胜于无吧。

多路径存储策略

在ClickHouse 19.15版本之前,MergeTree只支持单路径存储,所有 的数据都会被写入config.xml配置中path指定的路径下,即使服务器挂载 了多块磁盘,也无法有效利用这些存储空间。为了解决这个痛点,从 19.15版本开始,MergeTree实现了自定义存储策略的功能,支持以数据 分区为最小移动单元,将分区目录写入多块磁盘目录。根据配置策略的不同,目前大致有三类存储策略。

默认策略

MergeTree原本的存储策略,无须任何配置,所有分区 会自动保存到config.xml配置中path指定的路径下。

JBOD策略

这种策略适合服务器挂载了多块磁盘,但没有做 RAID的场景。JBOD的全称是Just a Bunch of Disks,它是一种轮询策 略,每执行一次INSERT或者MERGE,所产生的新分区会轮询写入各个 磁盘。这种策略的效果类似RAID 0,可以降低单块磁盘的负载,在一定 条件下能够增加数据并行读写的性能。如果单块磁盘发生故障,则会丢 掉应用JBOD策略写入的这部分数据。(数据的可靠性需要利用副本机 制保障,这部分内容将会在后面介绍副本与分片时介绍。)

HOT/COLD策略

这种策略适合服务器挂载了不同类型磁盘的场 景。将存储磁盘分为HOT与COLD两类区域。HOT区域使用SSD这类高 性能存储媒介,注重存取性能;COLD区域则使用HDD这类高容量存储 媒介,注重存取经济性。数据在写入MergeTree之初,首先会在HOT区 域创建分区目录用于保存数据,当分区数据大小累积到阈值时,数据会 自行移动到COLD区域。而在每个区域的内部,也支持定义多个磁盘, 所以在单个区域的写入过程中,也能应用JBOD策略。

存储配置需要预先定义在config.xml配置文件中,由 storage_configuration标签表示。在storage_configuration之下又分为disks 和policies两组标签,分别表示磁盘与存储策略。disks的配置示例如下所示,支持定义多块磁盘:<storage_configuration>

<disks> <disk_name_a> <!--自定义磁盘名称 --> <path>/chbase/data</path><!—磁盘路径 --> <keep_free_space_bytes>1073741824</keep_free_space_bytes> </disk_name_a> <disk_name_b> <path>… </path> </disk_name_b> </disks> 其中:·<disk_name_*>,必填项,必须全局唯一,表示磁盘的自定义名 称;·<path>,必填项,用于指定磁盘路径;·<keep_free_space_bytes>,选填项,以字节为单位,用于定义磁盘 的预留空间。在policies的配置中,需要引用先前定义的disks磁盘。policies同样 支持定义多个策略,它的示例如下:<policies> <policie_name_a> <!--自定义策略名称 --> <volumes> <volume_name_a> <!--自定义卷名称 --> <disk>disk_name_a</disk> <disk>disk_name_b</disk> <max_data_part_size_bytes>1073741824</max_data_part_size_bytes> </volume_name_b> </volumes> <move_factor>0.2</move_factor> </policie_name_a> <policie_name_b> </policie_name_b> </policies> </storage_configuration> 其中:·<policie_name_*>,必填项,必须全局唯一,表示策略的自定义名 称;

·<volume_name_*>,必填项,必须全局唯一,表示卷的自定义名 称;·<disk>,必填项,用于关联配置内的磁盘,可以声明多个disk, MergeTree会按定义的顺序选择disk;·<max_data_part_size_bytes>,选填项,以字节为单位,表示在这个 卷的单个disk磁盘中,一个数据分区的最大存储阈值,如果当前分区的 数据大小超过阈值,则之后的分区会写入下一个disk磁盘;·<move_factor>,选填项,默认为0.1;如果当前卷的可用空间小于 factor因子,并且定义了多个卷,则数据会自动向下一个卷移动。在知道了配置格式之后,现在用一组示例说明它们的使用方法。

1.JBOD策略

首先,在config.xml配置文件中增加storage_configuration元素,并配 置3块磁盘:<storage_configuration> <!--自定义磁盘配置 --> <disks> <disk_hot1> <!--自定义磁盘名称 --> <path>/chbase/data</path> </disk_hot1> <disk_hot2> <path>/chbase/hotdata1</path> </disk_hot2> <disk_cold> <path>/chbase/cloddata</path> <keep_free_space_bytes>1073741824</keep_free_space_bytes> </disk_cold> </disks> …省略接着,配置一个存储策略,在volumes卷下引用两块磁盘,组成一 个磁盘组:<!-- 实现JDOB效果 --> <policies> <default_jbod> <!--自定义策略名称 --> <volumes> <jbod> <!—自定义名称 磁盘组 -->

<disk>disk_hot1</disk> <disk>disk_hot2</disk> </jbod> </volumes> </default_jbod> </policies> </storage_configuration> 至此,一个支持JBOD策略的存储策略就配置好了。在正式应用之 前,还需要做一些准备工作。首先,需要给磁盘路径授权,使 ClickHouse用户拥有路径的读写权限:sudo chown clickhouse:clickhouse -R /chbase/cloddata /chbase/hotdata1 由于存储配置不支持动态更新,为了使配置生效,还需要重启 clickhouse-server服务:sudo service clickhouse-server restart 服务重启好之后,可以查询系统表以验证配置是否已经生效:SELECT name, path,formatReadableSize(free_space) AS free, formatReadableSize(total_space) AS total, formatReadableSize(keep_free_space) AS reserved FROM system.disks ┌─name─────┬─path────────┬─free────┬─total────┬─reserved─┐ │ default │ /chbase/data/ │ 38.26 GiB │ 49.09 GiB │ 0.00 B │ │ disk_cold │ /chbase/cloddata/ │ 37.26 GiB │ 48.09 GiB │ 1.00 GiB │ │ disk_hot1 │ /chbase/data/ │ 38.26 GiB │ 49.09 GiB │ 0.00 B │ │ disk_hot2 │ /chbase/hotdata1/ │ 38.26 GiB │ 49.09 GiB │ 0.00 B │ └────────┴────────────┴────────┴────────┴───────┘ 通过system.disks系统表可以看到刚才声明的3块磁盘配置已经生 效。接着验证策略配置:SELECT policy_name, volume_name, volume_priority, disks, formatReadableSize(max_data_part_size) max_data_part_size , move_factor FROM

system.storage_policies ┌─policy_name─┬─volume_name─┬─disks──────────┬─max_data_part_size─┬─move_factor─┐ │ default │ default │ ['default'] │ 0.00 B │ 0 ││ default_jbod │ jbod │ ['disk_hot1','disk_hot2']│ 0.00 B │ 0.1 │└────────┴────────┴─────────────┴──────────┴─────────┘ 通过system.storage_policies系统表可以看到刚才配置的存储策略也 已经生效了。现在创建一张MergeTree表,用于测试default_jbod存储策略的效 果:CREATE TABLE jbod_table( id UInt64 )ENGINE = MergeTree() ORDER BY id SETTINGS storage_policy = 'default_jbod' 在定义MergeTree时,使用storage_policy配置项指定刚才定义的 default_jbod存储策略。存储策略一旦设置,就不能修改了。现在开始测 试它的效果。首先写入第一批数据,创建一个分区目录:INSERT INTO TABLE jbod_table SELECT rand() FROM numbers(10) 查询分区系统表,可以看到第一个分区写入了第一块磁盘 disk_hot1:SELECT name, disk_name FROM system.parts WHERE table = 'jbod_table' ┌─name─────┬─disk_name─┐ │ all_1_1_0 │ disk_hot1 │ └────────┴───────┘ 接着写入第二批数据,创建一个新的分区目录:INSERT INTO TABLE jbod_table SELECT rand() FROM numbers(10) 再次查询分区系统表,可以看到第二个分区写入了第二块磁盘

disk_hot2:SELECT name, disk_name FROM system.parts WHERE table = 'jbod_table' ┌─name─────┬─disk_name─┐ │ all_1_1_0 │ disk_hot1 │ │ all_2_2_0 │ disk_hot2 │ └────────┴───────┘ 最后触发一次分区合并动作,生成一个合并后的新分区目录:optimize TABLE jbod_table 还是查询分区系统表,可以看到合并后生成的all_1_2_1分区,再一 次写入了第一块磁盘disk_hot1:┌─name─────┬─disk_name─┐ │ all_1_1_0 │ disk_hot1 │ │ all_1_2_1 │ disk_hot1 │ │ all_2_2_0 │ disk_hot2 │ └────────┴───────┘ 至此,大家应该已经明白JBOD策略的工作方式了。在这个策略 中,由多个磁盘组成了一个磁盘组,即volume卷。每当生成一个新数据 分区的时候,分区目录会依照volume卷中磁盘定义的顺序,依次轮询并 写入各个磁盘。

2.HOT/COLD策略

ReplacingMergeTree

(1)使用ORBER BY排序键作为判断重复数据的唯一键。

(2)只有在合并分区的时候才会触发删除重复数据的逻辑。

(3)以数据分区为单位删除重复数据。当分区合并时,同一分区 内的重复数据会被删除;不同分区之间的重复数据不会被删除。

(4)在进行数据去重时,因为分区内的数据已经基于ORBER BY 进行了排序,所以能够找到那些相邻的重复数据。

(5)数据去重策略有两种:

·如果没有设置ver版本号,则保留同一组重复数据中的最后一行。

·如果设置了ver版本号,则保留同一组重复数据中ver字段取值最大 的那一行。

SummingMergeTree

(1)用ORBER BY排序键作为聚合数据的条件Key。

(2)只有在合并分区的时候才会触发汇总的逻辑。

(3)以数据分区为单位来聚合数据。当分区合并时,同一数据分 区内聚合Key相同的数据会被合并汇总,而不同分区之间的数据则不会 被汇总。

(4)如果在定义引擎时指定了columns汇总列(非主键的数值类型 字段),则SUM汇总这些列字段;如果未指定,则聚合所有非主键的数 值类型字段。

(5)在进行数据汇总时,因为分区内的数据已经基于ORBER BY 排序,所以能够找到相邻且拥有相同聚合Key的数据。

(6)在汇总数据时,同一分区内,相同聚合Key的多行数据会合并,成一行。其中,汇总字段会进行SUM计算;对于那些非汇总字段,则会 使用第一行数据的取值。

(7)支持嵌套结构,但列字段名称必须以Map后缀结尾。嵌套类 型中,默认以第一个字段作为聚合Key。除第一个字段以外,任何名称 以Key、Id或Type为后缀结尾的字段,都将和第一个字段一起组成复合 Key。

预先聚合计算

AggregatingMergeTree

CREATE TABLE agg_table(

 id String

, city String

, code AggregateFunction(uniq,String)

, value AggregateFunction(sum,UInt32), create_time DateTime 

)ENGINE = AggregatingMergeTree() PARTITION BY toYYYYMM(create_time) ORDER BY (id,city) 

PRIMARY KEY id

AggregateFunction是ClickHouse提供的一种特殊的数据类型,它能 够以二进制的形式存储中间状态结果。其使用方法也十分特殊,对于 AggregateFunction类型的列字段,数据的写入和查询都与寻常不同。在 写入数据时,需要调用*State函数;而在查询数据时,则需要调用相应 的*Merge函数。其中,*表示定义时使用的聚合函数。例如示例中定义 的code和value,使用了uniq和sum函数:

code AggregateFunction(uniq,String)

, value AggregateFunction(sum,UInt32),

那么,在写入数据时需要调用与uniq、sum对应的uniqState和 sumState函数,并使用INSERT SELECT语法:

INSERT INTO TABLE agg_table 

SELECT 'A000'

              ,'wuhan'

              , uniqState('code1'), 

             sumState(toUInt32(100))

           , '2019-08-10 17:00:00'

在查询数据时,如果直接使用列名访问code和value,将会是无法显 示的二进制形式。此时,需要调用与uniq、sum对应的uniqMerge、 sumMerge函数:

SELECT 

       id

      ,city

      ,uniqMerge(code)

      ,sumMerge(value) 

FROM agg_table 

GROUP BY id,city

CREATE MATERIALIZED VIEW agg_view ENGINE = AggregatingMergeTree() PARTITION BY city 

ORDER BY (id,city) 

AS SELECT id

                  , city

                  , uniqState(code) AS code, 

                   sumState(value) AS value 

FROM agg_table_basic 

GROUP BY id, city

(1)用ORBER BY排序键作为聚合数据的条件Key。

(2)使用AggregateFunction字段类型定义聚合函数的类型以及聚 合的字段。

(3)只有在合并分区的时候才会触发聚合计算的逻辑。

(4)以数据分区为单位来聚合数据。当分区合并时,同一数据分 区内聚合Key相同的数据会被合并计算,而不同分区之间的数据则不会 被计算。

(5)在进行数据计算时,因为分区内的数据已经基于ORBER BY 排序,所以能够找到那些相邻且拥有相同聚合Key的数据。

(6)在聚合数据时,同一分区内,相同聚合Key的多行数据会合并 成一行。对于那些非主键、非AggregateFunction类型字段,则会使用第 一行数据的取值。

(7)AggregateFunction类型的字段使用二进制存储,在写入数据 时,需要调用*State函数;而在查询数据时,则需要调用相应的*Merge 函数。其中,*表示定义时使用的聚合函数。

(8)AggregatingMergeTree通常作为物化视图的表引擎,与普通 MergeTree搭配使用。

CollapsingMergeTree

CollapsingMergeTree就是一种通过以增代删的思路,支持行级数据 修改和删除的表引擎。它通过定义一个sign标记位字段,记录数据行的 状态。如果sign标记为1,则表示这是一行有效的数据;如果sign标记 为-1,则表示这行数据需要被删除。当CollapsingMergeTree分区合并 时,同一数据分区内,sign标记为1和-1的一组数据会被抵消删除。这种 1和-1相互抵消的操作,犹如将一张瓦楞纸折叠了一般。

sign用于指定一个Int8类型的标志位字段。一个完整的使用

CREATE TABLE collpase_table( 

              id String

              , code Int32

              , create_time DateTime

              , sign Int8 

)ENGINE = CollapsingMergeTree(sign) PARTITION BY toYYYYMM(create_time) ORDER BY id

其一,修改一行数据:

--修改前的源数据, 它需要被修改 

INSERT INTO TABLE collpase_table VALUES('A000',100,'2019-02-20 00:00:00',1) 


--镜像数据, ORDER BY字段与源数据相同(其他字段可以不同),sign取反为-1,它会和源数据折叠 INSERT INTO TABLE collpase_table VALUES('A000',100,'2019-02-20 00:00:00',-1) 


--修改后的数据 ,sign为1 INSERT INTO TABLE collpase_table VALUES('A000',120,'2019-02-20 00:00:00',1)

其二,删除一行数据:

--修改前的源数据, 它需要被删除 INSERT INTO TABLE collpase_table VALUES('A000',100,'2019-02-20 00:00:00',1)


 --镜像数据, ORDER BY字段与源数据相同, sign取反为-1, 它会和源数据折叠 INSERT INTO TABLE collpase_table VALUES('A000',100,'2019-02-20 00:00:00',-1)

CollapsingMergeTree在折叠数据时,遵循以下规则。

·如果sign=1比sign=-1的数据多一行,则保留最后一行sign=1的数 据。

·如果sign=-1比sign=1的数据多一行,则保留第一行sign=-1的数据。

·如果sign=1和sign=-1的数据行一样多,并且最后一行是sign=1,则 保留第一行sign=-1和最后一行sign=1的数据。

·如果sign=1和sign=-1的数据行一样多,并且最后一行是sign=-1,则 什么也不保留。

·其余情况,ClickHouse会打印警告日志,但不会报错,在这种情形 下,查询结果不可预知。

在使用CollapsingMergeTree的时候,还有几点需要注意。

(1)折叠数据并不是实时触发的,和所有其他的MergeTree变种表 引擎一样,这项特性也只有在分区合并的时候才会体现。所以在分区合 并之前,用户还是会看到旧的数据。解决这个问题的方式有两种。

·在查询数据之前,使用optimize TABLE table_name FINAL命令强 制分区合并,但是这种方法效率极低,在实际生产环境中慎用。

·需要改变我们的查询方式。以collpase_table举例,如果原始的SQL 如下所示:

SELECT 

id

,SUM(code)

,COUNT(code)

,AVG(code)

,uniq(code) 

FROM collpase_table 

GROUP BY id

则需要改写成如下形式:

SELECT 

         id

         ,SUM(code * sign)

         ,COUNT(code * sign)

         ,AVG(code * sign)

         ,uniq(code * sign) 

FROM collpase_table 

GROUP BY id 

HAVING SUM(sign) > 0

(2)只有相同分区内的数据才有可能被折叠。不过这项限制对于 CollapsingMergeTree来说通常不是问题,因为修改或者删除数据的时 候,这些数据的分区规则通常都是一致的,并不会改变。

(3)最后这项限制可能是CollapsingMergeTree最大的命门所在。CollapsingMergeTree对于写入数据的顺序有着严格要求。现在用一个示 例说明。如果按照正常顺序写入,先写入sign=1,再写入sign=-1,则能 够正常折叠:

--先写入sign=1 INSERT INTO TABLE collpase_table VALUES('A000',102,'2019-02-20 00:00:00',1) 


--再写入sign=-1 INSERT INTO TABLE collpase_table VALUES('A000',101,'2019-02-20 00:00:00',-1)


现在将写入的顺序置换,先写入sign=-1,再写入sign=1,则不能够 折叠:


--先写入sign=-1 INSERT INTO TABLE collpase_table VALUES('A000',101,'2019-02-20 00:00:00',-1) 


--再写入sign=1 INSERT INTO TABLE collpase_table VALUES('A000',102,'2019-02-20 00:00:00',1)

这种现象是CollapsingMergeTree的处理机制引起的,因为它要求 sign=1和sign=-1的数据相邻。而分区内的数据基于ORBER BY排序,要 实现sign=1和sign=-1的数据相邻,则只能依靠严格按照顺序写入。如果数据的写入程序是单线程执行的,则能够较好地控制写入顺 序;如果需要处理的数据量很大,数据的写入程序通常是多线程执行 的,那么此时就不能保障数据的写入顺序了。在这种情况下, CollapsingMergeTree的工作机制就会出现问题。为了解决这个问题, ClickHouse另外提供了一个名为VersionedCollapsingMergeTree的表引 擎

VersionedCollapsingMergeTree

VersionedCollapsingMergeTree表引擎的作用与CollapsingMergeTree 完全相同,它们的不同之处在于,VersionedCollapsingMergeTree对数据 的写入顺序没有要求,在同一个分区内,任意顺序的数据都能够完成折 叠操作。VersionedCollapsingMergeTree是如何做到这一点的呢?其实从 它的命名各位就应该能够猜出来,是版本号。

ENGINE = VersionedCollapsingMergeTree(sign,ver)

CREATE TABLE ver_collpase_table( 

             id String

             , code Int32

             , create_time DateTime

             , sign Int8

             , ver UInt8 )ENGINE = VersionedCollapsingMergeTree(sign,ver) PARTITION BY toYYYYMM(create_time) ORDER BY id

VersionedCollapsingMergeTree是如何使用版本号字段的呢?其实很 简单,在定义ver字段之后,VersionedCollapsingMergeTree会自动将ver 作为排序条件并增加到ORDER BY的末端。以上面的ver_collpase_table 表为例,在每个数据分区内,数据会按照ORDER BY id,ver DESC排 序。所以无论写入时数据的顺序如何,在折叠处理时,都能回到正确的 顺序。

各种MergeTree之间的关系总结

继承关系

组合关系



四、其他常见类型表引擎

外部存储类型

HDFS

ENGINE = HDFS(hdfs_uri,format)

hdfs_uri表示HDFS的文件存储路径

format表示文件格式(指ClickHouse支持的文件格式,常见的有 CSV、TSV和JSON等)。

HDFS表引擎通常有两种使用形式

既负责读文件,又负责写文件。

CREATE TABLE hdfs_table1( 

          id UInt32

          , code String

          , name String 

)ENGINE = HDFS('hdfs://hdp1.nauu.com:8020/clickhouse/hdfs_table1','CSV')

可以发现,通过HDFS表引擎,ClickHouse在HDFS的指定目录下创 建了一个名为hdfs_table1的文件,并且按照CSV格式写入了数据。不过 目前ClickHouse并没有提供删除HDFS文件的方法,即便将数据表 hdfs_table1删除:

只负责读文件,文件写入工作则由其他外部系统完成。

MySQL

ENGINE = MySQL('host:port', 'database', 'table', 'user', 'password'[, replace_query, 'on_duplicate_clause'])

host:port表示MySQL的地址和端口。

database表示数据库的名称。

table表示需要映射的表名称。

user表示MySQL的用户名。

password表示MySQL的密码。

replace_query默认为0,对应MySQL的REPLACE INTO语法。如果 将它设置为1,则会用REPLACE INTO代替INSERT INTO

·on_duplicate_clause默认为0,对应MySQL的ON DUPLICATE KEY 语法。如果需要使用该设置,则必须将replace_query设置成0。

当通过MySQL表引擎向远端MySQL数据库写入数据的同时,物化 视图也会同步更新数据。不过比较遗憾的是,目前MySQL表引擎不支持任何UPDATE和 DELETE操作,如果有数据更新方面的诉求,可以考虑使用 CollapsingMergeTree作为视图的表引擎。

JDBC

Kafka

目前ClickHouse还不支 持恰好一次(Exactly once)的语义

ENGINE = Kafka() 

            SETTINGS 

kafka_broker_list = 'host:port,... ', kafka_topic_list    = 'topic1,topic2,...', kafka_group_name = 'group_name', kafka_format       = 'data_format'[,] [kafka_row_delimiter = 'delimiter_symbol'] [kafka_schema    = ''] 

[kafka_num_consumers = N] 

[kafka_skip_broken_messages = N]

 [kafka_commit_every_batch = N]

kafka_broker_list:表示Broker服务的地址列表,多个地址之间使用 逗号分隔,例如'hdp1.nauu.com:6667,hdp2.nauu.com:6667'。

kafka_topic_list:表示订阅消息主题的名称列表,多个主题之间使 用逗号分隔,例如'topic1,topic2'。多个主题中的数据均会被消费。

kafka_group_name:表示消费组的名称,表引擎会依据此名称创建 Kafka的消费组。

kafka_format:表示用于解析消息的数据格式,在消息的发送端, 必须按照此格式发送消息。数据格式必须是ClickHouse提供的格式之 一,例如TSV、JSONEachRow和CSV等。

接下来是选填参数:

kafka_row_delimiter:表示判定一行数据的结束符,默认值为'\0'。

kafka_schema:对应Kafka的schema参数。

kafka_num_consumers:表示消费者的数量,默认值为1。表引擎会 依据此参数在消费组中开启相应数量的消费者线程。在Kafka的主题 中,一个Partition分区只能使用一个消费者。

kafka_skip_broken_messages:当表引擎按照预定格式解析数据出 现错误时,允许跳过失败的数据行数,默认值为0,即不允许任何格式 错误的情形发生。在此种情形下,只要Kafka主题中存在无法解析的数 据,数据表都将不会接收任何数据。如果将其设置为非0正整数,例如 kafka_skip_broken_messages=10,表示只要Kafka主题中存在无法解析的 数据的总数小于10,数据表就能正常接收消息数据,而解析错误的数据 会被自动跳过。

内存类型

                Set

Memory

Memory表引擎直接将数据保存在内存中,数据既不会被压缩也不 会被格式转换,数据在内存中保存的形态与查询时看到的如出一辙。正 因为如此,当ClickHouse服务重启的时候,Memory表内的数据会全部丢 失。所以在一些场合,会将Memory作为测试表使用,很多初学者在学 习ClickHouse的时候所写的Hello World程序很可能用的就是Memory表。由于不需要磁盘读取、序列化以及反序列等操作,所以Memory表引擎 支持并行查询,并且在简单的查询场景中能够达到与MergeTree旗鼓相 当的查询性能(一亿行数据量以内)

CREATE TABLE memory_1 ( 

         id UInt64

 )ENGINE = Memory()



ps:欢迎加我微信:edw0808 讨论技术,批评指正!


预告:送书活动继续ing...,最近将安排《云原生数据中台:架构、方法论与实践》x5,关注公众号最新动态,不要错过哦


ps:本公众号资源、福利不停,需要资料视频加群,备注:数仓,请添加微信『ID:iom1128』获取!

资源获取 获取Flink,Spark,Hive,Hadoop,面试题,数据治理,数据质量等资源请公众号后台回复关键词:flink,hive,hbase,数据治理,数据质量,机器学习等;也可以公众号菜单栏自行查看更多精彩专题。

ClickHouse 在有赞的实践之路

ClickHouse 在有赞的实践之路

基于Flink+Clickhouse构建实时数仓最佳实践


回复:ck进阶,下载xmind 

希望这篇文章可以帮到你~

欢迎大家点个在看,分享至朋友圈



# 我是空空,公众号[数据仓库与Python大数据]、[数据爱好者社区]签约作者。擅长数据仓库、模型建设、数据治理等领域,涉猎金融、电信、电商、互联网等多个行业。希望大家支持,多多分享转发#

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存