ClickHouse入門實踐--MergeTree原理解析

MergeTree原理解析

表引擎是ClickHouse設計實現中的一大特點。能夠說,是表引擎決定了一張數據表最終的「性格」,好比數據表擁有何種特性、數據以何種形式被存儲以及如何被加載。ClickHouse擁有很是龐大的表引擎體系,截至本書完成時,其共擁有合併樹、外部存儲、內存、文件、接口和其餘6大類20多種表引擎。而在這衆多的表引擎中,又屬合併樹(MergeTree)表引擎及其家族系列(*MergeTree)最爲強大,在生產環境的絕大部分場景中,都會使用此係列的表引擎。由於只有合併樹系列的表引擎才支持主鍵索引、數據分區、數據副本和數據採樣這些特性,同時也只有此係列的表引擎支持ALTER相關操做。合併樹家族自身也擁有多種表引擎的變種。其中MergeTree做爲家族中最基礎的表引擎,提供了主鍵索引、數據分區、數據副本和數據採樣等基本能力,而家族中其餘的表引擎則在MergeTree的基礎之上各有所長。例如ReplacingMergeTree表引擎具備刪除重複數據的特性,而SummingMergeTree表引擎則會按照排序鍵自動聚合數據。若是給合併樹系列的表引擎加上Replicated前綴,又會獲得一組支持數據副本的表引擎,例如ReplicatedMergeTree、ReplicatedReplacingMergeTree、ReplicatedSummingMergeTree等。合併樹表引擎家族如圖所示:
image.png算法

雖然合併樹的變種不少,但MergeTree表引擎纔是根基。做爲合併樹家族系列中最基礎的表引擎,MergeTree具有了該系列其餘表引擎共有的基本特徵,因此吃透了MergeTree表引擎的原理,就可以掌握該系列引擎的精髓。數據庫

MergeTree的建立方式與存儲結構

MergeTree在寫入一批數據時,數據總會以數據片斷的形式寫入磁盤,且數據片斷不可修改。爲了不片斷過多,ClickHouse會經過後臺線程,按期合併這些數據片斷,屬於相同分區的數據片斷會被合成一個新的片斷。這種數據片斷往復合併的特色,也正是合併樹名稱的由來。緩存

MergeTree的建立方式

建立MergeTree數據表的方法,與咱們第4章介紹的定義數據表的方法大體相同,但須要將ENGINE參數聲明爲MergeTree(),其完整的語法以下所示:
image.png
MergeTree表引擎除了常規參數以外,還擁有一些獨有的配置選項。接下來會着重介紹其中幾個重要的參數,包括它們的使用方法和工做原理。可是在此以前,仍是先介紹一遍它們的做用。數據結構

(1)PARTITION BY [選填]:分區鍵,用於指定表數據以何種標準進行分區。分區鍵既能夠是單個列字段,也能夠經過元組的形式使用多個列字段,同時它也支持使用列表達式。若是不聲明分區鍵,則ClickHouse會生成一個名爲all的分區。合理使用數據分區,能夠有效減小查詢時數據文件的掃描範圍。多線程

(2)ORDER BY [必填]:排序鍵,用於指定在一個數據片斷內,數據以何種標準排序。默認狀況下主鍵(PRIMARY KEY)與排序鍵相同。排序鍵既能夠是單個列字段,例如ORDER BY CounterID,也能夠經過元組的形式使用多個列字段,例如ORDER BY(CounterID, EventDate)。當使用多個列字段排序時,以ORDERBY(CounterID, EventDate)爲例,在單個數據片斷內,數據首先會以CounterID排序,相同CounterID的數據再按EventDate排序。dom

(3)PRIMARY KEY [選填]:主鍵,顧名思義,聲明後會依照主鍵字段生成一級索引,用於加速表查詢。默認狀況下,主鍵與排序鍵(ORDER BY)相同,因此一般直接使用ORDER BY代爲指定主鍵,無須刻意經過PRIMARY KEY聲明。因此在通常狀況下,在單個數據片斷內,數據與一級索引以相同的規則升序排列。與其餘數據庫不一樣,MergeTree主鍵容許存在重複數據(ReplacingMergeTree能夠去重)。函數

(4)SAMPLE BY [選填]:抽樣表達式,用於聲明數據以何種標準進行採樣。若是使用了此配置項,那麼在主鍵的配置中也須要聲明一樣的表達式,例如:
image.png
抽樣表達式須要配合SAMPLE子查詢使用,這項功能對於選取抽樣數據十分有用。工具

(5)SETTINGS: index_granularity [選填]:index_granularity對於MergeTree而言是一項很是重要的參數,它表示索引的粒度,默認值爲8192。也就是說,MergeTree的索引在默認狀況下,每間隔8192行數據才生成一條索引,其具體聲明方式以下所示:
image.png
8192是一個神奇的數字,在ClickHouse中大量數值參數都有它的影子,能夠被其整除(例如最小壓縮塊大小min_compress_block_size:65536)。一般狀況下並不須要修改此參數,但理解它的工做原理有助於咱們更好地使用MergeTree。關於索引詳細的工做原理會在後續闡述。性能

(6)SETTINGS: index_granularity_bytes [選填]:在19.11版本以前,ClickHouse只支持固定大小的索引間隔,由index_granularity控制,默認爲8192。在新版本中,它增長了自適應間隔大小的特性,即根據每一批次寫入數據的體量大小,動態劃分間隔大小。而數據的體量大小,正是由index_granularity_bytes參數控制的,默認爲10M(10×1024×1024),設置爲0表示不啓動自適應功能。測試

(7)SETTINGS: enable_mixed_granularity_parts [選填]:設置是否開啓自適應索引間隔的功能,默認開啓。

(8)SETTINGS: merge_with_ttl_timeout [選填]:從19.6版本開始,MergeTree提供了數據TTL的功能。

(9)SETTINGS: storage_policy [選填]:從19.15版本開始,MergeTree提供了多路徑的存儲策略。

MergeTree的存儲結構

MergeTree表引擎中的數據是擁有物理存儲的,數據會按照分區目錄的形式保存到磁盤之上,其完整的存儲結構如圖所示:
image.png
image.png

從圖中能夠看出,一張數據表的完整物理結構分爲3個層級,依次是數據表目錄、分區目錄及各分區下具體的數據文件。接下來就逐一介紹它們的做用。
(1)partition:分區目錄,餘下各種數據文件(primary.idx、[Column].mrk、[Column]. bin等)都是以分區目錄的形式被組織存放的,屬於相同分區的數據,最終會被合併到同一個分區目錄,而不一樣分區的數據,永遠不會被合併在一塊兒。更多關於數據分區的細節會在6.2節闡述。
(2)checksums.txt:校驗文件,使用二進制格式存儲。它保存了餘下各種文件(primary. idx、count.txt等)的size大小及size的哈希值,用於快速校驗文件的完整性和正確性。
(3)columns.txt:列信息文件,使用明文格式存儲。用於保存此數據分區下的列字段信息,例如:
image.png
(4)count.txt:計數文件,使用明文格式存儲。用於記錄當前數據分區目錄下數據的總行數:
(5)primary.idx:一級索引文件,使用二進制格式存儲。用於存放稀疏索引,一張MergeTree表只能聲明一次一級索引(經過ORDER BY或者PRIMARY KEY)。藉助稀疏索引,在數據查詢的時可以排除主鍵條件範圍以外的數據文件,從而有效減小數據掃描範圍,加速查詢速度。
(6)[Column].bin:數據文件,使用壓縮格式存儲,默認爲LZ4壓縮格式,用於存儲某一列的數據。因爲MergeTree採用列式存儲,因此每個列字段都擁有獨立的.bin數據文件,並以列字段名稱命名(例如CounterID.bin、EventDate.bin等)。
(7)[Column].mrk:列字段標記文件,使用二進制格式存儲。標記文件中保存了.bin文件中數據的偏移量信息。標記文件與稀疏索引對齊,又與.bin文件一一對應,因此MergeTree經過標記文件創建了primary.idx稀疏索引與.bin數據文件之間的映射關係。即首先經過稀疏索引(primary.idx)找到對應數據的偏移量信息(.mrk),再經過偏移量直接從.bin文件中讀取數據。因爲.mrk標記文件與.bin文件一一對應,因此MergeTree中的每一個列字段都會擁有與其對應的.mrk標記文件(例如CounterID.mrk、EventDate.mrk等)。
(8)[Column].mrk2:若是使用了自適應大小的索引間隔,則標記文件會以.mrk2命名。它的工做原理和做用與.mrk標記文件相同。
(9)partition.dat與minmax_[Column].idx:若是使用了分區鍵,例如PARTITION BY EventTime,則會額外生成partition.dat與minmax索引文件,它們均使用二進制格式存儲。partition.dat用於保存當前分區下分區表達式最終生成的值;而minmax索引用於記錄當前分區下分區字段對應原始數據的最小和最大值。例如EventTime字段對應的原始數據爲2019-05-0一、2019-05-05,分區表達式爲PARTITION BY toYYYYMM(EventTime)。partition.dat中保存的值將會是2019-05,而minmax索引中保存的值將會是2019-05-012019-05-05。
在這些分區索引的做用下,進行數據查詢時可以快速跳過沒必要要的數據分區目錄,從而減小最終須要掃描的數據範圍。

(10)skp_idx_[Column].idx與skp_idx_[Column].mrk:若是在建表語句中聲明瞭二級索引,則會額外生成相應的二級索引與標記文件,它們一樣也使用二進制存儲。二級索引在ClickHouse中又稱跳數索引,目前擁有minmax、set、ngrambf_v1和tokenbf_v1四種類型。這些索引的最終目標與一級稀疏索引相同,都是爲了進一步減小所需掃描的數據範圍,以加速整個查詢過程。

數據分區

經過先前的介紹已經知曉在MergeTree中,數據是以分區目錄的形式進行組織的,每一個分區獨立分開存儲。藉助這種形式,在對MergeTree進行數據查詢時,能夠有效跳過無用的數據文件,只使用最小的分區目錄子集。這裏有一點須要明確,在ClickHouse中,數據分區(partition)和數據分片(shard)是徹底不一樣的概念。數據分區是針對本地數據而言的,是對數據的一種縱向切分。MergeTree並不能依靠分區的特性,將一張表的數據分佈到多個ClickHouse服務節點。而橫向切分是數據分片(shard)的能力。

數據的分區規則

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落入相應的數據分區,下表列舉了分區ID在不一樣規則下的一些示例。
image.png
若是經過元組的方式使用多個分區字段,則分區ID依舊是根據上述規則生成的,只是多個ID之間經過「-」符號依次拼接。例如按照上述表格中的例子,使用兩個字段分區:
image.png
則最終的分區ID會是下面的模樣:
image.png

分區目錄的命名規則

咱們已經知道了分區ID的生成規則。可是若是進入數據表所在的磁盤目錄後,會發現MergeTree分區目錄的完整物理名稱並非只有ID而已,在ID以後還跟着一串奇怪的數字,例如201905_1_1_0。那麼這些數字又表明着什麼呢?

衆所周知,對於MergeTree而言,它最核心的特色是其分區目錄的合併動做。可是咱們可曾想過,從分區目錄的命名中便可以解讀出它的合併邏輯。在這一小節,咱們會着重對命名公式中各分項進行解讀,而關於具體的目錄合併過程將會留在後面小節講解。一個完整分區目錄的命名公式以下所示:
image.png
若是對照着示例數據,那麼數據與公式的對照關係會如同下圖所示通常。
image.png
上圖中,201905表示分區目錄的ID;1_1分別表示最小的數據塊編號與最大的數據塊編號;而最後的_0則表示目前合併的層級。接下來開始分別解釋它們的含義:
(1)PartitionID:分區ID,無須多說,關於分區ID的規則在上一小節中已經作過詳細闡述了。
(2)MinBlockNum和MaxBlockNum:顧名思義,最小數據塊編號與最大數據塊編號。ClickHouse在這裏的命名彷佛有些歧義,很容易讓人與稍後會介紹到的數據壓縮塊混淆。可是本質上它們毫無關係,這裏的BlockNum是一個整型的自增加編號。若是將其設爲n的話,那麼計數n在單張MergeTree數據表內全局累加,n從1開始,每當新建立一個分區目錄時,計數n就會累積加1。對於一個新的分區目錄而言,MinBlockNum與MaxBlockNum取值同樣,同等於n,例如201905_1_1_0、201906_2_2_0以此類推。可是也有例外,當分區目錄發生合併時,對於新產生的合併目錄MinBlockNum與MaxBlockNum有着另外的取值規則。對於合併規則,咱們留到下一小節再詳細講解。
(3)Level:合併的層級,能夠理解爲某個分區被合併過的次數,或者這個分區的年齡。數值越高表示年齡越大。Level計數與BlockNum有所不一樣,它並非全局累加的。對於每個新建立的分區目錄而言,其初始值均爲0。以後,以分區爲單位,若是相同分區發生合併動做,則在相應分區內計數累積加1。

分區目錄的合併過程

MergeTree的分區目錄和傳統意義上其餘數據庫有所不一樣。首先,MergeTree的分區目錄並非在數據表被建立以後就存在的,而是在數據寫入過程當中被建立的。也就是說若是一張數據表沒有任何數據,那麼也不會有任何分區目錄存在。其次,它的分區目錄在創建以後也並非一成不變的。在其餘某些數據庫的設計中,追加數據後目錄自身不會發生變化,只是在相同分區目錄中追加新的數據文件。而MergeTree徹底不一樣,伴隨着每一批數據的寫入(一次INSERT語句),MergeTree都會生成一批新的分區目錄。即使不一樣批次寫入的數據屬於相同分區,也會生成不一樣的分區目錄。也就是說,對於同一個分區而言,也會存在多個分區目錄的狀況。在以後的某個時刻(寫入後的10~15分鐘,也能夠手動執行optimize查詢語句), ClickHouse會經過後臺任務再將屬於相同分區的多個目錄合併成一個新的目錄。已經存在的舊分區目錄並不會當即被刪除,而是在以後的某個時刻經過後臺任務被刪除(默認8分鐘)。

屬於同一個分區的多個目錄,在合併以後會生成一個全新的目錄,目錄中的索引和數據文件也會相應地進行合併。新目錄名稱的合併方式遵循如下規則,其中:
❑ MinBlockNum:取同一分區內全部目錄中最小的MinBlockNum值。
❑ MaxBlockNum:取同一分區內全部目錄中最大的MaxBlockNum值。
❑ Level:取同一分區內最大Level值並加1。

合併目錄名稱的變化過程如圖所示:
image.png

partition_v5測試表按日期字段格式分區,即PARTITION BYtoYYYYMM(EventTime), T表示時間。假設在T0時刻,首先分3批(3次INSERT語句)寫入3條數據人:

INSERT INTO partition_v5 values
('A','c1','2019-05-01'),
('B','c2','2019-05-02'),
('C','c1','2019-06-01');

按照目錄規,上述代碼會建立3個分區目錄。分區目錄的名稱由PartitionID、MinBlockNum、MaxBlockNum和Level組成,其中PartitionID根據6.2.1節介紹的生成規則,3個分區目錄的ID依次爲20190五、201905和201906。而對於每一個新建的分區目錄而言,它們的MinBlockNum與MaxBlockNum取值相同,均來源於表內全局自增的BlockNum。BlockNum初始爲1,每次新建目錄後累計加1。因此,3個分區目錄的MinBlockNum與MaxBlockNum依次爲0_0、1_1和2_2。最後是Level層級,每一個新建的分區目錄初始Level都是0。因此3個分區目錄的最終名稱分別是201905_1_1_0、201905_2_2_0和201906_3_3_0。

假設在T1時刻,MergeTree的合併動做開始了,那麼屬於同一分區的201905_1_1_0與201905_2_2_0目錄將發生合併。從圖6-4所示過程當中能夠發現,合併動做完成後,生成了一個新的分區201905_1_2_1。根據本節所述的合併規則,其中,MinBlockNum取同一分區內全部目錄中最小的MinBlockNum值,因此是1; MaxBlockNum取同一分區內全部目錄中最大的MaxBlockNum值,因此是2;而Level則取同一分區內,最大Level值加1,因此是1。然後續T2時刻的合併規則,只是在重複剛纔所述的過程而已。

至此,你們已經知道了分區ID、目錄命名和目錄合併的相關規則。最後,再用一張完整的示例圖做爲總結,描述MergeTree分區目錄從建立、合併到刪除的整個過程:
image.png

分區目錄在發生合併以後,舊的分區目錄並無被當即刪除,而是會存留一段時間。可是舊的分區目錄已再也不是激活狀態(active=0),因此在數據查詢時,它們會被自動過濾掉。

一級索引

MergeTree的主鍵使用PRIMARY KEY定義,待主鍵定義以後,MergeTree會依據index_granularity間隔(默認8192行),爲數據表生成一級索引並保存至primary.idx文件內,索引數據按照PRIMARY KEY排序。相比使用PRIMARY KEY定義,更爲常見的簡化形式是經過ORDER BY指代主鍵。在此種情形下,PRIMARY KEY與ORDER BY定義相同,因此索引(primary.idx)和數據(.bin)會按照徹底相同的規則排序。

稀疏索引

primary.idx文件內的一級索引採用稀疏索引實現。此時有人可能會問,既然提到了稀疏索引,那麼是否是也有稠密索引呢?還真有!稀疏索引和稠密索引的區別如圖所示。
image.png
簡單來講,在稠密索引中每一行索引標記都會對應到一行具體的數據記錄。而在稀疏索引中,每一行索引標記對應的是一段數據,而不是一行。用一個形象的例子來講明:若是把MergeTree比做一本書,那麼稀疏索引就比如是這本書的一級章節目錄。一級章節目錄不會具體對應到每一個字的位置,只會記錄每一個章節的起始頁碼。

稀疏索引的優點是顯而易見的,它僅需使用少許的索引標記就可以記錄大量數據的區間位置信息,且數據量越大優點越爲明顯。以默認的索引粒度(8192)爲例,MergeTree只須要12208行索引標記就能爲1億行數據記錄提供索引。因爲稀疏索引佔用空間小,因此primary.idx內的索引數據常駐內存,取用速度天然極快。

索引粒度

在先前的篇幅中已經數次出現過index_granularity這個參數了,它表示索引的粒度。雖然在新版本中,ClickHouse提供了自適應粒度大小的特性,可是爲了便於理解,仍然會使用固定的索引粒度(默認8192)進行講解。索引粒度對MergeTree而言是一個很是重要的概念,所以頗有必要對它作一番深刻解讀。索引粒度就如同標尺通常,會丈量整個數據的長度,並依照刻度對數據進行標註,最終將數據標記成多個間隔的小段:
image.png

數據以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 BYtoYYYYMM(EventDate)),因此2014年3月份的數據最終會被劃分到同一個分區目錄內。若是使用CounterID做爲主鍵(ORDER BY CounterID),則每間隔8192行數據就會取一次CounterID的值做爲索引值,索引數據最終會被寫入primary.idx文件進行保存。
image.png

索引數據的生成規則

因爲是稀疏索引,因此MergeTree須要間隔index_granularity行數據纔會生成一條索引記錄,其索引值會依據聲明的主鍵字段獲取。圖6-8所示是對照測試表hits_v1中的真實數據具象化後的效果。hits_v1使用年月分區(PARTITION BYtoYYYYMM(EventDate)),因此2014年3月份的數據最終會被劃分到同一個分區目錄內。若是使用CounterID做爲主鍵(ORDER BY CounterID),則每間隔8192行數據就會取一次CounterID的值做爲索引值,索引數據最終會被寫入primary.idx文件進行保存。
image.png
例如第0(81920)行CounterID取值57,第8192(81921)行CounterID取值1635,而第16384(8192*2)行CounterID取值3266,最終索引數據將會是5716353266。

從圖中也可以看出,MergeTree對於稀疏索引的存儲是很是緊湊的,索引值先後相連,按照主鍵字段順序緊密地排列在一塊兒。不只此處,ClickHouse中不少數據結構都被設計得很是緊湊,好比其使用位讀取替代專門的標誌位或狀態碼,能夠不浪費哪怕一個字節的空間。以小見大,這也是ClickHouse爲什麼性能如此出衆的深層緣由之一。

若是使用多個主鍵,例如ORDER BY (CounterID, EventDate),則每間隔8192行能夠同時取CounterID與EventDate兩列的值做爲索引值,具體如圖所示。
image.png

索引的查詢過程

在介紹了上述關於索引的一些概念以後,接下來講明索引具體是如何工做的。首先,咱們須要瞭解什麼是MarkRange。MarkRange在ClickHouse中是用於定義標記區間的對象。經過先前的介紹已知,MergeTree按照index_granularity的間隔粒度,將一段完整的數據劃分紅了多個小的間隔數據段,一個具體的數據段便是一個MarkRange。MarkRange與索引編號對應,使用start和end兩個屬性表示其區間範圍。經過與start及end對應的索引編號的取值,即可以獲得它所對應的數值區間。而數值區間表示了此MarkRange包含的數據範圍。

若是隻是這麼幹巴巴地介紹,你們可能會以爲比較抽象,下面用一份示例數據來進一步說明。假如如今有一份測試數據,共192行記錄。其中,主鍵ID爲String類型,ID的取值從A000開始,後面依次爲A00一、A002……直至A192爲止。MergeTree的索引粒度index_granularity = 3,根據索引的生成規則,primary.idx文件內的索引數據會如圖所示。
image.png
根據索引數據,MergeTree會將此數據片斷劃分紅192/3=64個小的MarkRange,兩個相鄰MarkRange相距的步長爲1。其中,全部MarkRange(整個數據片斷)的最大數值區間爲[A000 , +inf),其完整的示意如圖所示。
image.png

在引出了數值區間的概念以後,對於索引的查詢過程就很好解釋了。索引查詢其實就是兩個數值區間的交集判斷。其中,一個區間是由基於主鍵的查詢條件轉換而來的條件區間;而另外一個區間是剛纔所講述的與MarkRange對應的數值區間。整個索引查詢過程能夠大體分爲3個步驟。

(1)生成查詢條件區間:首先,將查詢條件轉換爲條件區間。即使是單個值的查詢條件,也會被轉換成區間的形式,例以下面的例子。
image.png

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

❑ 若是不存在交集,則直接經過剪枝算法優化此整段MarkRange。

❑ 若是存在交集,且MarkRange步長大於8(end - start),則將此區間進一步拆分紅8個子區間(由merge_tree_coarse_index_granularity指定,默認值爲8),並重復此規則,繼續作遞歸交集判斷。

❑ 若是存在交集,且MarkRange不可再分解(步長小於8),則記錄MarkRange並返回。

(3)合併MarkRange區間:將最終匹配的MarkRange聚在一塊兒,合併它們的範圍。
image.png
MergeTree經過遞歸的形式持續向下拆分區間,最終將MarkRange定位到最細的粒度,以幫助在後續讀取數據的時候,可以最小化掃描數據的範圍。以圖6-12所示爲例,當查詢條件WHERE ID = 'A003’的時候,最終只須要讀取[A000 , A003]和[A003 , A006]兩個區間的數據,它們對應MarkRange(start:0, end:2)範圍,而其餘無用的區間都被裁剪掉了。由於MarkRange轉換的數值區間是閉區間,因此會額外匹配到臨近的一個區間。

二級索引

除了一級索引以外,MergeTree一樣支持二級索引。二級索引又稱跳數索引,由數據的聚合信息構建而成。根據索引類型的不一樣,其聚合信息的內容也不一樣。跳數索引的目的與一級索引同樣,也是幫助查詢時減小數據掃描的範圍。跳數索引在默認狀況下是關閉的,須要設置allow_experimental_data_skipping_indices(該參數在新版本中已被取消)才能使用:

SET allow_experimental_data_skipping_indices=1;

跳數索引須要在CREATE語句內定義,它支持使用元組和表達式的形式聲明,其完整的定義語法以下所示:
image.png
與一級索引同樣,若是在建表語句中聲明瞭跳數索引,則會額外生成相應的索引與標記文件(skp_idx_[Column].idx與skp_idx_[Column].mrk)。

granularity與index_granularity的關係

不一樣的跳數索引之間,除了它們自身獨有的參數以外,還都共同擁有granularity參數。初次接觸時,很容易將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]),如圖所示。
image.png

跳數索引的類型

目前,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()
ORDER BY ID ;

接下來,就藉助上面的例子逐個介紹這幾種跳數索引的用法:

(1)minmax:minmax索引記錄了一段數據內的最小和最大極值,其索引的做用相似分區目錄的minmax索引,可以快速跳過無用的數據區間,示例以下所示:
INDEX a ID TYPE minmax GRANULARITY 5
上述示例中minmax索引會記錄這段數據區間內ID字段的極值。極值的計算涉及每5個index_granularity區間中的數據。

(2)set:set索引直接記錄了聲明字段或表達式的取值(惟一值,無重複),其完整形式爲set(max_rows),其中max_rows是一個閾值,表示在一個index_granularity內,索引最多記錄的數據行數。若是max_rows=0,則表示無限制,例如:
INDEX b (length(ID) * 8) TYPE set(2) GRANULARITY 5
上述示例中set索引會記錄數據中ID的長度 * 8後的取值。其中,每一個index_granularity內最多記錄100條。

(3)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函數的隨機種子。

例如在下面的例子中,ngrambf_v1索引會依照3的粒度將數據切割成短語token,token會通過2個Hash函數映射後再被寫入,布隆過濾器大小爲256字節。
INDEX c (ID,Code) TYPE ngrambf_v1(3,256,2,0) GRANULARITY 5,
(4)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中數據是按列存儲的。可是前面的介紹都較爲抽象,具體到存儲的細節、MergeTree是如何工做的,讀者心中不免會有疑問。數據存儲,就比如一本書中的文字,在排版時,毫不會密密麻麻地把文字堆滿,這樣會致使難以閱讀。更爲優雅的作法是,將文字按段落的形式精心組織,使其錯落有致。本節將進一步介紹MergeTree在數據存儲方面的細節,尤爲是其中關於壓縮數據塊的概念。

各列獨立存儲

在MergeTree中,數據按列存儲。而具體到每一個列字段,數據也是獨立存儲的,每一個列字段都擁有一個與之對應的.bin數據文件。也正是這些.bin文件,最終承載着數據的物理存儲。數據文件以分區目錄的形式被組織存放,因此在.bin文件中只會保存當前分區片斷內的這一部分數據。按列獨立存儲的設計優點顯而易見:一是能夠更好地進行數據壓縮(相同類型的數據放在一塊兒,對壓縮更加友好),二是可以最小化數據掃描的範圍。

而對應到存儲的具體實現方面,MergeTree也並非一股腦地將數據直接寫入.bin文件,而是通過了一番精心設計:首先,數據是通過壓縮的,目前支持LZ四、ZSTD、Multiple和Delta幾種算法,默認使用LZ4算法;其次,數據會事先依照ORDER BY的聲明排序;最後,數據是以壓縮數據塊的形式被組織並寫入.bin文件中的。壓縮數據塊就比如一本書的文字段落,是組織文字的基本單元。這個概念十分重要,值得多花些篇幅進一步展開說明。

壓縮數據塊

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

從圖所示中可以看到,.bin壓縮文件是由多個壓縮數據塊組成的,而每一個壓縮數據塊的頭信息則是基於CompressionMethod_CompressedSize_UncompressedSize公式生成的。經過ClickHouse提供的clickhouse-compressor工具,可以查詢某個.bin文件中壓縮數據的統計信息。以測試數據集hits_v1爲例,執行下面的命令:
image.png
執行後,會看到以下信息:
image.png

其中每一行數據表明着一個壓縮數據塊的頭信息,其分別表示該壓縮塊中未壓縮數據大小和壓縮後數據大小(打印信息與物理存儲的順序恰好相反)。每一個壓縮數據塊的體積,按照其壓縮前的數據字節大小,都被嚴格控制在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大小截斷並生成下一個壓縮數據塊。剩餘數據繼續依照上述規則執行。此時,會出現一個批次數據生成多個壓縮數據塊的狀況。
整個過程邏輯如圖所示。
image.png

通過上述的介紹後咱們知道,一個.bin文件是由1至多個壓縮數據塊組成的,每一個壓縮塊大小在64KB~1MB之間。多個壓縮數據塊之間,按照寫入順序首尾相接,緊密地排列在一塊兒。
image.png

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

其一,雖然數據被壓縮後可以有效減小數據大小,下降存儲空間並加速數據傳輸效率,但數據的壓縮和解壓動做,其自己也會帶來額外的性能損耗。因此須要控制被壓縮數據的大小,以求在性能損耗和壓縮率之間尋求一種平衡。

其二,在具體讀取某一列數據時(.bin文件),首先須要將壓縮數據加載到內存並解壓,這樣才能進行後續的數據處理。經過壓縮數據塊,能夠在不讀取整個.bin文件的狀況下將讀取粒度下降到壓縮數據塊級別,從而進一步縮小數據讀取的範圍。

數據標記

若是把MergeTree比做一本書,primary.idx一級索引比如這本書的一級章節目錄,.bin文件中的數據比如這本書中的文字,那麼數據標記(.mrk)會爲一級章節目錄和具體的文字之間創建關聯。對於數據標記而言,它記錄了兩點重要信息:

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

其二,是一段文字在某一頁中的起始位置信息。這樣一來,經過數據標記就可以很快地從一本書中當即翻到關注內容所在的那一頁,並知道從第幾行開始閱讀。

數據標記的生成規則

數據標記做爲銜接一級索引和數據的橋樑,其像極了作過標記小抄的書籤,並且書本中每一個一級章節都擁有各自的書籤。它們之間的關係如圖所示。
image.png

從圖中一眼就能發現數據標記的首個特徵,即數據標記和索引區間是對齊的,均按照index_granularity的粒度間隔。如此一來,只需簡單經過索引區間的下標編號就能夠直接找到對應的數據標記。

爲了可以與數據銜接,數據標記文件也與.bin文件一一對應。即每個列字段[Column].bin文件都有一個與之對應的[Column].mrk數據標記文件,用於記錄數據在.bin文件中的偏移量信息。

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

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

數據標記的工做方式

MergeTree在讀取數據時,必須經過標記數據的位置信息纔可以找到所須要的數據。整個查找過程大體能夠分爲讀取壓縮數據塊和讀取數據兩個步驟。爲了便於解釋,這裏繼續使用測試表hits_v1中的真實數據進行說明。圖所示爲hits_v1測試表的JavaEnable字段及其標記數據與壓縮數據的對應關係。

首先,對圖所示左側的標記數據作一番解釋說明。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。由於根據規則,此時會生成下一個壓縮數據塊。
image.png
理解了上述標記數據以後,接下來就開始介紹MergeTree具體是如何定位壓縮數據塊並讀取數據的。

(1)讀取壓縮數據塊:在查詢某一列數據時,MergeTree無須一次性加載整個.bin文件,而是能夠根據須要,只加載特定的壓縮數據塊。而這項特性須要藉助標記文件中所保存的壓縮文件中的偏移量。

在圖所示的標記數據中,上下相鄰的兩個壓縮文件中的起始偏移量,構成了與獲取當前標記對應的壓縮數據塊的偏移量區間。由當前標記數據開始,向下尋找,直到找到不一樣的壓縮文件偏移量爲止。此時獲得的一組偏移量區間便是壓縮數據塊在.bin文件中的偏移量。例如在圖所示中,讀取右側.bin文件中[0,12016]字節數據,就能獲取第0個壓縮數據塊。

細心的讀者可能會發現,在.mrk文件中,第0個壓縮數據塊的截止偏移量是12016。而在.bin數據文件中,第0個壓縮數據塊的壓縮大小是12000。爲何兩個數值不一樣呢?其實緣由很簡單,12000只是數據壓縮後的字節數,並無包含頭信息部分。而一個完整的壓縮數據塊是由頭信息加上壓縮數據組成的,它的頭信息固定由9個字節組成,壓縮後大小爲8個字節。因此,12016 = 8 + 12000 + 8,其定位方法如圖右上角所示。壓縮數據塊被整個加載到內存以後,會進行解壓,在這以後就進入具體數據的讀取環節了。

(2)讀取數據:在讀取解壓後的數據時,MergeTree並不須要一次性掃描整段解壓數據,它能夠根據須要,以index_granularity的粒度加載特定的一小段。爲了實現這項特性,須要藉助標記文件中保存的解壓數據塊中的偏移量。

一樣的,在圖所示的標記數據中,上下相鄰兩個解壓縮數據塊中的起始偏移量,構成了與獲取當前標記對應的數據的偏移量區間。經過這個區間,可以在它的壓縮塊被解壓以後,依照偏移量按需讀取數據。例如在圖所示中,經過[0,8192]可以讀取壓縮數據塊0中的第一個數據片斷。

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

分區、索引、標記和壓縮數據,就比如是MergeTree給出的一套組合拳,使用恰當時威力無窮。那麼,在依次介紹了各自的特色以後,如今將它們聚在一塊進行一番總結。接下來,就分別從寫入過程、查詢過程,以及數據標記與壓縮數據塊的三種對應關係的角度展開介紹。

寫入過程

數據寫入的第一步是生成分區目錄,伴隨着每一批數據的寫入,都會生成一個新的分區目錄。在後續的某一時刻,屬於相同分區的目錄會依照規則合併到一塊兒;接着,按照index_granularity索引粒度,會分別生成primary.idx一級索引(若是聲明瞭二級索引,還會建立二級索引文件)、每個列字段的.mrk數據標記和.bin壓縮數據文件。圖所示是一張MergeTree表在寫入數據時,它的分區目錄、索引、標記和壓縮數據的生成過程。
image.png

從分區目錄201403_1_34_3可以得知,該分區數據共分34批寫入,期間發生過3次合併。在數據寫入的過程當中,依據index_granularity的粒度,依次爲每一個區間的數據生成索引、標記和壓縮數據塊。其中,索引和標記區間是對齊的,而標記與壓縮塊則根據區間數據大小的不一樣,會生成多對1、一對一和一對多三種關係。

查詢過程

數據查詢的本質,能夠看做一個不斷減少數據範圍的過程。在最理想的狀況下,MergeTree首先能夠依次藉助分區索引、一級索引和二級索引,將數據掃描範圍縮至最小。而後再借助數據標記,將須要解壓與計算的數據範圍縮至最小。以圖所示爲例,它示意了在最優的狀況下,通過層層過濾,最終獲取最小範圍數據的過程。
image.png

若是一條查詢語句沒有指定任何WHERE條件,或是指定了WHERE條件,但條件沒有匹配到任何索引(分區索引、一級索引和二級索引),那麼MergeTree就不能預先減少數據範圍。在後續進行數據查詢時,它會掃描全部分區目錄,以及目錄內索引段的最大區間。雖然不能減小數據範圍,可是MergeTree仍然可以藉助數據標記,以多線程的形式同時讀取多個壓縮數據塊,以提高性能。

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

因爲壓縮數據塊的劃分,與一個間隔(index_granularity)內的數據大小相關,每一個壓縮數據塊的體積都被嚴格控制在64KB~1MB。而一個間隔(index_granularity)的數據,又只會產生一行數據標記。那麼根據一個間隔內數據的實際字節大小,數據標記和壓縮數據塊之間會產生三種不一樣的對應關係。接下來使用具體示例作進一步說明,對於示例數據,仍然是測試表hits_v1,其中index_granularity粒度爲8192,數據總量爲8873898行。

多對一

多個數據標記對應一個壓縮數據塊,當一個間隔(index_granularity)內的數據未壓縮大小size小於64KB時,會出現這種對應關係。以hits_v1測試表的JavaEnable字段爲例。JavaEnable數據類型爲UInt8,大小爲1B,則一個間隔內數據大小爲8192B。因此在此種情形下,每8個數據標記會對應同一個壓縮數據塊,如圖所示。
image.png

一對一

一個數據標記對應一個壓縮數據塊,當一個間隔(index_granularity)內的數據未壓縮大小size大於等於64KB且小於等於1MB時,會出現這種對應關係。以hits_v1測試表的URLHash字段爲例。URLHash數據類型爲UInt64,大小爲8B,則一個間隔內數據大小爲65536B,剛好等於64KB。因此在此種情形下,數據標記與壓縮數據塊是一對一的關係,如圖所示。

image.png

一對多

一個數據標記對應多個壓縮數據塊,當一個間隔(index_granularity)內的數據未壓縮大小size直接大於1MB時,會出現這種對應關係。以hits_v1測試表的URL字段爲例。URL數據類型爲String,大小根據實際內容而定。如圖所示,編號45的標記對應了2個壓縮數據塊。
image.png

相關文章
相關標籤/搜索