ClickHouse 极简教程-图文详解原理系列ClickHouse 主键索引的存储结构与查询性能优化...

历览千载书,时时见遗烈。这篇文章主要讲述ClickHouse 极简教程-图文详解原理系列ClickHouse 主键索引的存储结构与查询性能优化...相关的知识,希望能为你提供帮助。
概述这是 Alexey Milovidov(ClickHouse 的创建者)给出的关于复合主键的答案的翻译。
原文:  https://groups.google.com/g/clickhouse/c/eUrsP30VtSU/m/p4-pxgdXAgAJ
问题:

  1. 主键可以有多少列?存储驱动器上的数据布局是什么?有任何理论/实践限制吗?
  2. 某些行缺少数据的列可以成为主键的一部分吗?
This is the translation of answer given by Alexey Milovidov (creator of ClickHouse) about composite primary key.
Questions:

1.How many columns primary key could have? And what is layout of data on storage drive? Is there any theoretical/practical limits?
2.Could columns with missing data at some rows be part of primary key?


在每一个部分按主键按字典顺序存储的数据。例如,如果您的主键 - (CounterID, Date),那么行将按 CounterID 排序,而对于具有相同 CounterID 的行 - 按日期排序。
概念说明
  • block:一次写入生成的一个数据块。
  • primary.idx 文件:存储了稀疏索引,一个part对应一个稀疏索引。
  • bin文件:真正存储数据的文件,由1到多个压缩数据组成。压缩数据是最小存储单位,由『头文件』和『压缩数据块』组成。头文件由压缩算法、压缩前的字节大小、压缩后的字节大小三部分组成;压缩数据块严格限定在压缩前 64K~1M byte 大小。(这个大小是ClickHouse认为的压缩与解压性能消耗最小的大小)。即,一个压缩数据块由N个block组成,一个bin文件又由N个压缩数据块组成。


  • mrk文件:存储了block在bin文件中哪个压缩数据以及这个压缩数据的数据块中的起始偏移量。
ClickHouse 主键索引【联合索引、排序键】ClickHouse 官网的主键相关内容:
主键和索引在查询中的表现我们以  ??(CounterID, Date)??  以主键。排序好的索引的图示会是下面这样:

如果指定查询如下:
  • ??CounterID in (a, h)???,服务器会读取标记号在  ??[0, 3)??  和  ??[6, 8)??  区间中的数据。
  • ??CounterID IN (a, h) AND Date = 3???,服务器会读取标记号在  ??[1, 3)??  和  ??[7, 8)??  区间中的数据。
  • ??Date = 3???,服务器会读取标记号在  ??[1, 10]??  区间中的数据。
上面例子可以看出使用索引通常会比全表描述要高效。
稀疏索引会引起额外的数据读取。当读取主键单个区间范围的数据时,每个数据块中最多会多读  ??index_granularity * 2??  行额外的数据。
稀疏索引使得您可以处理极大量的行,因为大多数情况下,这些索引常驻于内存。
ClickHouse 不要求主键唯一,所以您可以插入多条具有相同主键的行。
主键的构成,同样可以存在函数表达式。如,(CounterID,EventDate,intHash32(UserID))
上述例子中,通过使用哈希函数,把特定的用户名对应的CounterID和EVENTDATE做了聚合,顺便,这种聚合方式,可以在样本这个功能中利用到。稀疏索引适用于海量数据表,并且,稀疏索引文件本身,放到内存是没有问题的
ClickHouse 的索引优化
1.分区,原则是尽量把经常一起用到的数据放到相同区(也可以根据where条件来分区),如果一个区太大再放到多个区,
2.主键(索引,即排序)order by字段选择:就是把where 里面肯定有的字段加到里面,where 中一定有的字段放到第一位,注意字段的区分度适中即可 区分度太大太小都不好,因为ck的索引时稀疏索引,采用的是按照固定的粒度抽样作为实际的索引值,不是mysql的二叉树,所以不建议使用区分度特别高的字段。
两种主键,第一种ORDER BY (industry, l1_name, l2_name, l3_name, job_city, job_area, row_id),第二种不包含row_id字段,即ORDER BY (industry, l1_name, l2_name, l3_name, job_city, job_area),其中row_id 是唯一的,在where条件中使用row_id来查询时,你会发现第二种会性能更好,即将row_id从主键中移除,查询效果更好。
另外,ClickHouse 的索引结构是稀疏索引 , 跟 MySQL 的二叉树数据结构完全不同。
建索引的正确方式
开始字段不应该是区分度很高的字段,如果是唯一的,那么索引效果非常差,也不能找区分度特别差的,应该找区分度中等,这就涉及到你的SETTINGS的值,如果比较大,可以找区分度稍差的列,如果比较小,找区分度稍大的列作为索引。


void MergeTreeDataPartWriterOnDisk::initPrimaryIndex()

if (metadata_snapshot-> hasPrimaryKey())

index_file_stream = data_part-> volume-> getDisk()-> writeFile(part_path + "primary.idx", DBMS_DEFAULT_BUFFER_SIZE, WriteMode::Rewrite);
index_stream = std::make_unique< HashingWriteBuffer> (*index_file_stream);


MergeTree 存储结构
其中, Columns.txt 记录的每一列的信息。

每一列都有一个bin文件和mrk文件,其中bin文件是实际的数据存储
primary.idx存储主键信息,结构与mrk一样,类似于一个稀疏索引。



在MergeTree进行查询的时候,最关键的在于定位Block。根据主键进行查询的时候性能会比较好,但是在进行非主键的查询的时候,由于是按照列存储的关系,会进行一次全扫描。

ClickHouse primary.idx 主键的数据结构是一个标记数组 —— 它是每  ??index_granularity???  行主键的值。  ??index_granularity??  — MergeTree 引擎的设置,默认为 8192。

我们说主键是排序数据的稀疏索引。
您不应该尝试减少  ??index_granularity??。ClickHouse 旨在通过大批量的行有效地处理数据,这就是为什么在读取期间添加一些额外的列不会影响性能的原因。index_granularity = 8192 — 对于大多数情况而言,物有所值。
主键不是唯一的。您可以插入许多具有相同主键值的行。
主键还可以包含函数表达式。
示例:(CounterID, EventDate, intHash32(UserID))
上面它用于混合??UserID???每个 tuple的特定数据??CounterID, EventDate??。顺便说一句,它用于采样(https://clickhouse.yandex/reference_en.html#SAMPLE 子句)。
让我们总结一下主键的选择会影响什么:
  1. 最重要和最明显的:主键允许在??SELECT??查询期间读取更少的数据。如上面的示例所示,为此目的在主键中包含许多列通常没有意义。
假设您有 primary key  ??(a, b)???。通过再添加一列??c???:??(a, b, c)??仅在同时符合两个条件时才有意义:
  • 如果您对此列有过滤器查询;- 在您的数据中,具有相同值的数据范围
    可能相当长(比 大几倍) 。换句话说,当再添加一列时,将允许跳过足够大的数据范围。??index_granularity``(a, b)??
2. 数据按主键排序。这样数据更可压缩。有时,通过在主键中添加一列可以更好地压缩数据。
3. 当你在合并中使用不同类型的带有附加逻辑的 MergeTree 时:CollapsingMergeTree、SummingMergeTree等,主键会影响数据的合并。出于这个原因,即使第 1 点不需要,也可能需要在主键中使用更多列。
主键的列数没有明确限制。长主键通常是无用的。在实际用例中,我看到的最大值约为 20 列(对于 SummingMergeTree),但我不推荐这种变体。
长主键会对插入性能和内存使用产生负面影响。
长主键不会对??SELECT??查询的性能产生负面影响。
在插入期间,所有列的缺失值将被替换为默认值并写入表。
Data in table of MergeTree type stored in set of multiple parts. On average you could expect little number of parts (units-tens per month).
In every part data stored sorted lexicographically by primary key. For example, if your primary key — (CounterID, Date), than rows would be located sorted by CounterID, and for rows with the same CounterID — sorted by Date.
Data structure of primary key looks like an array of marks — it’s values of primary key every index_granularity rows.
??index_granularity??  — settings of MergeTree engine, default to 8192.
We say that primary key is sparse index of sorted data. Let’s visualise it with only one part. (I should have equal length between marks, but it’s a bit imperfect to draw asci-art here):


It’s convenient to represent marks as marks of ruler. Primary key allows effectively read range of data. For  ??select??  ClickHouse chooses set of mark ranges that could contain target data.
This way,
if you select  ??CounterID IN (‘a’, ‘h’)??server reads data with mark ranges [0, 3) and [6, 8).
if you select  ??CounterID IN (‘a’, ‘h’) AND Date = 3??server reads data with mark ranges [1, 3) and [7, 8).
Sometimes primary key works even if only the second column condition presents in select:
if you select  ??Date = 3??server reads data with mark ranges [1, 10).
In our example it’s all marks except  ??0???  — this is 90% of data. In this case index isn’t really effective, but still allows to skip part of data.
On the other hand, if we have more data for one  ???CounterID???, index allows to skip wider ranges of  ??Date??  in data.
In any case, usage of index never could be less efficient than full scan.
Sparse index could read unnecessary rows: during read of one range of primary key  ??index_granularity * 2???  unnecessary rows in every part. It’s normal and you shouldn’t try to reduce  ??index_granularity??. ClickHouse designed to work effective with data by large batches of rows, that’s why a bit of additional column during read isn’t hurt the performance. index_granularity = 8192 — good value for most cases.
Sparse index allows to work with tables that have enormous number of rows. And it always fits in RAM.
Primary key isn’t unique. You can insert many rows with the same value of primary key.
Primary key can also contain functional expressions.
Example:
(CounterID, EventDate, intHash32(UserID))

Above it’s used to mix up the data of particular  ??UserID???  for every tuple  ??CounterID, EventDate??. By-turn it’s used in sampling (https://clickhouse.yandex/reference_en.html#SAMPLE clause).
Let’s sum up what choice of primary key affects:
  1. The most important and obvious: primary key allows to read less data during??SELECT??  queries. As shown in examples above it’s usually doesn’t make sense to include many columns into primary key for this purpose.
Let’s say you have primary key  ??(a, b)???. By adding one more column  ??c???:  ??(a, b, c)??  makes sense only if it conforms with both conditions:
  • if you have queries with filter for this column;
  • in your data could be quite long (several time bigger than??index_granularity??) ranges of data with the same values of  ??(a, b)??.
    In other words when adding one more column will allow to skip big enough ranges of data.
2. Data is sorted by primary key. That way data is more compressable. Sometimes it happens that by adding one more column into primary key data could be compressed better.
3. When you use different kinds of MergeTree with additional logic in merge:  CollapsingMergeTree,  SummingMergeTree  and etc., primary key affects merge of data. For this reason it might be necessary to use more columns in primary key even when it’s not necessary for point 1.
Number of columns into primary key isn’t limited explicitly. Long primary key is usually useless. In real use case the maximum that I saw was ~20 columns (for SummingMergeTree), but I don’t recommend this variant.
Long primary key will negatively affect insert performance and memory usage.
Long primary key will not negatively affect the performance of  ??SELECT??  queries.
During insert, missing values of all columns will be replaced with default values and written to table.
索引结构

Clickhouse 索引的大致思路是:
1.选取部分列作为索引列,整个数据文件的数据按照索引列有序;
2.将排序后的数据每隔 8192 行选取出一行,记录其索引值和序号 Mark’s number;
3.对于每个列(索引列和非索引列),记录 Mark’s number 与对应行的数据的 offset。

一个单独的  ??primary.idx??  文件中存储了每个第 N 行的主键值。其中 N 称为 index_granularity(通常,N = 8192)。
同时,对于每一列,都有带有标记的 column.mrk 文件,该文件记录的是每个第 N 行在数据文件中的偏移量。每个标记是一个 pair:(文件中的偏移量到压缩块的起始位置,解压缩块中的偏移量到数据的起始位置)。
通常,压缩块根据标记对齐,并且解压缩块中的偏移量为 0。
??primary.idx??  的数据始终驻留在内存,同时 column.mrk 的数据被缓存。
当我们要从 MergeTree 的一个分块中读取部分内容时,我们会查看 primary.idx 数据并查找可能包含所请求数据的范围,然后查看 column.mrk 并计算偏移量从而得知从哪里开始读取些范围的数据。由于稀疏性,可能会读取额外的数据。ClickHouse 不适用于高负载的简单点查询,因为对于每一个键,整个 index_granularity 范围的行的数据都需要读取,并且对于每一列需要解压缩整个压缩块。我们使索引稀疏,是因为每一个单一的服务器需要在索引没有明显内存消耗的情况下,维护数万亿行的数据。另外,由于主键是稀疏的,导致其不是唯一的:无法在 INSERT 时检查一个键在表中是否存在。你可以在一个表中使用同一个键创建多个行。
当你向 MergeTree 中插入一堆数据时,数据按主键排序并形成一个新的分块。为了保证分块的数量相对较少,有后台线程定期选择一些分块并将它们合并成一个有序的分块,这就是 MergeTree 的名称来源。当然,合并会导致?写入放大?。所有的分块都是不可变的:它们仅会被创建和删除,不会被修改。当运行 SELECT 查询时,MergeTree 会保存一个表的快照(分块集合)。合并之后,还会保留旧的分块一段时间,以便发生故障后更容易恢复,因此如果我们发现某些合并后的分块可能已损坏,我们可以将其替换为原分块。
MergeTree 不是 LSM 树,因为它不包含?memtable?和?log?:插入的数据直接写入文件系统。这使得它仅适用于批量插入数据,而不适用于非常频繁地一行一行插入 - 大约每秒一次是没问题的,但是每秒一千次就会有问题。我们这样做是为了简单起见,因为我们已经在我们的应用中批量插入数据。
MergeTree 表只能有一个(主)索引:没有任何辅助索引。在一个逻辑表下,允许有多个物理表示,比如,可以以多个物理顺序存储数据,或者同时表示预聚合数据和原始数据。
有些 MergeTree 引擎会在后台合并期间做一些额外工作,比如 CollapsingMergeTree 和 AggregatingMergeTree。这可以视为对更新的特殊支持。请记住这些不是真正的更新,因为用户通常无法控制后台合并将会执行的时间,并且 MergeTree 中的数据几乎总是存储在多个分块中,而不是完全合并的形式。

MergeTree is a family of storage engines that supports indexing by primary key. The primary key can be an arbitrary tuple of columns or expressions. Data in a MergeTree table is stored in “parts”. Each part stores data in the primary key order, so data is ordered lexicographically by the primary key tuple. All the table columns are stored in separate column.bin files in these parts. The files consist of compressed blocks. Each block is usually from 64 KB to 1 MB of uncompressed data, depending on the average value size. The blocks consist of column values placed contiguously one after the other. Column values are in the same order for each column (the primary key defines the order), so when you iterate by many columns, you get values for the corresponding rows.
The primary key itself is “sparse”. It does not address every single row, but only some ranges of data. A separate primary.idx file has the value of the primary key for each N-th row, where N is called index_granularity (usually, N = 8192). Also, for each column, we have column.mrk files with “marks”, which are offsets to each N-th row in the data file. Each mark is a pair: the offset in the file to the beginning of the compressed block, and the offset in the decompressed block to the beginning of data. Usually, compressed blocks are aligned by marks, and the offset in the decompressed block is zero. Data for primary.idx always resides in memory, and data for column.mrk files is cached.

以一个二维表(date, city, action)为例介绍了整个索引结构,其中(date,city)是索引列。

以如下查询为例看索引的使用
select count(distinct action) where date=toDate(2020-01-01) and city=’bj’

  • 二分查找 primary.idx 并找到对应的 mark’s number 集合(即数据 block 集合)
  • 在上一步骤中的 block 中,在 date 和 city 列中查找对应的值的行号集合,并做交集,确认行号集合
  • 将行号转换为 mark’s number 和 offset in block(注意这里的 offset 以行为单位而不是 byte)
  • 在 action 列中,根据 mark’s number 和.mark 文件确认数据 block 在 bin 文件中的 offset,然后根据 offset in block 定位到具体的列值。
  • 后续计算
该实例中包含了对于列的正反两个方向的查找过程。
反向:查找 date=toDate(2020-01-01) and city=’bj’数据的行号;
正向:根据行号查找 action 列的值。对于反向查找,只有在查找条件匹配最左前缀的时候,才能剪枝掉大量数据,其它时候并不高效。
ClickHouse 带索引的检索过程以
where partition = 2019-10-23 and ID > = 10 and ID < 100

这个 query 描述大体检索流程(其中,ID是索引字段 ):
每个索引都有对应的min/max的partition值,存储在内存中。
1.当contition带上partition时就可以从这些block列表中找到需要检索的索引,找到对应的数据存储文件夹,命中对应的索引(primary.idx)。
2.根据ID字段,把条件转化为[10,100)的条件区间,再把条件区间与这个partition对应的稀疏索引做交集判断。如果没有交集则不进行具体数据的检索;如果有交集,则把稀疏索引等分8份,再把条件区间与稀疏索引分片做交集判断,直到不能再拆分或者没有交集,则最后剩下的所有条件区间就是我们要检索的block值。
3.通过步骤2我们得到了我们要检索的block值。通过上面我们知道存在多个block压缩在同一个压缩数据块的情况并且一个bin文件里面又存在N个压缩数据的情况,所以不能直接通过block的值直接到bin文件中搜寻数据。我们通过映射block值到mrk中,通过mrk知道这个block对应到的压缩数据以及在压缩数据块里面的字节偏移量,就得到了我们最后需要读取的数据地址。
4.把bin文件中的数据读取到内存中,找到对应的压缩数据,直接从对应的起始偏移量开始读取数据。
ClickHouse 索引查询原理(索引过程)通过上面的介绍相信大家已经对ClickHouse的索引结构有所了解,接下来用一张图简要描述Id字段的索引过程。

ClickHouse 在分片上执行查询语句过程如下:
  1. 根据查询语句中的分区范围,先进行分区级别的数据过滤。
    2.在满足分区条件的目录中,通过 primary.idx 文件,结合索引键的取值范围,查询出索引编号的范围。
    3.通过查询列的 [Column].mrk 文件,找到其 [Column].bin 文件中的偏移量对应关系,最终将数据加载到内存进行分析和计算。
索引文件和标记文件实际是一对多的关系(主键只有一个,但列有很多),将索引文件和标记文件剥离后,索引文件大小比较小,可以常驻内存。查询到数据范围后,可以直接计算出数据对应在标记文件中的位置,做最小化查询。

这里的行号其实只是用于关联起索引和标记两个表,而这两个表的数据在行方向其实是一一顺序对应的,因此行号其实是实际上是不需要存在文件中的,这也是Clickhouse追求极致性能,数据尽量精简的一个体现。
通过 od 查看真实的 primary.idx 索引文件内容可以通过od查看一下真实的数据索引文件中和数据标记文件中的数据:
数据索引文件,存储的是一个个主健的值,这里主键只有一列:
root@clickhouse-0:20210110_0_123_3_341# od -l -j 0 -N 80 --width=8 primary.idx
0000000 5670735277560
0000010 24176312979802680
0000020 48658950580167724
0000030 72938406171441414
0000040 96513037981382350
0000050 120656338641242134
0000060 145024009883201898
0000070 169438340458750532
0000100 193384698694174670
0000110 217869890390743588

【ClickHouse 极简教程-图文详解原理系列ClickHouse 主键索引的存储结构与查询性能优化...】数据标记文件,可以看作三列,分别是数据压缩块位置,数据块内偏移和granule大小
root@clickhouse-

    推荐阅读