前言
承接上文 ClickHouse:起源和架构,简单了解下 ClickHouse 的核心引擎 MergeTree,并且具备很多其他特性的变种 MergeTree 引擎,是 CK 目前支持的 6 类 20+ 种引擎中最为强悍的家族引擎,且仅有MergeTree家族引擎支持 Partition(分区),Shard(分片),Replica(副本), Primary Index(主键索引) ,Sample(抽样)等特性。
MergeTree 建表和存储结构
MergeTree 的数据写入总是以数据片段形式落盘,且数据片段不可修改,片段的逐步增多会触发后台的合并线程对同一个分区内的数据片段进行合并,MergeTree 的名称也由此得来
建表
CREATE TABLE [IF NOT EXISTS] [db.]table_name [ON CLUSTER cluster]
(
name1 [type1] [DEFAULT|MATERIALIZED|ALIAS expr1] [TTL expr1],
name2 [type2] [DEFAULT|MATERIALIZED|ALIAS expr2] [TTL expr2],
...
INDEX index_name1 expr1 TYPE type1(...) GRANULARITY value1,
INDEX index_name2 expr2 TYPE type2(...) GRANULARITY value2
) ENGINE = MergeTree()
ORDER BY expr
[PARTITION BY expr]
[PRIMARY KEY expr]
[SAMPLE BY expr]
[TTL expr [DELETE|TO DISK 'xxx'|TO VOLUME 'xxx'], ...]
[SETTINGS name=value, ...]
直观感受和 Mysql 建表非常类似但不尽相同,官网定义示例下也做了关键字说明,稍作解析:
ENGINE
– 必填:指定 Table 使用引擎ORDER BY
– 必填:排序 Key,指定数据片段内数据以何种标准排序,可以是单个name1,也可以是 name1和 name2 的元组,此时先按 name1 顺序,而后再以 name2 排序PARTITION BY
– 可选:指定分区Key,在不声明 partition 时所有数据在一个默认分区 all 下,分区 Key 可以类似ORDER BY
单列,列元组均可,合理规划分区可以达到前文所说大大减少 Scan 数据范围的功效,提升查询性能PRIMARY KEY
– 可选:主键,声明后会按照声明Key 生成一级索引,不声明时会默认与ORDER BY
中 Key 保持一直生成索引,与 Mysql 中主键概念不同,CK 中 PRIMARY KEY 允许重复,在 MergeTree 的家族引擎中,ReplacingMergeTree 引擎主键不可重SAMPLE BY
– 可选:抽样表达式,配合 SAMPLE 自查询使用,如果指定了SAMPLE BY
则在主键中必须包含该表达式SEETINGS
– 可选配置表的其他默认字段,官网介绍中有逐一介绍,此处仅仅关注个别核心配置字段:index_granularity
:索引间隔,极其重要的一个配置参数,默认 1024*8 = 8192,表明一级索引以多少行的数据进行间隔创建,默认情况每间隔 8192 行生成一条索引记录index_granularity_bytes
:以数据大小自适应生成索引,新版CK 支持设置参数,默认为 10M数据,index_granularity
是固定数据行间隔,index_granularity_bytes
则是数据体量
以上一般 CK 建表的特性参数基本如上,更多的参数请参考官网。CK 19.6 及其后版本支持TTL 设定,建表参数重亦可声明指定。
数据存储
参考CK原理解析书中图片,数据会按分区的目录在磁盘中保存,基本结构如下:
可以看出表数据在磁盘中的目录结构是以 Partition 划分,归属相同分区的数据会被划分到统一分区目录下存储,不同分区下数据永远不会被合并。Partition 内部有一系列文件包括 基础文件,分区文件,二级索引文件,其中基础文件是分区的主体文件,包含了数据文件,一级索引文件,元信息文件
checksums.txt
:校验文件,二进制格式存储,保存了其他各类文件的 size 以及 size 哈希值,用于快速校验分区内各个文件的完整性columns.txt
:列元信息存储文件,明文存储
count.txt
:如其名称,数据行数记录文件,记录当前分区目录下数据行数,明文存储
primary.idx
:主键(一级)索引文件,稀疏索引column.bin
,column.mrk
,column.mrk2
:column.bin
二进制数据文件保存列的数据;column.mrk
列标记文件,二进制存储;column.mrk2
如果设置了自适应数据合并则以 *.mrk2 二进制文件存储。标记文件非常重要,其职能是衔接 上接索引下接数据的粘合职能的文件。由索引过滤查询数据的实质并不是直接作用于数据文件,是对接到数据标记文件,而后查询出数据偏移量,再从 column.bin 文件中读出对应数据partition.dat
,minmax_column.idx
:使用PARTITION BY
时生成的二进制文件,partition.dat
用以保存当前分区下分区表达式的最终值,minmax_column.idx
则记录了当前分区下分区字段对应原始数据的最大/小值,此处理解有些模糊,For Example:
CreateTime 字段 2021-05-01 ~ 2021-05-05
分区表达式 PARTITION BY toYYYYMM(CreateTime) // 设置以年月分区
则 partition.dat 保存为 2021-05 的二进制值
而 minmax_column.idx 保存 2021-05-01 和 2021-05-05 的二进制值
skp_index_[column].idx
,skp_index_[column].mrk
:在建表表达式中,声明了INDEX index_name1
,这里是为某列声明了索引,此类索引为 二级索引,跳数索引。这两个文件类似primary.idx
和column.mrk
的作用
Partition 数据分区及合并
前文以及本文开篇重复提及 Partition(分区),Shard(分片),Replica(副本),CK中这三者务必要能够深刻区分,否则整体很难顺通理解。
CK 的数据分区是对数据在一个单节点层面垂直切分,为了加速在一个节点内数据的 Scan 效率。如果想要横向拓展数据到多个节点存储,则需要利用 Shard(分片) 能力,后续在分布式引擎中再行梳理。MergeTree 的实质,就是分区目录合并的过程,
执行分区合并是CK自动进行新数据插入后10-15min,也可以手动通过指令 optimize
触发。
分区数据规则及命名规则
在创建数据表示提供了声明PARTITION BY
的关键字用以指定分区表达式,可以是单个列,多列的组合,当不声明时默认所有数据都在分区 all
下。声明分区后数据的划分则是由分区ID 决定,分区 ID 的计算:
- 对于指定整型字段,且无法转化为 YYYYYMMDD 的日期格式则直接由 整型数值的字符串格式输出作为 ID
- 指定日期字段或者能转化为 YYYYYMMDD 的日期格式,则有对应的字符串作为 ID 取值
- 如果均不是上述二者,而是由 string,float 等其他类型则取其 Hash 值作为 ID 取值
- 如果取值是多列的元组,则每个字段的 ID 值遵从上述原则,而后以 “-” 进行链接
实际完整数据分区的ID 不仅有上述规则,而是遵从以下表达式:
PartitionID_MinBlockNumb_MaxBlockNumb_Level
PartitionID: 就是命名规则中换算出的ID值
MinBlockNumb / MaxBlockNumb: 数据块最小 / 大块编号,在单个 MergeTree 表内部,每个数据块都是有一个自增的数据块编号的,最小单元就是最初的分区块,而后分区合并后就是块的的集合合并
Level: 标记合并层级,数值越大说明历经了合并的次数越多,初始新分区均是 0
分区合并
MergeTree 的分区目录并不是在初始化建表后就存在,由之前分分区名称规则可知是根据插入数据值计算得来的,分区是实际插入数据后才会生成的。并且在分区生成后并不是不在变化,而是会进行分区合并,这也是 MergeTree 名称的由来。
对于一次写入,或者一批写入,都会生成多个分区目录,哪怕对应的分区命名规则中的 PartitionID 相同,数据块编号不同,相同 PartitionID 的分区目录会进行合并生成新目录,目录中的索引文件和数据文件都会相应变化,合并过程新分区的名称还是遵从前文中PartitionID_MinBlockNumb_MaxBlockNumb_Level
命名规则:
PartitionID:由于是 PartitionID 相同分区I才会进行合并,此部分保持不变
MinBlockNumb:被合并区域中的最小值
MaxBlockNumb:被合并区域中的最大值
Level:取被合并对象中最大值 + 1
For Example:
// T0 时刻,Table 中存在3条数据对应为:
2021-05-03_1_1_0 // PartitionID: 2021-05-03 MinBlockNumb: 1 MaxBlockNumb: 1 Level: 0
2021-05-03_2_2_0 // PartitionID: 2021-05-03 MinBlockNumb: 2 MaxBlockNumb: 2 Level: 0
2021-05-04_3_3_0 // PartitionID: 2021-05-04 MinBlockNumb: 3 MaxBlockNumb: 3 Level: 0
触发合并
// T1时刻触发合并,对相同分区进行合并
2021-05-03_1_1_0 & 2021-05-03_2_2_0 // 合并遵从先前说明的合并规则
=> 2021-05-03_1_2_1
// PartitionID: 保持不变 2021-05-03
// MinBlockNumb: 取被合并中最小值 1
// MaxBlockNumb: 取被合并中最大值 2
// Level: 取被合并对象中最大值+1 = 0+1 = 1
值得注意:合并后的旧分区目录 2021-05-03_1_1_0 与 2021-05-03_2_2_0 并非立马被删除,而是在某个时刻由后台程序统一删除
再次写入数据:
// T2时刻写入 PartitionID 2021-05-03 的数据
2021-05-03_1_2_1
2021-05-04_3_3_0
2021-05-03_4_4_0
再次触发合并
// T3 时刻再次触发合并,相同 PartitionID 分区进行合并
2021-05-03_1_4_2 // PartitionID不变,MinBlockNumb=1,MaxBlockNumb=4,Level=1+1
2021-05-04_3_3_0 // 没有相同 PartitionID,未经历合并
Google 图库中截取了 ClickHouse原理解析和应用实践中的完整过程图如下:
索引
在建表处的声明中已有完整的建表关键字,其中有两处出现 Index 的字样分别是:
PRIMARY KEY
:一级索引 – 稀疏索引INDEX index_name1 expr1 TYPE type1(...) GRANULARITY value1
:二级索引 – 跳数索引
primary.idx 一级索引
生成规则
PRIMARY KEY
声明主键索引表达式,当省略时主键索引默认与 ORDER BY
的 Key 一致。MergeTree 会根据 Key 值以及之前提及的重要 SETTINGS
参数 index_granularity
索引间隔来生成一级索引,并保存至 primary.idx
文件中。当使用默认 ORDER BY
的 Key 时一级索引的顺序与数据文件 Column.bin
是保持一致。
Mysql 的主键索引是密集索引,即每一行数据的主键都会录入索引中,而 CK 的主键索引是稀疏索引,是根据主键 Key 顺序分段取值构建索引,其中分段的间隔就是由 index_granularity
(默认8192) 参数决定的。前文也提及索引真正过度到数据是由标记文件(*.mrk)文件进行映射的,所以一级索引的间隔粒度与标记文件间隔粒度是对齐的。
假设我们数据以 PARTITION BY toYYYYMM(CreateTime)
设定,那么 2021-04 的数据会被录入同一个分区目录下,且不主动声明主键复用ORDER BY CounterID
为主键索引。
序号 | 0 | 1 | … | 8192 | 8193 | … | 8192*2 | 8192*2 +1 | … |
CounterID | 30 | 32 | … | 1311 | 1312 | … | 5178 | 5178 | … |
CreateTime | 2021-04-01 | 2021-04-01 | … | 2021-04-07 | 2021-04-07 | … | 2021-04-19 | 2021-04-20 | … |
以上表数据为例,每间隔 8192 行数据取 CounterID
作为索引值,primary.idx
文件内容为:
3013115178
。
30
为0号对应 CounterID
,1311
为 8193 号,5178
为 8192*2 号
如果 PARTITION BY toYYYYMM(CounterID, CreateTime)
设置多列元组,则primary.idx
内容为:
302021-04-0113112021-04-0751782021-04-19
查询过程
假设数据总行数为 192,index_granularity
=3每间隔3行数据取一个索引值,数据ID取值连续从A000,A001至A191,则 primary.idx
为
A000A003A006…A183A186A189
对应MergeTree 一共会把数据分为192/3 = 64 个区间 MarkRange:
MarkRange0 | MarkRange1 | ... | MarkRange63
(p0, p1) | (p1, p2) | ... | (p63, p64)
[A000, A003)| [A003, A006)| ... | [A0189, +inf)
相邻 MarkRange 之间步长是 pEnd-pStart = 1
,整个MarkRange对应的数值区间为 [A000, +inf) 查询过程:
- 将查询条件进行区间转化:
WHERE ID='A003' => [A003, A003]
,WHERE ID>'A000' => (A000, +inf)
,生成条件区间 - 递归判定交集,依次对 MarkRange 的数值区间与条件区间判定,
- 从最大区间 [A000, +inf) 开始判定,如果不存在直接直接整段剪枝
- 如果存在交集,且 MarkRange 对应大于等于 8(SETTINGS中一个参数设定,默认为8)倍步长(pEnd-pStart),则将此区间进行拆分成8个子区间,重复执行此判定
- 如存在交集 MarkRange 步长小于 8,则记录 MarkRange 并返回该 MarkRange
- 合并返回的 MarkRange,将所有查询到的 MarkRange 合并到一起
以上文数据为例,查询 [A003, A003]
条件区间:
阶段1:整个数据的 MarkRange 为 MarkRange{ (p0, p64), [A000, +inf)}
,[A003, A003]
条件区间在 MarkRange 内,无需剪枝,执行递归交集判定
阶段2:MarkRange{(p0, p64), [A000, +inf)}
拆成8个子区间,
MarkRange{(p0, p8), [A000, A24)}
,MarkRange{(p8, p16), [A024, A048)}
… MarkRange{(p48, p56), [A144, A168)}
, MarkRange{(p56, p64), [A168, +inf)}
,
判定后仅保留 MarkRange{ (p0, p8), [A000, A24)}
,其余均剪枝删除,步长等于8,进一步拆分
MarkRange{(p0, p1), [A000, A003)}
,MarkRange{(p8, p16), [A003, A006)}
… MarkRange{(p6, p7), [A018, A021)}
, MarkRange{(p7, p8), [A021, A024)}
判定后只命中 MarkRange{(p0, p1), [A000, A003)}
,MarkRange{(p8, p16), [A003, A006)}
阶段三:合并返回MarkRange,得出MarkRange{(p0, p2), [A000, A006)}
二级索引
在声明了二级索引的表中,分区目录下会生成:skp_index_[column].idx
和 skp_index_[column].mrk
文件。在一级稀疏索引中 index_granularity
标记了稀疏索引的索引间隔,而二级跳数索引中有参数 granularity
用以规定,二级的跳数索引能够跳跃 index_granularity
的个数
空泛说明不易理解,一个实际的例子,声明二级索引类型为 minmax 的极值类型对应 clolumn 为 ID 的二级索引:
INDEX id_skip_index ID TYPE minmax GRANULARITY 5
二级索引名称为 id_skip_index,列为 ID,类型是 minmax 极值索引,GRANULARITY
跳数为 5。
则表数据按照 index_granularity
= 8192 行数据进行汇总,数据片段0,1,2 … 依次往后
// 数据片段编号
| 0 | 1 | 2 | 3 | 4 | ...
// ID 在 index_granularity 行数片段内的极值
|0 1|2 3|4 5|6 7|8 9| ...
// GRANULARITY=5,汇总后第一行 minmax 索引
[0, 9]
二级索引类型除了 minmax 极值类型外,还支持 Set
类型等,官网中有其他类型二级索引的详细介绍。
数据存储
索引是使数据 Scan 更高效的一个方式,数据按列存储也是为了高效进行数据读取,最终数据是从存储数据的文件(*.bin)中读取出来。CK 数据文件中存储的数据首先都是压缩过的,之前提过压缩算法是 LZ4,还支持其他压缩算法:ZSTD,Multiple 和 Delta;其次数据是按照 ORDER BY
声明的 Key 值排序;最后数据是以压缩块的形式存储在bin文件中。
压缩数据块
对于压缩数据块,是由两部分组成:
压缩数据块
= 头信息
+ 压缩数据
其中头信息
占 9 个字节为:
头信息
= CompressionMethod 压缩算法类型 1 字节
+ CompressedSize 压缩后数据大小 4 字节
+ UncompressedSize 压缩前数据大小 4 字节
一个压缩数据块的 UncompressedSize 大小被控制在 64K ~ 1M 由:
- min_compress_block_size(默认 2^16=65536 个字节)
- max_compress_block_size (默认2^32=1048576 个字节)
两个设定决定,一个压缩块的最终实际数据大小则是由index_granularity
大小和上面两个参数决定,index_granularity
视为一个批次的数据: - 当一个批次的未压缩数据 size < 64Kb,则继续拿下一个批次数据,直到累计到数据 >= 64Kb 生成下一个数据块
- 当一个批次的数据 64Kb <= size <= 1M,则直接生成下一个 block 数据
- 一个批次数据 size > 1M 则按照 1M 大小把数据进行截断生成下个数据块,而后剩余部分的数据继续执行上面的判定逻辑
由上述的数据块压缩过程可知,数据 .bin 文件中存储的是一个个连续的压缩数据块,数据块的顺序就是写入数据按ORDER BY
排序后的顺序,文件中包含至1个数据块。bin 文件按压缩数据块存储,其一可以节约存储空间,其二存储是分小的block的,可以避免读取全量bin文件数据,可以逐个块读取。
数据标记
primary.idx 索引 和 数据存储 bin 文件都已梳理清楚,前文已提及串联二者从索引到数据的翻译官就是 .mrk 文件。primary.idx 索引就是目录,bin 就是目录对应的详细内容,mrk 文件记录了目录对应的页码信息,以及一段文字在一页中的位置信息。
索引是按照 index_granularity
大小生成索引文件的,.mrk 文件数据标记首先是和索引区间对齐的并且内容记录了数据文件中的偏移量信息, .mrk 文件中的每一行数据为一个包含两个整型数据的元组,0号位指代 压缩文件中的压缩数据偏移量,1号位指代解压缩数据的偏移量,如下图:
压缩文件中的偏移量 0-7 号都是 0,表示前八个索引区间对应到数据块的偏移量都是从 0 开始,解压缩后的偏移量是0,8192,8192*2,说明需要 8 个 index_granularity
的数据片段才达到 min_compress_block_size
的 64Kb,对应 65536 个字节。
实际查询时 .mrk 文件工作方式,引用 ClickHouse原理解析和应用实践中过程图
上图中展示了完整的数据定位过程:
首先读取压缩数据块。查询某列时无需家在完整 bin 文件,而是根据 mrk 文件中信息加载特定的 数据块。因为 primary.idx 与mrk 文件是对齐的,筛选到对应的 mrk 行元组后,根据压缩文件偏移量的信息,就能够读取特定的压缩块数据
而后读取数据:在读取数据时可以根据 mrk 文件中的解压缩块偏移量读取对应的需要解压缩的 index_granularity
粒度的一小段行数据。
至此整个 MergeTree 的核心基本已经梳理完毕,其中很多内容参考了ClickHouse原理解析和应用实践,收获颇多。