楔子
表引擎是 ClickHouse 中的一大特色,可以說表引擎決定了一張表最終的性格,比如數據表擁有何種特性、數據以何種形式被存儲以及如何被加載。ClickHouse 擁有非常龐大的表引擎體系,總共有合並樹、外部存儲、內存、文件、接口和其它 6 大類 20 多種表引擎,而在這眾多的表引擎中,又屬合並樹(MergeTree)表引擎及其家族系列(*MergeTree)最為強大,在生產環境中絕大部分場景都會使用此引擎。因為只有合並樹系列的表引擎才支持主鍵索引、數據分區、數據副本、數據采樣等特性,同時也只有此系列的表引擎支持 ALTER 相關操作。因此這里我們着重介紹合並樹,因為它非常非常非常重要,並且難度也最高,至於其它的表引擎由於比較簡單,所以我們放到后面再介紹。
當然我們說合並樹家族自身也有很多表引擎的變種,其中 MergeTree 作為家族中最為基礎的表引擎,提供了主鍵索引、數據分區、數據副本和數據采樣等基本能力,而家族中的其它其它表引擎則在 MergeTree 的基礎之上各有所長。比如 ReplacingMergeTree 表引擎具有刪除重復數據的特性,而 SummingMergeTree 表引擎則會按照排序鍵自動聚合數據。如果再給合並樹系列的表引擎加上 Replicated 前綴,又會得到一組支持數據副本的表引擎,例如 ReplicatedMergeTree、ReplicatedReplacingMergeTree、ReplicatedSummingMergeTree、ReplicatedAggregatingMergeTree 等等。

雖然合並樹的變種有很多,但 MergeTree 表引擎才是根基。作為合並樹家族最基礎的表引擎,MergeTree 具備了該系列其它表引擎共有的基本特征,吃透了 MergeTree 表引擎的基本原理,就掌握了該系列表引擎的精髓。
MergeTree 的創建方式與存儲結構
MergeTree 在寫入一批數據時,數據總會以數據片段的形式寫入磁盤,且數據片段不可修改。而為了避免數據片段過多,ClickHouse 會通過后台線程定期的合並這些數據片段,屬於相同分區的數據片段會被合並成一個新的數據片段,這種數據片段往復合並的過程,正是 MergeTree 名稱的由來。
MergeTree 的創建方式
創建數據表的方法我們上面介紹過,而創建 MergeTree 數據表只需要在創建表的時候將 ENGINE 指定為 MergeTree() 即可,其完整語法如下:
CREATE TABLE [IF NOT EXISTS] [db_name.]table_name(
name1 type [DEFAULT|MATERIALIZED|ALIAS expr],
name2 type [DEFAULT|MATERIALIZED|ALIAS expr],
......
) ENGINE = MergeTree()
[PARTITION BY expr]
ORDER BY expr
[PRIMARY KEY expr]
[SAMPLE BY expr]
[SETTINGS name1=value1, name2=value2, ......]
我們看到 MergeTree 表引擎除了常規的參數之外,還有一些獨有的配置選項,一會兒會詳細介紹這幾個重要的配置項,包括它們的使用方法和工作原理,目前先來大致看一下它們的作用。
1) PARTITON BY:選填,表示分區鍵,用於指定表數據以何種標准進行分區。分區鍵既可以是單個字段、也可以通過元組的形式指定多個字段,同時也支持使用列表達式。如果不支持分區鍵,那么 ClickHouse 會生成一個名稱為 all 的分區,合理地使用分區可以有效的減少查詢時數據量。最常見的莫過於按照時間分區了,數據量非常大的時候可以按照天來分區,一天一個分區,這樣查找某一天的數據時直接從指定分區中查找即可。
2)ORDER BY:必填,表示排序鍵,用於指定在一個分區內,數據以何種標准進行排序。排序鍵既可以是單個字段,例如 ORDER BY CounterID,也可以是通過元組聲明的多個字段,例如 ORDER BY (CounterID, EventDate)。如果是多個字段,那么會先按照第一個字段排序,如果第一個字段中有相同的值,那么再按照第二個字段排序,依次類推。總之在每個分區內,數據是按照分區鍵排好序的,但多個分區之間就沒有這種關系了。
3)PRIMARY KEY:選填,表示主鍵,聲明之后會依次按照主鍵字段生成一級索引,用於加速表查詢。如果不指定,那么主鍵默認和排序鍵相同,所以通常直接使用 ORDER BY 代為指定主鍵,無須可以使用 PRIMARY KEY 聲明。所以一般情況下,在每個分區內,數據與一級索引以相同的規則升序排列(因為數據是按照排序鍵排列的,而一級索引也是按排序鍵、也就是主鍵進行排列的)。和其它關系型數據庫不同,MergeTree 允許主鍵有重復數據(可以通過 ReplacingMergeTree 實現去重)。
4)SAMPLE KEY:選填,抽樣表達式。用於聲明數據以何種標准進行采樣,注意:如果聲明了此配置項,那么主鍵的配置中也要聲明同樣的表達式。例如:
......
) ENGINE = MergeTree()
ORDER BY (CountID, EventDate, intHash32(UserID))
SAMPLE BY intHash32(UserID)
-- 抽樣表達式需要配合 SAMPLE 子查詢使用,該功能對選取抽樣數據十分有用
-- 關於抽樣查詢,后面會在介紹查詢的時候說
5)SETTINGS:選填,用於指定一些額外的參數,以 name=value 的形式出現,name 主要包含 index_granularity、min_compress_block_size、index_granularity_bytes、enbale_mixed_granularity_parts、merge_with_ttl_timeout、storage_policy,比如:
......
) ENGINE = MergeTree()
......
SETTINGS index_granularity=8192, min_compress_block_size=6536
下面解釋一下這些參數的含義:
index_granularity:對於 MergeTree 而言是一個非常重要的參數,它表示索引的粒度,默認值為 8192。所以 ClickHouse 根據主鍵生成的索引實際上稀疏索引,默認情況下是每隔 8192 行數據才生成一條索引。類似於 kafka 的日志數據段,kafka 的每個數據段是由存儲實際消息的數據文件,和用於加速消息查找的索引文件組成,而 kafka 的索引文件建立的也是稀疏索引。
min_compress_block_size:我們知道 ClickHouse 是會對數據進行壓縮的,而 min_compress_block_size 表示的就是最小壓縮的塊大小,默認值為 65536。
index_granularity_bytes:在 19.11 版本之前 ClickHouse 只支持固定大小的索引間隔,由 index_granularity 控制,但是在新版本中增加了自適應間隔大小的特性,即根據每批次寫入的數據的體量大小,動態划分間隔大小。而數據的體量大小,則由 index_granularity_bytes 參數控制的,默認為 10M,設置為 0 表示不啟用自適應功能。
enbale_mixed_granularity_parts:表示是否開啟自適應索引的功能,默認是開啟的。
merge_with_ttl_timeout:從 19.6 版本開始 MergeTree 提供了數據的 TTL 功能,該部分后面詳細說。
storage_policy:從 19.15 版本開始 MergeTree 提供了多路徑的存儲策略,該部分同樣留到后面詳細說。
MergeTree 數據表的存儲結構
我們說在 ClickHouse 中一張表對應一個目錄,那么 MergeTree 數據表對應的目錄結構如何呢?我們之前說表對應的目錄里面存儲的就是文本文件(數據在磁盤上的載體),但對於 MergeTree 數據表而言還不太一樣,因為我們說 MergeTree 數據表是有分區的,所以表對應的目錄里面存儲的還是目錄。並且每個目錄對應一個分區,因此也叫分區目錄,而分區目錄里面存儲的才是負責容納數據的文本文件。
所以一張 MergeTree 數據表在磁盤上的物理結構分為三個層級,依次是數據表目錄、分區目錄、以及各分區目錄下的數據文件。我們畫一張圖:

分別解釋一下它們的作用:
1)partition:分區目錄,里面的各類數據文件(primary.idx、data.mrk、data.bin 等等)都是以分區目錄的形式被組織存放的,屬於相同分區的數據,最終會被合並到同一個分區目錄,而不同分區的數據永遠不會被合並在一起。關於數據分區的細節,后面會詳細說。
2)checksums.txt:校驗文件,使用二進制的格式進行存儲,它保存了余下各類文件(primary.txt、count.txt 等等)的 size 大小以及哈希值,用於快速校驗文件的完整性和正確性。
3)columns.txt:列信息文件,使用明文格式存儲,用於保存此分區下的列字段信息,比如我們創建一張表:
-- 該表負責存儲用戶參加過的活動,每參加一個活動,就會生成一條記錄
CREATE TABLE IF NOT EXISTS user_activity_event (
ID UInt64, -- 表的 ID
UserName String, -- 用戶名
ActivityName String, -- 活動名稱
ActivityType String, -- 活動類型
ActivityLevel Enum('Easy' = 0, 'Medium' = 1, 'Hard' = 2), -- 活動難度等級
IsSuccess Int8, -- 是否成功
JoinTime DATE -- 參加時間
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(JoinTime) -- 按照 toYYYYMM(JoinTime) 進行分區
ORDER BY ID; -- 按照 ID 字段排序
-- 插入一條數據
INSERT INTO user_activity_event VALUES (1, '張三', '尋找遺失的時間', '市場營銷', 'Medium', 1, '2020-05-13')
然后查看相關信息:
[root@satori ~]# cat /var/lib/clickhouse/data/default/user_activity_event/202005_1_1_0/columns.txt
columns format version: 1
7 columns:
`ID` UInt64
`UserName` String
`ActivityName` String
`ActivityType` String
`ActivityLevel` Enum8('Easy' = 0, 'Medium' = 1, 'Hard' = 2)
`IsSuccess` Int8
`JoinTime` Date
[root@satori ~]#
4)count.txt:計數文件,使用明文格式存儲,用於記錄當前分區下的數據總數。所以后續在查詢數據總量的時候可以瞬間返回,因為已經提前記錄好了。
[root@satori ~]# cat /var/lib/clickhouse/data/default/user_activity_event/202005_1_1_0/count.txt
1
5)primary.idx:一級索引文件,使用二進制格式存儲,用於存儲稀疏索引,一張 MergeTree 表只能聲明一次一級索引(通過 ORDER BY 或 PRIMARY KEY)。借助稀疏索引,在查詢數據時能夠排除主鍵條件范圍之外的數據文件,從而有效減少數據掃描范圍,加速查詢速度。
6)data.bin:數據文件,使用壓縮格式存儲,默認為 LZ4 格式,用於存儲表的數據。在老版本中每一個列字段都有自己獨立的 .bin 數據文件,並以列字段命名,但是在新版本中只有一個 data.bin,也就是合並在一起了。
7)data.mrk:標記文件,使用二進制格式存儲,標記文件中保存了 data.bin 文件中數據的偏移量信息,並且標記文件與稀疏索引對齊,因此 MergeTree 通過標記文件建立了稀疏索引(primary.idx)與數據文件(data.bin)之間的映射關系。而在讀取數據的時候,首先會通過稀疏索引(primary.idx)找到對應數據的偏移量信息(data.mrk),因為兩者是對齊的,然后再根據偏移量信息直接從 data.bin 文件中讀取數據。
8)data.mrk3:如果使用了自適應大小的索引間隔,則標記文件會以 data.mrk3 結尾,但它的工作原理和 data.mrk 文件是相同的。
9)partition.dat 和 minmax_[Column].idx:如果使用了分區鍵,例如上面的 PARTITION BY toYYYYMM(JoinTime),則會額外生成 partition.dat 與 minmax_JoinTime.idx 索引文件,它們均使用二進制格式存儲。partition.dat 用於保存當前分區下分區表達式最終生成的值,而 minmax_[Column].idx 則負責記錄當前分區下分區字段對應原始數據的最小值和最大值。舉個栗子,假設我們往上面的 user_activity_event 表中插入了 5 條數據,JoinTime 分別 2020-05-05、2020-05-15、2020-05-31、2020-05-03、2020-05-24,顯然這 5 條都會進入到同一個分區,因為 toYYYMM 之后它們的結果是相同的,都是 2020-05,而 partition.dat 中存儲的就是 2020-05,也就是分區表達式最終生成的值;同時還會有一個 minmax_JoinTime.idx 文件,里面存儲的就是 2020-05-03 2020-05-31,也就是分區字段對應的原始數據的最小值和最大值。
在這些分區索引的作用下,進行數據查詢時能夠快速跳過不必要的分區目錄,從而減少最終需要掃描的數據范圍。比如我們存儲了 JoinTime 為 2020-01-01 到 2020-12-31 一整年用戶參加活動的數據,那么 toYYYYMM 之后肯定就會有 12 個分區,然后按照 JoinTime 查找數據的時候,比如要查找 JoinTime 為 2020-06-12 的數據,那么直接去指定的分區(2020-06)中查找即可,也就是我們通過分區機制將查詢范圍限定在 2020-06,其余的 11 個月的數據我們壓根不用看,因此大大減少了查詢的數據量。
10)skp_idx_[IndexName].idx 和 skp_idx_[IndexName].mrk3:如果在建表語句中指定了二級索引(后面會說),則會額外生成相應的二級索引文件與標記文件,它們同樣使用二進制存儲。二級索引在 ClickHouse 中又被稱為跳數索引,目前擁有 minmax、set、ngrambf_v1 和 token_v1 四種類型,這些種類的跳數索引的目的和一級索引都相同,都是為了進一步減少數據的掃描范圍,從而加速整個查詢過程。
數據分區
通過之前的介紹我們已經知道在 MergeTree 數據表中,數據是以分區目錄的形式進行組織的,每個分區的數據獨立分開存儲。借助這種形式,MergeTree 在查詢數據時,可以跳過無用的數據文件,只在最小分區目錄子集中查詢。這里再強調一次,在 ClickHouse 中存在數據分區(partition)和數據分片(shard),但它們是完全不同的概念。數據分區是針對本地數據而言的,相當於是對數據的一種縱向切分,就類似將關系型數據中的一張大高表切成多張個頭沒那么高的子表。

而數據分片則與 ClickHouse 集群相關,我們后面會說,我們目前都是單機的,所以不涉及數據分片。
數據的分區規則
MergeTree 數據表的分區規則由分區 ID 決定,而具體到每個分區對應的 ID 則是由分區鍵的取值決定的。分區鍵支持使用任何一個或一組字段表達式聲明,其業務語義可以是年、月、日或者組織單位等任何一種規則,而針對取值數據的類型不同,分區 ID 的生成邏輯目前擁有四種規則:
1. 不指定分區鍵:如果不使用分區鍵,即不使用 PARTITION BY 聲明任何分區表達式,則分區 ID 默認為 all,所有的數據都會被寫入 all 這個分區2. 使用整型:如果分區鍵的取值為整型(UInt64、Int8 等等都算),且無法轉成日期類型 YYYYMMDD 格式,則直接按照該整型的字符串形式作為分區 ID 的取值3. 使用日期類型:如果分區鍵取值屬於日期類型,或者是能夠轉換為 YYYYMMDD 格式的整型,則使用按照 YYYYMMDD 進行格式化后的字符串形式作為分區 ID 的取值4. 使用其它類型:如果分區鍵取值既不是整型、也不是日期類型,比如 String、Float 等等。則通過 128 位 Hash 算法取其 Hash 值作為分區 ID 的取值
以我們之前的 PARTITION BY toYYYYMM(JoinTime) 為例,當寫入一條 JoinTime 為 2020-09-18 的記錄時,該記錄就會落在分區 ID 為 202009 的分區中;如果是 PARTITION BY JoinTime,那么 JoinTime 為 2020-09-18 的記錄就會落在分區 ID 為 20200918 的分區中;再比如 PARTITION BY age,當寫入一條 age 為 16 的記錄時,該數據就會落在分區 ID 為 16 的分區中;再比如 PARTITION BY length(name),那么 name 為 "古明地覺" 的記錄就會落在分區 ID 為 4 的分區中, name 為 "霧雨魔理沙" 的記錄就會落在分區 ID 為 5 的分區中。
相信這個分區 ID 還是好理解的,但需要注意的是,如果分區字段有多個,那么會按照相同的規則為每個字段都生成一個分區 ID,最后再將這些分區 ID 使用減號合並起來,作為最終的分區 ID。
比如:PARTITION BY (length(UserName), toYYYYMM(JoinTime)),那么 UserName 為 "張三"、JoinTime 為 2020-05-13 的記錄就會落在分區 ID 為 2-202005 的分區中。
分區目錄的命名規則
現在我們已經知道了分區 ID 生成規則,但如果進入數據表所在的磁盤目錄時,會發現 MergeTree 分區目錄的完整物理名稱並不只有分區 ID,在 ID 的后面還跟着一串奇怪的數字,以我們之前創建的 user_activity_event 數據表為例,里面有一個分區,其名稱就叫 202005_1_1_0。前面的 202005 顯然就是分區 ID,那后面的部分代表啥含義呢?
首先對於 MergeTree 而言,其最大的特點就是分區目錄的合並動作(至於怎么合並我們后面再說),而合並邏輯我們從分區目錄的名稱便可窺知一二。
首先分區目錄的命名規則是: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 分鍾)被刪除。
屬於同一個分區的多個目錄,在合並之后會生成一個全新的目錄,目錄中的索引和數據文件也會相應地進行合並。而新目錄的名稱的生成方式遵循如下規則:
PartitionID:不變MinBlockNum:取同一分區內所有目錄中最小的 MinBlockNumMaxBlockNum:取同一分區內所有目錄中最大的 MaxBlockNumLevel:取同一分區內最大 Level 值並加 1
我們舉例說明一下,假設我們之前的 user_activity_event 表是空的,然后我們往里面分 3 批寫入 3 條數據:
INSERT INTO user_activity_event VALUES (1, '張三', '尋找遺失的時間', '市場營銷', 'Medium', 1, '2020-05-01');
INSERT INTO user_activity_event VALUES (1, '李四', '尋找遺失的時間', '市場營銷', 'Medium', 0, '2020-05-02');
INSERT INTO user_activity_event VALUES (1, '王五', '尋找遺失的時間', '市場營銷', 'Medium', 1, '2020-06-01');
根據規則,ClickHouse 會創建 3 個分區目錄,分區目錄的 PartitionID 部分依次為 202005、202005、202006;而對於每個新創建的分區目錄而言,它們的 MinBlockNum 和 MaxBlockNum 都是相等的,並且我們說 MinBlockNum 和 MaxBlockNum 是全局的,從 1 開始自增,所以三個分區目錄的 MinBlockNum 和 MaxBlockNum 依次是 1 1、2 2、3 3;最后是 Level,每個新建的分區目錄的初始 Level 都是0。因此三個分區目錄的最終名稱就是 202005_1_1_0、202005_2_2_0、202006_3_3_0。
之后在某一時刻 MergeTree 的合並動作開始了,那么屬於同一分區的 202005_1_1_0、202005_2_2_0 將會發生合並,得到 202005_1_2_1(MinBlockNum 取最小值、MaxBlockNum 取最大值,Level 去最大值加 1)。

然后我們再插入三條數據(分 3 批寫入),JoinTime 分別為 2020-05-03、2020-06-02、2020-07-01,那么會再創建 3 個分區目錄,分區 ID 分別為 202005、202006、202007:

之后 MergeTree 的合並動作開始,屬於相同分區的目錄開始合並,202005_1_2_1 和 202005_4_4_0 會發生合並,得到 202005_1_4_2;202006_3_3_0 和 202006_5_5_0 發生合並,得到 202006_3_5_1。

如果再寫入數據的話,那么 MergeTree 依舊會發生合並,然后重復和上面的一樣的動作。相信到這里已經明白分區 ID、目錄命名、以及數據合並的相關規則。
但需要注意的是:我們上面顯示的是目錄合並之后的結果,至於舊的分區目錄、也就是合並之前的目錄會依舊保留一段時間,但已不再是激活狀態(active = 0),在數據查詢的時候會被過濾掉。然后 ClickHouse 有一個后台任務會定時掃描(默認 8 分鍾),負責將 active = 0 的目錄從物理磁盤上刪除。
一級索引
MergeTree 的主鍵使用 PRIMARY KEY 定義,主鍵定義之后,MergeTree 會依據 index_granularity 間隔(默認 8192 行)為數據表生成一級索引並保存至 primary.idx 文件中,並按照主鍵進行排序。如果不指定 PAIMARY KEY,那么主鍵默認和排序鍵相同,在這種情況下,索引(primary.idx)和數據(data.bin)會按照完全相同的規則排序。
使用 PRIMARY KEY 定義主鍵和使用 ORDER BY 代替定義主鍵,兩者之間還是有點差別的,這個差別會在 SummingMergeTree 中有所體現,后續介紹 。
稀疏索引
primary.idx 文件內的一級索引采用稀疏索引實現,既然有稀疏索引,那么是不是也有稠密索引呢?答案是還真有,稀疏索引和稠密索引之間的區別如下:

從圖中可以看到,在稠密索引中每一行數據都會對應一行唯一的索引;而在稀疏索引中只有部分數據會對應索引,也就是相鄰索引對應的數據不相鄰,中間會跨越一定行數的數據。那么問題來了,稀疏索引是如何准確定位數據的呢?
假設我要找第 10000 條數據,那么首先 ClickHouse 會進行一次二分查找,找到對應的索引。因為 0 -> 0、1 -> 8192、2 -> 16384,而第 10000 條數據位於 8192 和 16384 之間,那么要找的索引就是 1,於是 ClickHouse 會再通過索引 1 找到第 8192 行數據,然后不斷往后遍歷,最終找到我們要數據。如果熟悉 kafka 的話,kafka 底層存儲消息也是用到的稀疏索引,還是很好理解的。但是問題來了,為什么不用稠密索引呢?如果使用稠密索引的話,那么直接就可以定位到准確的數據,從而減少后續遍歷所帶來的磁盤 IO,可為什么不這么做呢。其實原因很簡單,ClickHouse、kafka 都是應用在大數據場景下,由於數據量本身就很大,那么使用稠密索引帶來的空間占用也會很大。而稀疏索引占空間小,因為不需要那么多行,以默認的索引粒度(8192)為例,MergeTree 只需要 12208 行索引標記就能為 1 億行數據記錄提供索引。雖然后續遍歷會帶來額外的磁盤 IO,但由於是順序 IO,因此效率實際上是不低的,我們知道機械磁盤雖然不擅長隨機讀寫,但順序讀寫還是很快的,SSD 就更不必說了。
最關鍵的是,由於稀疏索引占用空間小,那么可以常駐內存,因此讀取的速度非常快。如果使用稠密索引,那么由於空間占用過大而可能導致無法讀進內存中,因此只能在磁盤上操作,這樣在查找索引的時候也會帶來磁盤 IO。因此綜上所述,使用稀疏索引的性價比相較於稠密索引明顯要更高,因為速度相差不大,但省下了大量的磁盤空間。
注意:雖然我們說索引和數據之間是對應的,但我們知道它們不是直接對應的,我們之前介紹數據表的存儲結構時說過,除了索引文件(primary.idx)和數據文件(data.bin)之外,還有一個標記文件(data.mrk)。標記文件中保存了 data.bin 文件中數據的偏移量信息,並且標記文件(或者說偏移量)與稀疏索引對齊,因此想要通過索引找到具體的數據還需要借助於 data.mrk 中偏移量。
索引粒度
索引粒度是建表的時候,在 SETTINGS 里面指定 index_granularity 控制的,雖然 ClickHouse 提供了自適應粒度大小的特性,但是為了便於理解,我們會使用固定的索引粒度進行介紹(8192)。索引粒度對於 MergeTree 而言是一個非常重要的概念,它就如同一把標尺,會丈量整個數據的長度,並依照刻度對數據進行標注,最終將數據標記成多個間隔的小段。

數據以 index_granularity 的粒度(默認 8192)被標記成多個小的區間,其中每個區間最多 8192 行數據,MergeTree 使用 MarkRange 表示一個具體的區間,並通過 start 和 end 表示其具體的范圍。index_granularity 的名字雖然取了索引二字,但它不單單只作用於一級索引,同時還會影響數據標記文件(data.mrk)和數據文件(data.bin)。因為只有一級索引是無法完成查詢工作的,它需要借助標記文件中的偏移量才能定位數據,所以一級索引和數據標記的間隔粒度(同為 index_granularity 行)相同,彼此對齊,而數據文件也會按照 index_granularity 的間隔粒度生成壓縮數據塊。
索引數據的生成規則
由於是稀疏索引,所以 MergeTree 需要間隔 index_granularity 行數據才會生成一條索引記錄,其索引值會依據聲明的主鍵字段獲取。這里我們創建一張表:
CREATE TABLE hits_v1 (
CounterID Int64,
EventDate Date
) ENGINE = MergeTree()
PRIMARY KEY CounterID -- 也可以不寫,默認和排序鍵保持一致
ORDER BY CounterID
PARTITION BY toYYYYMM(EventDate)
顯然 EventDate 為 2020 年 4 月的數據會被划分到同一個分區目錄中,並且每隔 8192 條數據就會取一次 CounterID 的值(排好序的)作為索引值,寫入 primary.idx 文件進行保存。

例如第 0 行(8192 * 0)的 CounterID 取值為 57,第 8192 行(8192 * 1)的 CounterID 取值為 1635,第 16384 行(8192 * 2)的 CounterID 取值為 3266,最終索引數據將會是 5716353266......。
可以看到 MergeTree 對於稀疏索引的存儲是非常緊湊的,索引值前后相連,按照主鍵字段順序緊密地排列在一起。並且不僅是這里,ClickHouse 中很多數據結構都被設計的非常緊湊,比如使用位讀取替代專門的標志位或狀態碼(假設 32 位整型存儲的數據最多占用 20 個位,那么就可以只用 20 個位表示數據,然后剩余的位用來表示狀態碼),不浪費任何一個字節。所以 ClickHouse 性能出眾不是沒有原因的,每一步都做足了優化。
如果使用多個主鍵,例如 ORDER BY (CounterID, EventDate),則每間隔 8192 行會同時取 CounterID 和 EventDate 兩列的值作為索引值。

索引的查詢過程
在說完索引的一些概念之后,接下來說明索引具體是如何工作的。首先我們需要了解什么是 MarkRange,MarkRange 在 ClickHouse 中是用於定義標記區間的對象。MergeTree 按照 index_granularity 的間隔粒度,將一段完整的數據划分成了多個小的間隔數據段,一個具體的數據段就是一個 MarkRange,並與索引編號對應,使用start 和 end 兩個屬性表示其范圍。通過 start 和 end 對應的索引編號的取值,即可得到它所對應的數值區間,而數值區別表示了此 MarkRange 的數據范圍。
如果只是這么干巴巴的介紹,可能會有些抽象,下面用一份示例數據來說明一下。假如現在有一份測試數據,共 192 行記錄,其中主鍵 ID 為 String 類型,取值從 A000 開始,后面依次為 A001、A002、......,直到 A192 為止。MergeTree 的索引粒度 index_granularity 為 3,根據索引的生成規則,那么 primary.idx 文件的索引如下所示:

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

在引入了數值區間的概念之后,對於索引數據的查詢過程就很好解釋了,索引查詢其實就是兩個區間的交集判斷。其中一個區間是由基於主鍵的查詢條件轉換而來的條件區間;另一個區間就是上面說的與 MarkRange 對應的數值區間。
所以整個查詢可以分為三步:
1)生成查詢區間:首先將查詢條件轉換為區間,即使是單個值也會轉換為區間的形式,舉個栗子:
WHERE ID = 'A003' -> ['A003', 'A003']
WHERE ID > 'A012' -> ('A012', +inf]
WHERE ID < 'A185' -> [-inf, 'A185')
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 聚在一起,合並它們的范圍。
光說不好理解,我們畫一張圖,來展示一下上面的幾個步驟,還以上面的測試數據為例,查詢條件為 ID = 'A003'。

MergeTree 通過遞歸的形式持續向下拆分區間,最終將 MarkRange 定位到最細的粒度,以便在后續讀取數據的時候,能夠最小化數據的掃描范圍。以上圖為例,當查詢條件為 ID = 'A003' 的時候,最終只需要讀取 [A000, A003] 和 [A003, A006] 兩個區間的數據,它們對應 MarkRange(start:0, end:2) 范圍,而其它無用區間都被裁剪掉了。由於 MarkRange 轉換的數值區間是閉區間,所以會額外匹配到臨近的一個區間。
二級索引
除了一級索引之外,MergeTree 同樣支持二級索引,二級索引又稱跳數索引,由數據的聚合信息構建而成。根據索引類型的不同,其聚合信息的內容也不同,當然跳數索引的作用和一級索引是一樣的,也是為了查詢時減少數據的掃描范圍。
跳數索引需要在 CREATE 語句內定義,它支持使用元組和表達式的形式聲明,其完整的定義語法如下所示:
CREATE TABLE table_name (
column1 type,
column2 type,
......
INDEX index_name expr TYPE index_type(...) GRANULARITY granularity
)
與一級索引一樣,如果在建表語句中聲明了跳數索引,則會額外生成相應的索引文件和標記文件(skp_idx_[Column].idx 與 skp_idx_[Column].idx)。
granularity 和 index_granularity 的關系
不同的跳數索引之間,除了它們自身獨有的參數之外,還都共同擁有 granularity 參數。初次接觸時,很容易將 granularity 和 index_granularity 的概念弄混淆,對於跳數索引而言,index_granularity 定義了數據的粒度,而 granularity 定義了聚合信息匯總的粒度。換言之,granularity 定義了一行跳數索引能夠跳過多少個 index_granularity 區間的數據。
要解釋清除 granularity 的作用,就要成跳數索引的生成規則說起,其規則大致是如下:首先按照 index_granularity 粒度間隔將數據划分成 n 段,總共有 [0, n - 1] 個區間(n = totol_rows / index_granularity,向上取整);接着根據索引定義時聲明的表達式,從 0 區間開始依次按照 index_granularity 粒度從數據中獲取聚合信息,每次向前移動一步,聚合信息聚合信息逐步累加。最后當移動 granularity 次區間時,則匯總並聲稱一行跳數索引數據。
以 minmax 索引為例,它的聚合信息是在一個 index_granularity 區間內數據的最小和最大極值。

以上圖為例,假設 index_granularity = 8192 且 granularity = 3,則數據會按照 index_granularity 划分為 n 等份,MergeTree 從第 0 段分區開始,依次獲取聚合信息。當獲取到第 3 個分區時(granularity = 3),則會匯總並生成第一行 minmax 索引(前 3 段 minmax 極值匯總后取值為 [1, 9])。
跳數索引的類型
目前 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(100) 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()............
接下來就借助上面的栗子來介紹這幾種跳數索引的用法:
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(100) 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 中數據按列存儲,在老版本中每個列也是獨立存儲的,也就是每個列字段都擁有一個與之對應的 .bin 文件;但是在新版本中這些列字段對應的 .bin 文件合並在一起了,只有一個 data.bin。正是 data.bin 文件,最終承載着物理存儲。那么按列存儲有什么好處呢?首先可以更好的進行數據壓縮,因為相同類型的數據放在一起,對壓縮更加友好;其實是能夠最小化數據掃描的范圍。
而對應到存儲的具體實現方面,MergeTree 也並不是一股腦地將數據直接寫入 data.bin 文件,而是經過了一番精心設計:首先數據是經過壓縮的,目前支持 LZ4、ZSTD、Multiple 和 Delta 幾種算法,默認使用 LZ4 算法;其次,數據會實現按照 ORDER BY 的聲明排序;最后數據以壓縮數據塊的形式被組織並寫入 data.bin 文件。
壓縮數據塊就好比一本書的文件段落,是組織文字的基本單元,這個概念非常重要,需要深入說明一下。
壓縮數據塊
一個壓縮數據塊由頭信息和壓縮數據兩部分組成,頭信息固定使用 9 位字節表示,具體由 1 個 UInt8(1 字節)和 2 個 UInt32(4 字節)組成,分別代表使用的壓縮算法類型、壓縮后的數據大小、壓縮前的數據大小。所以雖然存儲的是壓縮后的數據,但是在頭信息中將壓縮前的數據大小也記錄了下來。我們先創建一張表,然后寫入一些數據測試一下。

然后用 Python 寫入寫入 10 萬條數據:
from faker import Faker
from clickhouse_driver import Client
f = Faker("ja_JP") # 生成 1 萬條測試數據,Age 均為 26
data = [str((f"{i}", f.name(), 26, f.address())) for i in range(10000)]
sql = f"INSERT INTO people VALUES {', '.join(data)}"
client = Client(host="47.94.174.89", port=9000)
client.execute(sql)
顯然會創建一個分區,根據我們之前介紹的規則,分區對應的分區目錄為 26_1_1_0。但是注意,這里要使用 1 個 INSERT 語句,否則的話數據會分多批導入,這樣的就會創建多個分區目錄:26_1_1_0、26_2_2_0、26_3_3_0,......。而我們要的是合並后的結果,雖然屬於相同分區的分區目錄之后會自動合並,但是需要等一段時間,因此這里我們就一批次直接導入。
然后我們可以使用 clickhouse-compressor 查看數據大小:

其中每一行代表一個數據壓縮塊的頭信息,分別表示該壓縮塊中 "壓縮前的數據大小" 和 "壓縮后的數據大小",並且我們看到總共有 4 個壓縮數據塊。為什么有 4 個,原因是我們只有 4 個列,所以 data.bin 里面存儲的就是對 4 個列壓縮之后的結果,這是顯而易見的。像第一列就是 ID,由於我們的 ID 是順序自增的,幾乎沒有什么重復,所以壓縮之后和壓縮之前的的大小差別不大;但是后面幾列的數據壓縮之后就小很多了,尤其是第三行 Age 字段,因為每個值都是一個 UInt8,1 萬條數據所以占 10000 個字節,但由於所有值都是 26,數據全部一樣,而數據越相似壓縮比越高,因此壓縮之后變成了 59 個字節。至於壓縮背后的算法我們這里不細究了, 只需要知道數據之間越相似、或者說重復率越高,壓縮之后的數據就越小。
但是注意:我們這里的數據量比較少,每一列數據的大小不是很大,因此每一列只用一個壓縮數據塊即可存儲。如果數據量再多一些,一個壓縮數據塊存儲不下,那么就會對應多個壓縮數據塊。
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
......
示意圖如下:

其中每一行數據都代表者一個壓縮數據塊的頭信息,其分別表示該壓縮塊中未壓縮數據和壓縮數據的大小,注意:打印信息和物理存儲的順序剛好相反。
但是我們需要注意,之前我們說在早期的 ClickHouse 中每一列數據各自對應一個 .bin 文件,但是在新版本的時候合並在一起了,這是沒錯的,但前提是數據量不大。如果數據量大到一定程度,那么每一列的數據就會分開存儲了,也就是各自對應一個 .bin 文件和一個 .mrk 文件,和 data.bin、data.mrk 的作用是完全等價的, 只不過一個是各列分開存儲、一個是所有列合並在一起存儲。
我們再執行一次上面的 Python 代碼,但是將生成的數據改成 10 萬條:

顯然此時會創建一個 26_2_2_0 分區目錄,里面就沒有 data.bin 了,取而代之的時候 ID.bin、Name.bin、Age.bin、Place.bin,同理 .mrk 文件也是如此。當然除了 .mrk 之外還有一個 .mrk2,它和 .mrk3 作用相似,當使用了自適應大小的索引間隔,會出現此標記文件。
然后我們再來對每一個列對應的 .bin 文件查看一下大小:

如果每一列都對應單獨的 .bin 文件的話,那么每一列都有自己的壓縮數據塊:塊 0、塊1、塊2、......、塊 n,這和 data.bin 原理相同,只不過 data.bin 在數據量沒達到閾值的時候會將所有列對應的壓縮數據塊存儲在一起:列 1 塊 0、列 2 塊 0、....、列 N 塊 0、列 1 塊 1、列 2 塊 1、......、列 N 塊 1、列 1 塊 2、列 2 塊 2、......、列 N 塊 2、列 1 塊 3、.......。
並且此時每個壓縮數據塊的大小,都被嚴格控制在 64KB 到 1MB 之間,其上下限分別由 min_compress_block_size(默認 65536)和 min_compress_block_size(默認值為 1048576)決定。比如 Age.bin,因為 10 萬條數據所以 10 萬個字節,第一個壓縮數據塊的大小就是 65536 字節,被壓縮成了 276 字節,至於剩余的 34464 字節是因為只剩下這么多了,然后被壓縮成了 155 字節。至於其它的列也是同理,只不過列不相同,所以壓縮數據塊的最終大小也不相同。
那么壓縮數據塊的大小是怎么計算出來的呢?首先壓縮數據塊的最終大小是和索引粒度(index_granularity)相關的,MergeTree 在具體的數據寫入過程中,會依照索引粒度按批次獲取數據並寫入(由於索引粒度默認是 8192,所以每批次會獲取 8192 行)。如果把一批未壓縮的數據的大小設為 size,則整個數據的寫入過程遵循如下規則:
- 1)單個批次數據 size < 64KB:如果單個批次數據小於 64KB,則繼續獲取下一批數據,直至累積到 size 大於等於 64KB 時,生成下一個壓縮數據塊。
- 2)單個批次數據 64KB <= size <= 1MB:如果單個批次數據在 64KB 到 1MB 之間,則直接生成下一個壓縮數據塊。
- 3)單個批次數據 size > 1MB:如果單個批次數據直接超過 1MB,則首先按照 1MB 大小截斷並生成下一個壓縮數據塊,然后剩余數據繼續依照上述規則執行。因此,此時會出現一批次數據生成多個壓縮數據塊的情況。
因此說白了就是 MergeTree 在獲取數據的時候會依照索引粒度按批次獲取數據,所以默認情況下就是每批獲取 8192 行,然后一批一批獲取。而如果設當前批次的數據大小為 size,那么會根據 size 的不同,走上面三個分支中的一個。我們以 Age.bin 為例,每批取 8912 行顯然就是 8192 個字節,也就是 8 KB,小於 64 KB,因此會讀取下一批。而當讀到第 8 批時,發現數據加起來達到了 64 KB,因此生成一個壓縮數據塊,此時還剩下 34464(100000 減去 8 * 8192)、不到 34 KB,因此剩余的部分直接作為一個壓縮數據塊;當然其它列也很好分析,和 Age 是類似的,只不過 Age 最簡單,每個值占 1 字節。而其它列因為存儲的內容大小不固定(有的長有的短),所以不是很好計算,但存儲的邏輯是不變的。

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

所以壓縮數據塊是重點,每一次都是按塊寫入、同時也會按塊讀取。另外當數據量沒有超過閾值的時候,所有的列會放在一起存儲(data.bin),數據量超過閾值、那么每一列會單獨存儲。
數據標記文件(.mrk)
如果把 MergeTree 比作一本書,primary.idx(一級索引)就類似書的一級章節目錄,但是這個目錄具體對應書中( .bin 文件)的哪一頁呢?

顯然每個目錄后面的頁碼會告訴你,該目錄位於書中的哪一頁,而頁碼就相當於數據標記文件(.mrk)中記錄的偏移量(數據標記),標記索引在數據文件中的具體位置。因此對於數據標記文件而言,它記錄了兩點關鍵信息:
1. 與一級章節目錄對應的頁碼信息2. 一段文字在某一頁中的起始位置信息
這樣一來,通過數據標記文件可以很快地從一本書中立即翻到關注內容所在的那一頁,並知道從第幾行開始閱讀。
數據標記的生成規則
數據標記作為銜接一級索引和數據的橋梁,像極了書簽,而且書本總每一個章節目錄都有各自的書簽。

從圖中我們可以看到,數據標記和索引區間是對齊的,均按照 index_granularity 的粒度間隔,如此一來只需要簡單通過索引下標編號即可直接找到對應的數據標記。並且為了能夠與數據銜接,.bin 文件和數據標記文件是一一對應的,即每一個 [Column].bin 文件都有一個 [Column].mrk 數據標記文件與之對應,用於記錄數據在 .bin 文件中的偏移量信息。
一行標記數據使用一個元組表示,元組內包含兩個整型數值的偏移量信息,分別表示在此段數據區間內:
1. 對應 .bin 壓縮文件中,壓縮數據塊的起始偏移量2. 將該數據塊解壓縮后,未壓縮數據的起始偏移量
我們以之前 Age.bin 為例:

每次按批讀取 8192 行,因為一個 UInt 8 一字節,所以每次讀取 8192 個字節,在讀取 8 批之后會進行壓縮得到一個壓縮數據塊。生成的第一個壓縮數據塊為 276 個字節,然后未壓縮數據還剩下 34464 字節,小於 64KB,於是直接生成 155 字節的第二個壓縮數據塊。因此在 Age.bin 中兩個偏移量的對應關系如下:

每一行標記數據都標記了一個片段的數據(默認 8192 行)在 .bin 壓縮文件中的讀取位置信息,因為 Age 占 1 字節,所以每次讀取 8912 行相當於每次讀取 8192 個字節,因此 "未壓縮數據的起始偏移量" 就是 0、8192、16384、24576、......。但是需要注意圖中的 57344,它表示第 8 批未壓縮數據的起始偏移量,因為此時已經達到了 64KB,所以會生成一個壓縮數據塊,於是接下來讀取第 9 批未壓縮數據的時候就會對應新的壓縮數據塊,因此起始偏移量會重置為 0,而不是 65536。
我們這里是 Age 字段為例,至於其它列也是同理,只不過由於每一行字符串的長度不同,所以我們很難計算每次讀 8192 行的話會讀多少個字節,但它們的原理是很明顯都是一樣的。
然后是 "壓縮數據塊的起始偏移量",因為讀了 8 批才生成了第一個壓縮數據塊,因此前 8 行都是 0。然后由於第一個壓縮數據塊的大小是 276,因此第 9 行、即索引為 8 的位置,存儲的值就是 276,表示第二個壓縮數據塊的起始偏移量。
以上就是標記文件的存儲原理,但是標記文件和一級索引不同,它不能常駐內存,而是使用 LRU(最近最少使用)緩存策略加快其取用速度。
數據標記的工作方式
MergeTree 在讀取數據時,必須通過標記文件中的偏移量才能找到所需要的數據,因此整個查找過程可以分為讀取壓縮數據塊和讀取數據兩個步驟。為了便於解釋,這里繼續使用剛才的 people 表的 Age 字段進行說明,因為 Age 字段的大小固定,最好分析,至於其它字段的查找過程與之完全類似。

1)讀取壓縮數據塊
在查詢某一列數據時,MergeTree 無需加載整個 .bin 文件,而是可以根據需要只加載特定的壓縮數據塊,而這項特性則要借助 .mrk 文件中所保存的偏移量信息。
從圖中可以看到,上下相鄰的兩個壓縮數據塊的起始偏移量,構成了與當前標記對應的壓縮數據塊的偏移量區間,說人話就是通過第 n 個壓縮數據塊的起始偏移量和第 n + 1 個壓縮數據塊的起始偏移量,可以獲取第 n 個壓縮數據塊。具體做法就是從當前偏移量開始向下尋找(當前塊的起始位置 start),直到找到不同的偏移量位置(當前塊的下一個塊的起始位置 next_start),此時 start 到 next_start 便是當前塊對應的偏移量區間,比如圖中的 0 到 276。通過偏移量區間,即可獲得當前的壓縮塊。
2)讀取數據
在讀取解壓后的數據時,MergeTree 並不需要一次性掃描整段解壓數據,它可以根據需要,以 index_granularity 的粒度加載特定的一小段,而為了實現這種特性,需要借助標記文件中保存的解壓數據塊的偏移量。
通過偏移量,ClickHouse 可以按需讀取數據,比如通過 [0, 8192] 即可讀取壓縮數據塊 0 中第一個數據片段對應的解壓數據。
分區、索引、標記和壓縮數據的協同總結
分區、索引、標記和壓縮數據,就類似於 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 家族中其它常見表引擎的使用方法,以及它們都有哪些特點、使用方式是什么。
