【ClickHouse 內核原理圖文詳解】關于分區、索引、標記和壓縮數據的協同工作

概述

ClickHouse 是一個用于聯機分析處理(OLAP)的列式數據庫管理系統(Columnar DBMS)。

分區、索引、標記和壓縮數據,這些組件配合在一起給 ClickHouse 數據庫帶來非常高效的查詢性能。

一切皆是映射。光劍

本文先簡單介紹一下這幾個組件。然后就分別從寫入過程、查詢過程,以及數據標記與壓縮數據塊的三種對應關系的角度展開介紹。

分區、索引、標記和壓縮數據核心組件介紹

MergeTree引擎存儲結構

MergeTree的存儲結構

  • partition:分區目錄,余下各類數據文件(primary.idx、[Column].mrk、[Column]. bin等)都是以分區目錄的形式被組織存放的,屬于相同分區的數據,最終會被合并到同一個分區目錄,而不同分區的數據,永遠不會被合并在一起。
  • checksums:校驗文件,使用二進制格式存儲。它保存了余下各類文件(primary. idx、count.txt等)的size大小及size的哈希值,用于快速校驗文件的完整性和正確性。
  • columns.txt:列信息文件,使用明文格式存儲,用于保存此數據分區下的列字段信息。
  • count.txt:計數文件,使用明文格式存儲,用于記錄當前數據分區目錄下數據的總行數。
  • primary.idx:一級索引文件,使用二進制格式存儲。用于存放稀疏索引,一張MergeTree表只能聲明一次一級索引。借助稀疏索引,在數據查詢的時能夠排除主鍵條件范圍之外的數據文件,從而有效減少數據掃描范圍,加速查詢速度。
  • [Column].bin:數據文件,使用壓縮格式存儲,用于存儲某一列的數據。由于MergeTree采用列式存儲,所以每一個列字段都擁有獨立的.bin數據文件,并以列字段名稱命名。
  • [Column].mrk:使用二進制格式存儲。標記文件中保存了.bin文件中數據的偏移量信息。標記文件與稀疏索引對齊,又與.bin文件一一對應,所以MergeTree通過標記文件建立了primary.idx稀疏索引與.bin數據文件之間的映射關系。即首先通過稀疏索引(primary.idx)找到對應數據的偏移量信息(.mrk),再通過偏移量直接從.bin文件中讀取數據。由于.mrk標記文件與.bin文件一一對應,所以MergeTree中的每個列字段都會擁有與其對應的.mrk標記文件
  • [Column].mrk2:如果使用了自適應大小的索引間隔,則標記文件會以.mrk2命名。它的工作原理和作用與.mrk標記文件相同。
  • partition.dat與minmax_[Column].idx:如果使用了分區鍵,例如PARTITION BY EventTime,則會額外生成partition.dat與minmax索引文件,它們均使用二進制格式存儲。partition.dat用于保存當前分區下分區表達式最終生成的值;而minmax索引用于記錄當前分區下分區字段對應原始數據的最小和最大值。
  • skp_idx_[Column].idx與skp_idx_[Column].mrk:如果在建表語句中聲明了二級索引,則會額外生成相應的二級索引與標記文件,它們同樣也使用二進制存儲。二級索引在ClickHouse中又稱跳數索引。

分區

在MergeTree中,數據是以分區目錄的形式進行組織的,每個分區獨立分開存儲: Partition_1, Partition_2, Partition_3, Partition_4, .....

借助這種形式,在對MergeTree進行數據查詢時,可以有效跳過無用的數據文件,只使用最小的分區目錄子集。

數據的分區規則

MergeTree數據分區的規則由分區ID決定,而具體到每個數據分區所對應的ID,則是由分區鍵的取值決定的。分區鍵支持使用任何一個或一組字段表達式聲明,其業務語義可以是年、月、日或者組織單位等任何一種規則。針對取值數據類型的不同,分區ID的生成邏輯目前擁有四種規則:

(1)不指定分區鍵:如果不使用分區鍵,即不使用PARTITION BY聲明任何分區表達式,則分區ID默認取名為all,所有的數據都會被寫入這個all分區。
(2)使用整型:如果分區鍵取值屬于整型(兼容UInt64,包括有符號整型和無符號整型),且無法轉換為日期類型YYYYMMDD格式,則直接按照該整型的字符形式輸出,作為分區ID的取值。
(3)使用日期類型:如果分區鍵取值屬于日期類型,或者是能夠轉換為YYYYMMDD格式的整型,則使用按照YYYYMMDD進行格式化后的字符形式輸出,并作為分區ID的取值。
(4)使用其他類型:如果分區鍵取值既不屬于整型,也不屬于日期類型,例如String、Float等,則通過128位Hash算法取其Hash值作為分區ID的取值。數據在寫入時,會對照分區ID落入相應的數據分區

partition:分區目錄,里面的各類數據文件(primary.idx、data.mrk、data.bin 等等)都是以分區目錄的形式被組織存放的,屬于相同分區的數據,最終會被合并到同一個分區目錄,而不同分區的數據永遠不會被合并在一起。

分區目錄的命名規則是:PartitionID_MinBlockNum_MaxBlockNum_Level

下面來解釋一下這幾個部分:

1)PartitionID:分區 ID,這個應該無需多說。

2)MinBlockNum、MaxBlockNum:最小數據塊編號和最大數據塊編號,這里的命名很容易讓人聯想到后面要說的數據壓縮塊,甚至產生混淆,但實際上這兩者沒有任何關系。這里的 BlockNum 是一個自增的整數,從 1 開始,每當創建一個新的分區時就會自增 1,并且對于一個新的分區目錄而言,它的 MinBlockNum 和 MaxBlockNum 是相等的。比如 202005_1_1_0、202006_2_2_0、202007_3_3_0,以此類推。但是也有例外,當分區目錄發生合并的時候,那么其 MinBlockNum 和 MaxBlockNum 會有另外的規則,一會兒細說。

3)Level:合并的層級,可以理解為某個分區被合并的次數,這里的 Level 和 BlockNum 不同,它不是全局累加的。對于每個新創建的目錄而言,其初始值都為 0,之后以分區為單位,如果相同分區發生合并動作,則該分區對應的 Level 加 1。可能有人不是很理解這里的 "相同分區發生合并" 到底是什么意思,我們下面就來介紹。

分區目錄的合并過程

MergeTree 的分區目錄和其它傳統意義上數據庫有所不同,首先 MergeTree 的分區目錄并不是在數據表被創建之后就存在的,而是在數據寫入的過程中被創建的,如果一張表中沒有任何數據,那么也就不會有任何的分區目錄。也很好理解,因為分區目錄的命名與分區 ID 有關,而分區 ID 又和分區鍵對應的值有關,而表中連數據都沒有,那么何來分區目錄呢。

其次,MergeTree 的分區目錄也不是一成不變的,在其它數據庫的設計中,追加數據的時候目錄自身不會改變,只是在相同分區中追加數據文件。而 MergeTree 完全不同,伴隨著每一次數據的寫入,MergeTree 都會生成一批新的分區目錄,即使不同批次寫入的數據屬于相同的分區,也會生成不同的分區目錄。也就是說對于同一個分區而言,會存在對應多個分區目錄的情況。而在之后的某個時刻(一般 10 到 15 分鐘),ClickHouse 會通過后臺任務將屬于相同分區的多個目錄合并(Merge)成一個新的目錄,當然也可以通過 optimize TABLE table_name FINAL 語句立即合并,至于合并之前的舊目錄會在之后的某個時刻(默認 8 分鐘)被刪除。

屬于同一個分區的多個目錄,在合并之后會生成一個全新的目錄,目錄中的索引和數據文件也會相應地進行合并。而新目錄的名稱的生成方式遵循如下規則:

1.PartitionID:不變
2.MinBlockNum:取同一分區內所有目錄中最小的 MinBlockNum
3.MaxBlockNum:取同一分區內所有目錄中最大的 MaxBlockNum
4.Level:取同一分區內最大 Level 值并加 1

這里有一點需要明確,在 ClickHouse中,數據分區(partition)和數據分片(shard)是完全不同的概念。數據分區是針對本地數據而言的,是對數據的一種縱向切分。MergeTree并不能依靠分區的特性,將一張表的數據分布到多個ClickHouse服務節點。而橫向切分是數據分片(shard)的能力。

索引

一級索引

primary.idx:一級索引文件,使用二進制格式存儲,用于存儲稀疏索引,一張 MergeTree 表只能聲明一次一級索引(通過 ORDER BY 或 PRIMARY KEY)。借助稀疏索引,在查詢數據時能夠排除主鍵條件范圍之外的數據文件,從而有效減少數據掃描范圍,加速查詢速度。

一級索引底層采用了稀疏索引來實現,從下圖我們可以看出它和稠密索引的區別。

稀疏索引與稠密索引的對比圖

對于稠密索引而言,每一行索引標記都會對應到具體的一行記錄上。而在稀疏索引中,每一行索引標記對應的一大段數據,而不是具體的一行(他們之間的區別就有點類似mysql中innodb的聚集索引與非聚集索引)。

稀疏索引的優勢是顯而易見的,它只需要使用少量的索引標記就能夠記錄大量數據的區間位置信息,并且數據量越大優勢愈發明顯。例如我們使用默認的索引粒度(8192)時,MergeTree只需要12208行索引標記就能為1億行數據記錄提供索引。由于稀疏索引占用空間小,所以primary.idx內的索引數據能夠常駐內存,取用速度自然極快。

索引粒度 index_granularity

索引粒度就如同標尺一般,會丈量整個數據的長度,并依照刻度對數據進行標注,最終將數據標記成多個間隔的小段。數據以index_granularity的粒度(老版本默認8192,新版本實現了自適應粒度)被標記成多個小的區間,其中每個區間最多8192行數據,MergeTree使用MarkRange表示一個具體的區間,并通過start和end表示其具體的范圍。如下圖所示。

索引粒度是建表的時候,在 SETTINGS 里面指定 index_granularity 控制的,雖然 ClickHouse 提供了自適應粒度大小的特性,但是為了便于理解,我們會使用固定的索引粒度進行介紹(8192)。索引粒度對于 MergeTree 而言是一個非常重要的概念,它就如同一把標尺,會丈量整個數據的長度,并依照刻度對數據進行標注,最終將數據標記成多個間隔的小段。

數據以 index_granularity 的粒度(默認 8192)被標記成多個小的區間,其中每個區間最多 8192 行數據,MergeTree 使用 MarkRange 表示一個具體的區間,并通過 start 和 end 表示其具體的范圍。index_granularity 的名字雖然取了索引二字,但它不單單只作用于一級索引,同時還會影響數據標記文件(data.mrk)和數據文件(data.bin)。因為只有一級索引是無法完成查詢工作的,它需要借助標記文件中的偏移量才能定位數據,所以一級索引和數據標記的間隔粒度(同為 index_granularity 行)相同,彼此對齊,而數據文件也會按照 index_granularity 的間隔粒度生成壓縮數據塊。

二級索引

skp_idx_[IndexName].idx 和 skp_idx_[IndexName].mrk3:如果在建表語句中指定了二級索引,則會額外生成相應的二級索引文件與標記文件,它們同樣使用二進制存儲。二級索引在 ClickHouse 中又被稱為跳數索引,目前擁有 minmax、set、ngrambf_v1 和 token_v1 四種類型,這些種類的跳數索引的目的和一級索引都相同,都是為了進一步減少數據的掃描范圍,從而加速整個查詢過程。

標記

如果把MergeTree比作一本書,primary.idx 一級索引好比這本書的一級章節目錄,.bin文件中的數據好比這本書中的文字,那么數據標記(.mrk) 會為一級章節目錄和具體的文字之間建立關聯 ( 書簽 )。對于數據標記而言,它記錄了兩點重要信息:

其一,是一級章節對應的頁碼信息;

其二,是一段文字在某一頁中的起始位置信息。

這樣一來,通過數據標記就能夠很快地從一本書中立即翻到關注內容所在的那一頁,并知道從第幾行開始閱讀。

data.mrk:標記文件

使用二進制格式存儲,標記文件中保存了 data.bin 文件中數據的偏移量信息,并且標記文件與稀疏索引對齊,因此 MergeTree 通過標記文件建立了稀疏索引(primary.idx)與數據文件(data.bin)之間的映射關系。而在讀取數據的時候,首先會通過稀疏索引(primary.idx)找到對應數據的偏移量信息(data.mrk),因為兩者是對齊的,然后再根據偏移量信息直接從 data.bin 文件中讀取數據。

data.mrk3:如果使用了自適應大小的索引間隔,則標記文件會以 data.mrk3 結尾,但它的工作原理和 data.mrk 文件是相同的。

數據標記作為銜接一級索引和數據的橋梁,像極了書簽,而且書本總每一個章節目錄都有各自的書簽。

從圖中我們可以看到,數據標記和索引區間是對齊的,均按照 index_granularity 的粒度間隔,如此一來只需要簡單通過索引下標編號即可直接找到對應的數據標記。并且為了能夠與數據銜接,.bin 文件和數據標記文件是一一對應的,即每一個 [Column].bin 文件都有一個 [Column].mrk 數據標記文件與之對應,用于記錄數據在 .bin 文件中的偏移量信息。

一行標記數據使用一個元組表示,元組內包含兩個整型數值的偏移量信息,分別表示在此段數據區間內:

  • 1\. 對應 .bin 壓縮文件中,壓縮數據塊的起始偏移量
  • 2\. 將該數據塊解壓縮后,未壓縮數據的起始偏移量

一行標記數據使用一個元組表示,元組內包含兩個整型數值的偏移量信息。它們分別表示在此段數據區間內,在對應的.bin壓縮文件中,壓縮數據塊的起始偏移量;以及將該數據壓縮塊解壓后,其未壓縮數據的起始偏移量。圖所示是.mrk文件內標記數據的示意。

每一行標記數據都表示了一個片段的數據(默認8192行)在.bin壓縮文件中的讀取位置信息。標記數據與一級索引數據不同,它并不能常駐內存,而是使用LRU(最近最少使用)緩存策略加快其取用速度。

壓縮數據

數據量比較少,每一列數據的大小不是很大,因此每一列只用一個壓縮數據塊即可存儲。如果數據量再多一些,一個壓縮數據塊存儲不下,那么就會對應多個壓縮數據塊。

Column1 壓縮數據塊0
Column2 壓縮數據塊0
Column3 壓縮數據塊0
......
ColumnN 壓縮數據塊0

Column1 壓縮數據塊1
Column2 壓縮數據塊1
Column3 壓縮數據塊1
......
ColumnN 壓縮數據塊1

Column1 壓縮數據塊2
Column2 壓縮數據塊2
Column3 壓縮數據塊2
......
ColumnN 壓縮數據塊2

Column1 壓縮數據塊3
Column2 壓縮數據塊3
Column3 壓縮數據塊3
......

壓縮數據塊

一個壓縮數據塊由頭信息和壓縮數據兩部分組成。頭信息固定使用9位字節表示,具體由1個UInt8(1字節)整型和2個UInt32(4字節)整型組成,分別代表使用的壓縮算法類型、壓縮后的數據大小和壓縮前的數據大小。

從圖所示中能夠看到,.bin壓縮文件是由多個壓縮數據塊組成的,而每個壓縮數據塊的頭信息則是基于CompressionMethod_CompressedSize_UncompressedSize公式生成的。通過ClickHouse提供的clickhouse-compressor工具,能夠查詢某個.bin文件中壓縮數據的統計信息。

一個 .bin 文件是由1至多個壓縮數據塊組成的,每個壓縮塊大小在64KB~1MB之間。多個壓縮數據塊之間,按照寫入順序首尾相接,緊密地排列在一起。

在 .bin 文件中引入壓縮數據塊的目的至少有以下兩個:

其一,雖然數據被壓縮后能夠有效減少數據大小,降低存儲空間并加速數據傳輸效率,但數據的壓縮和解壓動作,其本身也會帶來額外的性能損耗。所以需要控制被壓縮數據的大小,以求在性能損耗和壓縮率之間尋求一種平衡。

其二,在具體讀取某一列數據時(.bin文件),首先需要將壓縮數據加載到內存并解壓,這樣才能進行后續的數據處理。通過壓縮數據塊,可以在不讀取整個.bin文件的情況下將讀取粒度降低到壓縮數據塊級別,從而進一步縮小數據讀取的范圍。

分區索引 minmax_[Column].idx

partition.dat 和 minmax_[Column].idx:如果使用了分區鍵,例如上面的 PARTITION BY toYYYYMM(JoinTime),則會額外生成 partition.dat 與 minmax_JoinTime.idx 索引文件,它們均使用二進制格式存儲。

partition.dat 用于保存當前分區下分區表達式最終生成的值,而 minmax_[Column].idx 則負責記錄當前分區下分區字段對應原始數據的最小值和最大值。

數據Partitioning

ClickHouse支持PARTITION BY子句,在建表時可以指定按照任意合法表達式進行數據分區操作,比如通過toYYYYMM()將數據按月進行分區、toMonday()將數據按照周幾進行分區、對Enum類型的列直接每種取值作為一個分區等。

數據Partition在ClickHouse中主要有兩方面應用:

  • 在partition key上進行分區裁剪,只查詢必要的數據。靈活的partition expression設置,使得可以根據SQL Pattern進行分區設置,最大化的貼合業務特點。
  • 對partition進行TTL管理,淘汰過期的分區數據。

數據TTL

在分析場景中,數據的價值隨著時間流逝而不斷降低,多數業務出于成本考慮只會保留最近幾個月的數據,ClickHouse通過TTL提供了數據生命周期管理的能力。

ClickHouse支持幾種不同粒度的TTL:

1) 列級別TTL:當一列中的部分數據過期后,會被替換成默認值;當全列數據都過期后,會刪除該列。

2)行級別TTL:當某一行過期后,會直接刪除該行。

3)分區級別TTL:當分區過期后,會直接刪除該分區。

數據寫入過程

分區目錄、索引、標記和壓縮數據的生成過程示意圖如下:

生成分區目錄

數據寫入的第一步是生成分區目錄,伴隨著每一批數據的寫入,都會生成一個新的分區目錄。在后續的某一時刻,屬于相同分區的目錄會依照規則合并到一起。

生成索引

按照index_granularity索引粒度,會分別生成primary.idx主鍵索引(如果聲明了二級索引,還會創建二級索引文件)。

生成標記和數據壓縮文件

按照index_granularity索引粒度,分別生成每一個列字段的.mrk數據標記和.bin壓縮數據文件。

ClickHouse 數據查詢流程

數據查詢概述

數據查詢的本質,可以看作一個不斷減小數據范圍的過程。在最理想的情況下,MergeTree首先可以依次借助分區索引、一級索引和二級索引,將數據掃描范圍縮至最小。然后再借助數據標記,將需要解壓與計算的數據范圍縮至最小。

如果一條查詢語句沒有指定任何WHERE條件,或是指定了WHERE條件,但條件沒有匹配到任何索引(分區索引、一級索引和二級索引),那么MergeTree就不能預先減小數據范圍。

在后續進行數據查詢時,它會掃描所有分區目錄,以及目錄內索引段的最大區間。雖然不能減少數據范圍,但是MergeTree仍然能夠借助數據標記,以多線程的形式同時讀取多個壓縮數據塊,以提升性能。

索引的查詢過程

索引查詢其實就是兩個數值區間的交集判斷。

其中,一個區間是由基于主鍵的查詢條件轉換而來的條件區間;而另一個區間是剛才所講述的與MarkRange對應的數值區間。下圖簡要描述了 Id 字段的索引過程。

整個索引的查詢過程可以分為三大步驟

1.生成查詢條件區間:將查詢條件轉換為條件區間。即便是單個值的查詢條件,也會被轉換成區間的形式。

WHERE ID = 'A000' 
= ['A000', 'A000']

WHERE ID > 'A000'
= ('A000', '+inf')

WHERE ID < 'A000' 
= ('-inf', 'A000')

WHERE ID LIKE 'A000%' 
= ['A000', 'A001')

2.遞歸交集判斷:以遞歸的形式,依次對MarkRange的數值區間與條件區間做交集判斷。從最大的區間[A000 , +inf)開始。

(1)如果不存在交集,則直接通過剪枝算法優化此整段MarkRange
(2)如果存在交集,且MarkRange步長大于N,則將這個區間進一步拆分為N個子區間,并重復此規則,(3)繼續做遞歸交集判斷(N由merge_tree_coarse_index_granularity指定,默認值為8), 如果存在交集,且MarkRange不可再分解,則記錄MarkRange并返回.

3.合并MarkRange區間:將最終匹配的MarkRange聚在一起,合并它們的范圍。

總結

分區、索引、標記和壓縮數據的協同工作總結

分區、索引、標記和壓縮數據,就類似于 MergeTree 的一套組合拳,使用恰當的話威力無窮。那么在依次介紹了各自的特點之后,現在將它們聚在一起總結一下。

寫入過程

數據寫入的第一步是生成分區目錄,伴隨著每一批數據的寫入,都會生成一個新的分區目錄。在后續的某一時刻,屬于相同分區的分區目錄會被合并到一起。緊接著按照 index_granularity 索引粒度,會分別生成 primary.idx 一級索引(如果聲明了二級索引,還會創建二級索引文件)、每一個列字段的壓縮數據文件(.bin)和數據標記文件(.mrk),如果數據量不大,則是 data.bin 和 data.mrk 文件。

下面的示意圖展示了 MergeTree 表在寫入數據時,它的分區目錄、索引、標記和壓縮數據的生成。

從分區目錄 202006_1_34_3 能夠得知,該分區數據總共分 34 批寫入,期間發生過 3 次合并。在數據寫入的過程中,依據 index_granularity 的粒度,依次為每個區間的數據生成索引、標記和壓縮數據塊。其中索引和標記區間是對齊的,而標記與壓縮塊則是根據區間大小的不同,會生成多對一、一對一、一對多的關系。

查詢過程

數據查詢的本質可以看做是一個不斷減少數據范圍的過程,在最理想的情況下,MergeTree 首先可以借助分區索引、一級索引和二級索引將數據掃描范圍縮至最小。然后再借助數據標記,將需要解壓與計算的數據范圍縮至最小。以下圖為例,該圖展示了在最優的情況下,經過層層過濾,最終獲取最小數據范圍的過程。

如果一條查詢語句沒有指定任何 WHERE 條件,或者指定了 WHERE 條件、但是沒有匹配到任何的索引(分區索引、一級索引、二級索引),那么 MergeTree 就不能預先減少數據范圍。在后續進行數據查詢時,它會掃描所有分區目錄,以及目錄內索引段的最大區間。不過雖然不能減少數據范圍,但 MergeTree 仍然能夠借助數據標記,以多線程的形式同時讀取多個壓縮數據塊,以提升性能。

數據標記與壓縮數據塊的對應關系

由于壓縮數據塊的劃分,與一個間隔(index_granularity)內的數據大小相關,每個壓縮數據塊的體積都被嚴格控制在 64KB ~ 1MB 之間,而一個間隔(index_granularity)的數據,又只會產生一行數據標記。那么根據一個間隔內數據的實際字節大小,數據標記和壓縮數據塊之間會產生三種不同的對應關系:

1)多對一

多個數據標記對應一個壓縮數據塊,當一個間隔(index_granularity)內數據的未壓縮大小小于 64KB 時,會出現這種對應關系。

2)一對一

一個數據標記對應一個壓縮數據塊,當一個間隔(index_granularity)內數據的未壓縮大小大于等于 64KB 并小于等于 1MB 時,會出現這種對應關系。

3)一對多

一個數據標記對應多個壓縮數據塊,當一個間隔(index_granularity)內數據的未壓縮大小大于 1MB 時,會出現這種對應關系。

以上就是 MergeTree 的工作原理,首先我們了解了 MergeTree 的基礎屬性和物理存儲結構;接著,依次介紹了數據分區、一級索引、二級索引、數據存儲和數據標記的重要特性;最后總結了 MergeTree 上述特性一起協同時工作過程。掌握了 MergeTree 即掌握了合并樹系列表引擎的精髓,因為 MergeTree 本身也是一種表引擎。后面我們會介紹 MergeTree 家族中其它常見表引擎的使用方法,以及它們都有哪些特點、使用方式是什么。

參考資料

https://blog.csdn.net/Night_ZW/article/details/112845684

https://blog.csdn.net/qq_35423154/article/details/117160058

https://www.cnblogs.com/traditional/p/15218743.html

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

推薦閱讀更多精彩內容