MergeTree引擎
ClickHouse中有多種表引擎,包括MergeTree、外部存儲、內存、文件、接口等,6大類,20多種表引擎。其中最強大的當屬MergeTree(及其同一家族中)引擎。我們在前面的建表例子中也使用了MergeTree引擎。
MergeTree系列引擎,在寫入一批數據時,數據是以數據片段(官網稱為part)的形式一個接一個地快速寫入,且此數據片段無法修改。這些數據片段會在后台按照一定的規則進行合並,避免數據片段過多。相對於在插入時不斷修改(重寫)已存儲的數據,這種策略會高效很多。
相信大家對將數據片段寫入的方式不會太陌生,在kafka和ElasticSearch中使用的segment作為數據存儲,以及HBase中以HFile作為數據存儲,這些方式均采用了以下方式:
- 數據文件只能追加,不能對已有數據進行直接修改(即使是刪除,也僅是添加一行記錄表示刪除標記)
- 為了防止文件數過多,會在后台按照一定規則進行合並
這種設計對於大量寫入是非常有益的。
MergeTree的主要特點為:
- 存儲的數據按主鍵排序:這樣可以創建一個小型的稀疏索引來加快數據檢索
- 支持數據分區:數據分區可以僅掃描指定分區數據,提高性能
- 支持數據副本
- 支持數據采樣
1. MergeTree存儲結構
MergeTree表引擎中的數據會以分區目錄的形式保存在磁盤上,下面我們創建一個分區表,並以此為例,介紹MergeTree的實際數據存儲的目錄結構。
# 建表 CREATE TABLE partition_v1 ( `ID` String, `URL` String, `EventTime` Date ) ENGINE = MergeTree PARTITION BY toYYYYMM(EventTime) ORDER BY ID # 插入數據 insert into partition_v1 values ('A000', 'www.nauu.com', '2020-04-13'), ('A001', 'www.hello.com', '2021-05-14')
根據clickhouse服務器默認配置,數據默認落盤路徑為/var/lib/clickhouse/ 。
在/var/lib/clickhouse/下的 data/ 路徑下,我們定位到創建的表的路徑,打印當前路徑結果為:
# pwd /var/lib/clickhouse/data/default/partition_v1 # ll total 4 drwxr-x--- 2 clickhouse clickhouse 201 Apr 13 06:38 202004_1_1_0 drwxr-x--- 2 clickhouse clickhouse 201 Apr 13 06:38 202105_2_2_0 drwxr-x--- 2 clickhouse clickhouse 6 Apr 13 06:37 detached -rw-r----- 1 clickhouse clickhouse 1 Apr 13 06:37 format_version.txt
需要說明的是:partition_v1 是一個鏈接文件,指向的是實際存儲地址(此地址與system.parts 表里的地址一致)。
可以看到在表下出現了3類文件,分別為:
- 202004_1_1_0:指定的分區路徑
- detached:ClickHouse中可以從表detach一個分區,並在后續再attach回表,detach的分區會放入此路徑
- format_version.txt:當前格式版本
再往下一層查看分區路徑內文件:
# cd 202105_2_2_0/ # ll total 36 -rw-r----- 1 clickhouse clickhouse 256 Apr 13 06:38 checksums.txt -rw-r----- 1 clickhouse clickhouse 79 Apr 13 06:38 columns.txt -rw-r----- 1 clickhouse clickhouse 1 Apr 13 06:38 count.txt -rw-r----- 1 clickhouse clickhouse 99 Apr 13 06:38 data.bin -rw-r----- 1 clickhouse clickhouse 112 Apr 13 06:38 data.mrk3 -rw-r----- 1 clickhouse clickhouse 10 Apr 13 06:38 default_compression_codec.txt -rw-r----- 1 clickhouse clickhouse 4 Apr 13 06:38 minmax_EventTime.idx -rw-r----- 1 clickhouse clickhouse 4 Apr 13 06:38 partition.dat -rw-r----- 1 clickhouse clickhouse 10 Apr 13 06:38 primary.idx
這里面主要介紹的文件有:
- data.bin:實際存儲數據的文件,使用壓縮格式存儲,默認壓縮格式為LZ4(由default_compression_codec.txt定義)
- data.mrk3:標記文件,保存.bin 文件中數據的偏移量信息。標記文件與稀疏索引對齊,又與.bin文件一一對應,所以MergeTree通過標記文件建立了primary.idx稀疏索引與.bin數據文件之間的映射關系。也就是說,先通過稀疏索引(primary.idx)找到對應數據的偏移量信息(.mrk),再通過偏移量直接從.bin 文件中讀取數據。
- count.txt:記錄統計的行數
- partition.dat 與 minmax_[Column].idx:如果使用了分區鍵,則會額外生成這2個文件,均使用二進制存儲。partition.dat保存當前分區下分區表達式最終生成的值;minmax索引用於記錄當前分區下分區字段對應原始數據的最小值和最大值。例如,假設EventTime字段對應的原始數據為2019-05-01、2019-05-05,分區格式為 toYYYYMM(EventTime)。則partition.dat 中的值為2019-05,minmax索引中保存的值為2019-05-012019-05-05。
這里需要特別注意的是,由於測試表中partition_v1僅有2條數據,所以全部數據均在data.bin 文件中。但是在一般情況下,每一列均會有一個對應的[Column].bin、[Column].mrk 文件。
例如我們查看tutorial.hits_v1 表對應的數據目錄可以看到:
# ll | grep UserAgentMajor -rw-r----- 1 clickhouse clickhouse 3562335 Apr 12 12:16 UserAgentMajor.bin -rw-r----- 1 clickhouse clickhouse 26280 Apr 12 12:16 UserAgentMajor.mrk2
UserAgentMajor為hits_v1 表中的一個字段(列),它會有單獨的數據文件,以及對應的標記文件。
2. 分區目錄命名
在上面的例子中,分區表partition_v1 對應的分區目錄為:202004_1_1_0 和 202105_2_2_0
對於MergeTree來說,分區路徑命名的格式為:PartitionID_MinBlockNum_MaxBlockNum_Level。
以202004_1_1_0 舉例,202004表示分區目錄的ID;1_1表示最小的數據塊編號和最大的數據塊編號;最后的_0表示當前合並的層級。
具體解釋為:
- PartitionID:也就是分區具體名稱(ID)
- MinBlockNum_MaxBlockNum:最小數據塊的編號與最大數據塊的編號。BlockNum是一個自增的編號,每當新創建一個分區目錄時,計數就加1。對於新增加的分區目錄,MinBlockNum與MaxBlockNum一致,例如第1個分區為 202004_1_1_0,第2個分區為202105_2_2_0,依次類推。不過當分區目錄發生合並時,它們會有不同的取值規則。
- Level:合並的層級,可以理解為某個分區被合並過的次數,或者這個分區的年齡,數字越大表示年齡越大。Level計數與BlockNum不同,並非全局累加。對每個新分區來說,Level初始值均為0。之后若是相同分區發生合並,則相應分區內計數加1
3. 分區目錄合並
在MergeTree中,每次寫入數據(例如一次INSERT寫入一批新數據)時,並非是在已有分區目錄里追加文件,而是都會生成一批新的分區目錄。即便不同批次寫入的數據都數據相同分區,也會生成不同的分區目錄。所以,對於同一個分區,也會存在多個分區目錄的情況。在之后的某個時刻(寫入后的10~15分鍾,也可以手動執行optimize語句),ClickHouse會通過后台任務,將屬於相同分區的多個目錄合並為一個新的目錄。已經存在的舊分區目錄不會立即刪除,而是在之后的某個時刻通過后台任務刪除(默認8分鍾)。
例如,再次向表partition_v1 插入一條數據:
insert into partition_v1 values('A002','www.a02.com','2020-04-13')
查看表下路徑:
# ls 202004_1_1_0 202004_3_3_0 202105_2_2_0 detached format_version.txt
可以看到新增加了一個目錄202004_3_3_0,雖然插入的是一個已有的分區。
執行OPTIMIZE TABLE partition_v1 后的表下路徑:
# ls 202004_1_1_0 202004_1_3_1 202004_3_3_0 202105_2_2_0 detached format_version.txt
可以看到同一分區202004 進行了合並,生成新目錄202004_1_3_1,但舊目錄並未立即被刪除。
通過新生成的目錄名,我們可以得知:此分區為202004,MinBlockNum為1,MaxBlockNum為3,合並過一次,所以Level為1。
合並后,目錄下的索引和數據文件也會相應進行合並。新目錄命名遵循的規則為:
- MinBlockNum:取同一分區內所有目錄中最小的MinBlockNum值
- MaxBlockNum:取同一分區內所有目錄中最大的MaxBlockNum值
- Level:取同一分區內最大Level值並加1
在目錄分區合並完成后,舊的分區會置為active=0 的狀態(在system.parts表中記錄),在數據查詢時,它們會被自動過濾。等待一段時間后,就會被自動刪除。
4. 稀疏索引
primary.idx 文件內的一級索引采用稀疏索引實現。稀疏索引與稠密索引區別如下:

在稀疏索引中,每行索引不會對應到每行記錄,而是映射到一段數據的第一行。在Clickhouse中,索引粒度由index_granularity 決定(默認為8192,不過新版ClickHouse中提供了自適應粒度大小的特性),在這個配置下,對於1億行數據,只需要12208行索引標記即可提供索引。由於稀疏索引占用空間較小,所以primary.idx 內的索引數據常駐內存,取用速度非常快。
5. 索引數據生成規則
以hits_v1表的數據為例,其主鍵為CounterID(ORDER BY CounterID),對於2014-03分區內的數據,以index_granularity=8192為例,每隔8192行數據就會取一次CounterID的值作為索引,此索引值最終寫入primary.idx文件。如下圖所示:

如果主鍵為復合主鍵,例如ORDER BY (CounterID, EventDate),則每隔8192行可以同時取CounterID與EventDate兩列的值作為索引值。如下圖所示:

6. 索引查詢過程
在ClickHouse中,MarkRange用於定義標記區間的對象。MergeTree按照index_granularity的間隔粒度,將一段完整的數據划分成多個小的間隔數據段,一個具體的數據段即為一個MarkRange。
MarkRange與索引編號對應,使用start和end兩個屬性表示其區間范圍。結合索引編號的取值以及start和end,即可得到它所對應的數值區間,此數值區間即為此MarkRange包含的數據范圍。
假設有一份示例數據,一共192條記錄,主鍵ID為String類型,ID的取值從A000開始,后面依次為A001、A002 … 直到 A192。MergeTree的索引粒度index_granularity = 3,根據索引的生成規則,primary.idx 文件內的索引數據值如下圖所示:

根據索引數據,MergeTree會將此數據片段划分為 192/3 = 64 個小的MarkRange,2個相鄰的MarkRange的步長為1。其中,所有MarkRange(整個數據片段)的最大數值區間為[A000, +inf],示意圖如下:

在了解以上背景后,對索引的查詢過程就比較好解釋了。索引查詢是2個數值區間的交集判斷。其中,一個區間是由基於主鍵的查詢條件轉換而來的條件區間;而另一個區間是與MarkRange對應的數值區間。
整個索引查詢過程可以大致分為3個步驟:
1. 生成查詢條件區間:首先將查詢條件轉換為條件區間。即便是單個值的查詢條件,也會被轉換成區間的形式。例如:
WHERE ID = 'A003' ['A003', 'A003'] WHERE ID > 'A000' ('A000', + inf) WHERE ID < 'A188' (-inf, 'A188') WHERE ID LIKE 'A006%' ['A006', 'A007')
2. 遞歸交集判斷:以遞歸的形式,依次對MarkRange的數值區間與條件區間做交集判斷。從最大的區間[A000, +inf) 開始:
- 如果不存在交集,則直接通過剪支算法優化此整段MarkRange
- 如果存在交集,且MarkRange步長大於8(end-start),則將此區間進一步拆分成8個子區間(由merge_tree_coarse_index_granularity指定,默認值為8),並重復此規則,繼續做遞歸交集判斷
- 如果存在交集,且MarkRange不可再分解(步長小於8),則記錄MarkRange並返回
3. 合並MarkRange區間:將最終匹配的MarkRange聚在一起,合並它們的范圍。
完整過程如下圖所示:

MergeTree通過遞歸的形式持續向下拆分區間,最終將MarkRange定位到最細的粒度,以幫助在后續讀取數據的時候,能夠最小化掃描數據的范圍。以上圖為例,當查詢條件 WHERE ID=’A003’ 時,最終只需要讀取[A000, A003] 和 [A003, A006] 兩個區間的數據,它們對應所屬MarkRange(start:0, end:2) 的范圍。其他無用區間都被裁減掉了。因為MarkRange轉換的數值區間是閉區間,所以會額外匹配到鄰近的一個區間。
7. 數據存儲
從之前的文件目錄結構我們已經知道,在MergeTree中,每個分區都有一個獨立的目錄。每個分區目錄下,對於每列,都有一個 .bin 格式存儲文件,用於存儲實際壓縮后的數據。所以在 .bin 文件中僅保存當前分區片段內的這一部分數據。
在存儲數據到 .bin 文件中時,數據首先是經過壓縮的(默認為LZ4 算法);其次,數據會事先以ORDER BY 的聲明進行排序;最后,數據是以壓縮數據塊的形式被組織並寫入到 .bin 文件中的。
7.1. 壓縮數據塊
一個壓縮數據塊由頭信息和壓縮數據,這兩部分組成。頭信息固定使用9位字節表示,具體由1個UInt8(1字節)整型、2個UInt32(4字節)整型組成,分別代表使用的壓縮算法類型、壓縮后的數據大小和壓縮前的數據大小。具體如下圖所示:

從上圖可以看到,.bin壓縮文件是由多個壓縮數據塊組成,每個壓縮數據塊的頭信息是基於CompressionMethod_CompressedSize_UncompressedSize公式生成。
通過ClickHouse提供的clickhouse-compressor工具,可以查詢到某個.bin 文件中壓縮數據的統計信息:
# clickhouse-compressor --stat UserID.bin | head 65536 14009 65536 18431 65536 6434 … 65536 9267 65536 14260
其中每一行數據代表一個壓縮數據塊的頭部信息,分別表示該壓縮塊中“未壓縮數據大小”和“壓縮后數據大小”(打印的信息與物理存儲的順序相反)。
每個壓縮數據塊的體積,按照其壓縮前的數據字節大小,都被嚴格控制在64KB~1MB,其上下限分別由min_compress_block_size(默認65536)與max_compress_block_size(默認1048576)參數指定。而一個壓縮數據塊最終的大小,則和一個間隔(index_granularity)內數據的實際大小相關。
MergeTree在數據具體的寫入過程中,會依照索引粒度(默認情況下,每次取8192行),按批次獲取數據並進行處理。如果把一批數據的未壓縮大小設置為size,則整個寫入過程遵循以下規則:
- 單個批次數據size < 64KB:如果單個批次數據小於64KB,則繼續獲取下一批數據,直至累積到size >= 64 KB 時,生成下一個壓縮數據塊
- 單個批次數據64KB <= size <= 1MB:如果單個批次數據大小恰好在64KB 與 1MB之間,則直接生成下一個壓縮數據塊
- 單個批次數據 size > 1MB:如果單個批次數據直接超過1MB,則首先按照1MB大小截斷並生成下一個壓縮數據塊。剩余數據繼續按照上述規則執行。此時會出現一個批次數據生成多個壓縮數據塊的情況。
整體邏輯如下圖所示:

經過上述介紹后,我們知道:一個 .bin 文件是由1到多個壓縮數據塊組成,每個壓縮塊在64KB ~1MB 之間。多個壓縮數據塊之間,按照寫入順序首位相接,緊密地排列在一起。
在 .bin 文件中引入壓縮數據塊的目的至少有2點:
- 雖然數據被壓縮后能夠有效減少數據大小,降低存儲空間並加速數據傳輸效率,但數據的壓縮和解壓動作,其本身也會帶來額外的性能損耗。所以需要控制被壓縮數據的大小,以求在性能損耗和壓縮率之間尋求一種平衡;
- 在具體讀取某一列數據時(.bin文件),首先需要將壓縮數據加載到內存並解壓,然后才能進行后續的數據處理。通過壓縮數據塊,可以在不讀取整個 .bin 文件的情況下將讀取粒度降低到壓縮數據塊級別,從而進一步縮小數據讀取的范圍

8. 數據標記
前面我們已經介紹了 .bin 與 primary.idx,.bin 存儲了分區目錄下某一列的壓縮數據,primary.idx 中,以index_granularity為間隔,存儲了對應列的稀疏索引。前面我們還看到過一個文件格式是 .mrk類型,下面介紹數據標記文件。
8.1. 數據標記生成規則
通過索引下標找對應數據標記的流程如:

首先可以看到的是,數據標記和索引區間是對其的,均按照index_granularity的粒度間隔。這樣只需要通過索引區間的下標編號就可以直接找到對應的數據標記。
同時,每個列字段的 [column_name].bin 文件都有一個與之對應的 [column_name].mrk 數據標記文件,用於記錄數據在 [column_name].bin 文件中的偏移量信息。
一行標記數據使用一個元組表示,元組內包含2個整型數值的偏移量信息。它們分別表示在此段數據區間內,在對應的 .bin 文件中,壓縮數據塊的起始偏移量;以及將該數據塊解壓后,其未壓縮數據的起始偏移量。如下圖所示:

每一行標記數據都表示了一個片段的數據(默認8192行)在 .bin 壓縮文件中的讀取位置信息。標記數據與一級索引數據不同,它並不能常駐內存,而是使用LRU(最近最少使用)緩存策略加快其取用數據。
8.2. 數據標記的工作方式
MergeTree在讀取數據時,必須通過標記數據的位置信息才能夠找到所需要的數據。整個查找過程大致分為2步:“讀取壓縮數據塊” 和 “讀取數據”。
下面以hits_v1表為例,下圖為hits_v1表的JavaEnable字段及其標記數據與壓縮數據的對應關系:

首先解釋最左邊的“壓縮文件中的偏移量”。
- JavaEnable字段的數據類型為UInt8,所以對於此列,每個條目占用空間為1個字節,也就是1B
- hits_v1表的index_granularity粒度為8192。前面提到primary.idx 中,每隔8192行,就寫一個索引條目,所以此時一個MarkRange的大小即為 8192B
- 前面提到,在實際存儲數據到JavaEnable.bin中時,是以壓縮數據塊的形式。此時8192B作為單個批次數據的size,是小於64KB,所以會繼續獲取下一個批次的數據(也就是又一批size=8192B 的數據,也就是下一個MarkRange),直到數據量 >= 64 KB時,生成一個壓縮數據塊
- 對於JavaEnable字段,64KB = 65536B,65536B/8192 = 8。所以在 JavaEnable.mrk 數據標記文件中,前8行對應的均是JavaEnable.bin 里的第1個壓縮數據塊。也就是說,每8行標記數據,對應1個壓縮數據塊
- 從圖中可以看到,.mrk前8行對應的均是 .bin 文件中的壓縮塊0
再解釋左二的“解壓縮塊中的偏移量”:
- 前面提到,在 .mrk 文件中,每行標記數據為2元元組,第一個元素表示在 .bin 文件中壓縮數據塊的起始偏移量;第二個元素表示將數據塊解壓后,未壓縮數據的起始偏移量
- 在對JavaEnable字段數據解壓縮后,每個MarkRange的長度均為8192,所以在“解壓縮塊中的偏移量”以8192B為單位遞增。而由於超出了64KB后,一個壓縮數據塊結束,到第二個壓縮塊時,此偏移量又以0為起始
簡單來說:在 .mrk文件中,每行的第一個元素找到 .bin 中的對應壓縮數據塊;第二個元素找到此壓縮塊展開后,對應的MarkRange的其實地址在這個數據塊中的位置。
下面詳細解釋MergeTree如何定位壓縮數據塊,並讀取數據:
- 讀取壓縮數據塊:在查詢某一列數據時,MergeTree無須一次行加載整個.bin文件。而是根據過濾條件,僅加載所需特定壓縮數據塊。.mrk 文件中的元組的第一個元素即可用於判斷有多少行的 .mrk 數據構成了一個壓縮數據塊。例如:第1個壓縮數據塊在 .mrk 文件中元組的第一個元素均為0。所以在這個例子中,每8個數據標記遍定位到了一個壓縮數據塊
- 讀取數據:在壓縮數據塊解壓后,MergeTree並不需要掃描整個解壓后的數據,而是以index_granularity為粒度,加載特定一個MarkRange。此時遍可以通過 .mrk 文件中元組的第二個元素判斷要讀取哪個MarkRange的數據。例如在上圖中,通過 .mrk的[0, 0] 可以定位到第1個MarkRange的起始位置;通過 .mrk 的第2行 [0, 8192] 可以定位到第1個MarkRange的結束位置;結合起來,便可讀到對應 primar.idx 中第1條索引對應的數據內容。
9. MergeTree寫入過程總結
數據寫入的第1步是生成分區目錄,每個批次的寫入均會寫入一個新的分區。后續對於相同的分區會進行合並,生成新的分區,並且舊的分區會在后續被刪除。
接下來,按照index_granularity索引粒度,生成 primary.idx 一級索引(如果聲明了二級索引,還會創建二級索引文件),此時每個索引片段對應的是一個MarkRange。同時還會對每個column生成 .mrk 數據標記文件,和.bin 壓縮數據文件。下圖是MergeTree在寫入數據時,分區目錄、索引、標記和壓縮數據的過程:

10. MergeTree查詢過程總結
查詢的過程是一個不斷過濾的過程,在理想情況下,MergeTree可以依次借助分區目錄,primary.idx 和二級索引,將數據掃描的范圍縮至最小。然后再借助 .mrk 數據標記文件,定位到具體壓縮數據塊,並在解壓縮后,定位到具體MarkRange內的數據。如下圖所示:

如果一條查詢語句沒有指定任何WHERE條件,或是指定了WHERE條件但是沒有匹配到任何索引(包含分區、一級索引與二級索引),則MergeTree就無法預先減少所需掃描的數據范圍。它會掃描所有分區目錄,以及目錄內索引段的最大區間。雖然無法減少掃描數據范圍,但MergeTree仍能夠借助.mrk數據標記文件,以多線程的方式同時讀取多個壓縮數據塊,以提升性能。
