【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 時:CollapsingMergeTreeSummingMergeTree等,主鍵會影響數據的合并。出于這個原因,即使第 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

數據標記文件,可以看作三列,分別是數據壓縮塊位置,數據塊內偏移和granule大小

root@clickhouse-0:20210110_0_123_3_341# od -l -j 0 -N 240 --width=24 ./value9.mrk2
0000000 0 0 8192
0000030 0 32768 8192
0000060 65677 0 8192
0000110 65677 32768 8192
0000140 129357 0 8192
0000170 129357 32768 8192
0000220 193106 0 8192
0000250 193106 32768 8192
0000300 258449 0 8192
0000330 258449 32768 8192

此外,在上面所舉的例子中,granule都是固定為8192大小的,于是每8192行會有一行索引數據以及一行標記數據。但是從數據所占空間來看,8192行數據可能占很大空間,也可能占很小空間。如果占了很大空間,則會導致龐大的數據卻只有一行索引一行標記,每次查詢要做大量掃描解壓的工作,拖慢整體性能,用戶必須很小心地配置index_granularity。于是在新版本的Clickhouse中,會默認開啟自適應granularity,新增配置項index_granularity_bytes來使得一個granule的數據大小不僅取決于行數,也取決于數據大小,因此在標記文件中會有新的一列來表示每個granule的行數。每index_granularity行

源碼分析

Columns

含義:表示內存中的列,使用IColumn接口,這個接口提供用于實現各種關系操作符的輔助方法,但是幾乎所有的操作都是不可變的,不會改變原始列,但是可以創建一個新的修改列。
不同的IColumn實現福別不同的內存布局。內存布局退出時一個連續的數組,但是也有特殊的,比如String,Array等就是使用兩個向量來組成的。

Field

Field是一個enum

    enum Which
    {
        Null    = 0,
        UInt64  = 1,
        Int64   = 2,
        Float64 = 3,
        UInt128 = 4,
        Int128  = 5,

        /// Non-POD types.

        String  = 16,
        Array   = 17,
        Tuple   = 18,
        Decimal32  = 19,
        Decimal64  = 20,
        Decimal128 = 21,
        AggregateFunctionState = 22,
    };

IDataType

負責序列化與反序列化,讀寫二進制或者文本形式的列或者單個值構成的塊。IDataType直接與表中的數據類型相對應
IDataType與IColumn之間的關聯并不大,不同類型的IDatatType可以使用相同的IColumn來表示。
IDataType僅僅存儲源數據

Block

Block是表示內存中表的子集(Chunk)的子集,由{IColumn,IDataType,列名}三元組構成。
在查詢執行期間,數據是按照Block進行處理的,

Block Streams

Block Streams用于處理數據,Block Streams從某個地方讀取數據,并進行數據轉換,或者將數據寫入到某個地方。
IBlockInputStream具有read方法,而IBlockOutputStream具有write方法。

IO

使用ReadBuffer和WriteBuffer兩個抽象類,來替代iostream。這兩個類實現用于處理文件、文件描述符、socket,也可以用于進行壓縮

Table

Table 由 IStorage 接口表示,這個接口實現對應不同的表引擎,實現也不一樣。比如StorageMergeTree,StorageMemory.

IStorage最主要的方法就是 write 、read 、 alter 、 rename 、 drop 等方法。

Clickhouse 小結:

  • MergeTree引擎眾多,最常用并且默認的引擎是Merge Tree引擎,其分布式引擎在測試上面能提高更為復雜SQL的查詢速度,但是其分布式表是依賴于ZK的偽分布式,需要專門維護本地表做分布式表
  • MergeTree Family 作為主要引擎系列,其中包含適合明細數據的場景和適合聚合數據的場景;
  • Clickhouse 的索引有點類似 MySQL 的聯合索引,當查詢前綴元組能命中的時候效率最高,可是一旦不能命中,幾乎會掃描整個表,效率波動巨大;所以建表需要業務專家,這一點跟 kylin 類似。

參考資料

https://clickhouse.com/docs/zh/engines/table-engines/mergetree-family/mergetree/#primary-keys-and-indexes-in-queries

https://blog.csdn.net/h2604396739/article/details/86172756

http://www.lxweimin.com/p/c69b1b73b93b

https://www.cnblogs.com/fourous/p/14725404.html

http://www.lxweimin.com/p/c69b1b73b93b

http://www.lxweimin.com/p/98dc2fa4ef5f

https://www.cnblogs.com/wayne2018/p/15733640.html

https://zhuanlan.zhihu.com/p/359924260

http://www.lxweimin.com/p/6d547cbdc7ac

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。