前言:Clickhouse是一款列式存儲的開源DBMS,以其強悍的單機運算能力著稱,最近在工作中接觸了這款數據庫,對其進行了一些學習,在這里記錄下來與大家共同分享交流。
Clickhouse中有眾多表引擎,不同的表引擎在底層數據存儲上千差萬別,在功能和性能上各有側重。但實際生產中,使用最廣泛的表引擎就是MergeTree系列。
MergeTree家族是Clickhouse中最有特色,也是功能最強大的表引擎,實現了數據的partitioning、replication、mutation、merge,以及在merge基礎上的replace、aggregation。為了了解這些功能是如何實現的,首先需要知道在MergeTree引擎時,數據文件的組織、存儲形式及內容。
數據表的目錄組成
clickhouse
└── test_db
├── test_table_a
│ ├── 20210224_0_1_1_2
│ ├── 20210224_3_3_0
│ ├── 20210225_0_1_2_3
│ └── 20210225_4_4_0
└── test_table_b
├── 20210224_0_1_1_2
├── 20210224_3_3_0
├── 20210225_0_1_2_3
└── 20210225_4_4_0

在clickhouse中,一個典型的分區表在文件系統中的目錄存儲結構如上圖所示。庫(database)、表(table)、分區(partition)都是按文件目錄組織起來的,每個庫會有對應的一個庫目錄,庫中的每個表也會有各自對應的表目錄。
每張表會包含若干個分區,這里的分區是邏輯概念,並不像庫表那樣會有目錄與之一一對應。在目錄形式上,分區實際是一系列part的集合。每張表至少會有一個分區,如果不進行分區配置,則默認為一個all分區。
每個分區是由若干個part組成的,每個part對應一個目錄,一般命名格式為{partition}{min_block_number}{max_block_number}{level},如果經過mutate操作,則還會有{data_version}的后綴。
在part命名格式{partition}{min_block_number}{max_block_number}{level}_{data_version}中:
- partition是分區值
- min_block_number,max_block_number表示這個part包含的最小、最大的block number。每次數據寫入都會至少生成一個block,每個block會有自己的block_number
- level表示這個part經過了幾次merge,每merge一次會更新生成一個level+1后的part目錄
- data_version表示mutate操作的data_version,每一次mutate都會生成一個新的data_version的part目錄,這個值的含義其實和block_number類似,同時也和block_number共用自增id空間,通過這個值可以判斷每個part是否包含在本次mutate操作的影響范圍內
- block_number,level以及mutation在不同的partition中是相互獨立的
到這里,我們知道,Clickhouse表中數據是存儲在對應表目錄下一系列partition parts目錄中的,下面就來看看part目錄下的數據又是如何組織的。
數據Part目錄
用一張具體的表舉例:
CREATE TABLE test_benchmark.test_data
(
`id` UInt64,
`prop1` String,
`prop2` String,
`dt` Date,
`update_time` DateTime
) ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/test_benchmark.test_data', '{replica}', update_time)
PARTITION BY toYYYYMMDD(dt)
ORDER BY id
SETTINGS index_granularity = 8192
視數據量大小,上表的part可能有Wide或Compact兩種格式,Compact格式中,所有列的數據都會放在同一個文件中,對小批量數據寫入更友好,但經過合並后隨着數據增加最終都會用Wide格式存儲,這里先以Wide格式為例,上表的一個part目錄中的文件有:
-rw-r----- 1 clickhouse clickhouse 655 Jan 27 11:52 checksums.txt
-rw-r----- 1 clickhouse clickhouse 140 Jan 27 11:52 columns.txt
-rw-r----- 1 clickhouse clickhouse 6 Jan 27 11:52 count.txt
-rw-r----- 1 clickhouse clickhouse 10 Jan 27 11:52 default_compression_codec.txt
-rw-r----- 1 clickhouse clickhouse 4 Jan 27 11:52 minmax_dt.idx
-rw-r----- 1 clickhouse clickhouse 4 Jan 27 11:52 partition.dat
-rw-r----- 1 clickhouse clickhouse 304 Jan 27 11:52 primary.idx
-rw-r----- 1 clickhouse clickhouse 2.7K Jan 27 11:52 dt.bin
-rw-r----- 1 clickhouse clickhouse 912 Jan 27 11:52 dt.mrk2
-rw-r----- 1 clickhouse clickhouse 2.3M Jan 27 11:52 id.bin
-rw-r----- 1 clickhouse clickhouse 912 Jan 27 11:52 id.mrk2
-rw-r----- 1 clickhouse clickhouse 1.8M Jan 27 11:52 prop1.bin
-rw-r----- 1 clickhouse clickhouse 912 Jan 27 11:52 prop1.mrk2
-rw-r----- 1 clickhouse clickhouse 1.8M Jan 27 11:52 prop2.bin
-rw-r----- 1 clickhouse clickhouse 912 Jan 27 11:52 prop2.mrk2
-rw-r----- 1 clickhouse clickhouse 1.2M Jan 27 11:52 update_time.bin
-rw-r----- 1 clickhouse clickhouse 912 Jan 27 11:52 update_time.mrk2
part目錄的作用是以一種方式將磁盤的數據組織起來,以支持高效的寫入和查詢,目錄中各個文件的作用是:
- checksums.txt:當前目錄下各個文件的大小以及各文件內容的hash,用於驗證數據是否完整
- columns.txt:此表中所有列以及每列的類型
- count.txt:此part中數據的行數
- default_compression_codec.txt:數據文件的默認壓縮算法
- minmax_dt.idx:此表的分區列dt,在這個part中的最大值和最小值
- partition.dat:從分區列計算出分區值的方法
- primary.idx:數據索引,其實是排序鍵的那一列每間隔index_granularity的值,如果有n列,那每間隔index_granularity就會有n個值,同時也會受index_granularity_bytes影響
- {column_name}.bin:每一列數據的列存文件,存放了實際每一單獨列在各行的數值
- {column_name}.mrk2:每一列數據的列存數據標記
上述文件中,列存文件、數據索引和數據標記是最關鍵的三類部分,是Clickhouse存儲和查詢數據的核心基礎,下面再進一步詳細看看這三類文件的內容和作用。
列存文件
在Part目錄下,可以觀察到,每一列均有一個單獨的列存文件{column_name}.bin來存儲實際的數據。為了盡可能減小數據文件大小,文件需要進行壓縮,默認算法由part目錄下的default_compression_codec文件確定。如果直接將整個文件壓縮,則查詢時必須讀取整個文件進行解壓,顯然如果需要查詢的數據集比較小,這樣做的開銷就會顯得特別大,因此一個列存文件是一個個小的壓縮數據塊組成的。一個壓縮數據塊中可以包含若干個granule的數據,而granule就是Clickhouse中最小的查詢數據集,后面的索引以及標記也都是圍繞granule來實現的。granule的大小由配置項index_granularity確定,默認8192;壓縮數據塊大小范圍由配置max_compress_block_size和min_compress_block_size共同決定。每個壓縮塊中的header部分會存下這個壓縮塊的壓縮前大小和壓縮后大小。整個結構如下圖所示:

數據索引文件
在Clickhouse中,數據表中最終的數據是按列存儲在Part目錄下的各自對應的列存文件下的。那么,查詢時應該如何從不同的列存文件中查找到同屬於某一行的數據?
有一個前提是,在Clickhouse中,如果使用MergeTree系列的表引擎,則必須指定一個排序鍵。排序鍵可以由一列或多列組成,在數據寫入磁盤文件中時,數據行的就會按照制定的順序排列,隨后按列拆分寫入各個列存文件中。
在數據有序的前提下,假設文件不進行任何壓縮,則可以通過行號以及每個字段自身所占大小推算出某行號據在文件中的位置。因此,最暴力的方法是根據查詢條件到對應列存文件中遍歷掃描,查詢到對應的數據行號,再由此行號獲取其他列存文件在相同行號范圍內的數據。顯然,如果能夠做一個數據 => 行號的索引,則可以在很大程度上提高查詢效率,減少文件遍歷掃描,這就是數據索引文件(primary.idx)的作用。為了盡量減小索引所占的空間,並且實際上列存文件數據也是按granule組織的,因此可以用稀疏索引的方式建立排序鍵到行號的對應關系,索引間隙的大小和granule相同,則大致的索引結構就可以描述為下圖(和實際的primary.idx文件的內容有些許差異,后文會進一步說明,但用下圖可以描述需要達成的作用):

通過上面這樣一個索引結構,可以快速查詢到指定條件下數據所在行的范圍,再通過行號以及各個列的字段長度可以推算出其他列在各自文件中的位置范圍。然而實際上,數據在clickhouse中是經過壓縮的,且壓縮數據正是clickhouse以及其他列存數據庫能夠達到高性能的原因,極致的壓縮能盡可能減少對磁盤的讀取。在數據壓縮的情況下,各個列每行所占的世紀大小與設定的字段長度並不相同,單靠行號已經無法計算出各列在列存文件中的具體位置,而是還需要各個列單獨有自己的一個 行號 => 數據位置的關系。
數據標記文件
在Clickhouse中,每一列的數據都經過某種壓縮算法進行壓縮。在這種情況下,從數據索引文件查詢出數據所在行號后,無法通過行號+字段類型來推算出某行數據的具體位置,為了提高查詢性能,Clickhouse設計了數據標記文件,幫助我們更高效地獲取到每行數據在列存文件中的具體位置。

上文提到過,列存文件是由一個個壓縮數據塊組成的,為了查詢數據,首先需要定位到數據屬於那部分壓縮數據塊,這里的壓縮數據塊位置是未解壓前的偏移量,找到這個位置后可根據壓縮塊header中內容將壓縮塊解壓,再通過塊內偏移量,就能查到具體的數據。
索引與標記的協同
上文提到,定位一行數據的過程大概是:
- 通過索引文件查到對應的行號范圍
- 通過行號在數據標記文件中查到數據在列存文件中的偏移
索引文件和標記文件實際是一對多的關系(主鍵只有一個,但列有很多),將索引文件和標記文件剝離后,索引文件大小比較小,可以常駐內存。查詢到數據范圍后,可以直接計算出數據對應在標記文件中的位置,做最小化查詢。
這里的行號其實只是用於關聯起索引和標記兩個表,而這兩個表的數據在行方向其實是一一順序對應的,因此行號其實是實際上是不需要存在文件中的,這也是Clickhouse追求極致性能,數據盡量精簡的一個體現。可以通過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行會產生一行索引和標記,每index_granularity_bytes大小也會產生一行索引和標記。