楔子
目前在 ClickHouse 中,按照特點可以將表引擎分為 6 個系列,分別是合並樹、外部存儲、內存、文件、接口和其它,每一個系列的表引擎都有獨自的特點和使用場景。而其中最核心的當屬 MergeTree 系列,因為它們擁有最為強大的性能和最為廣泛的使用場景。
經過之前的介紹,我們知道 MergeTree 有兩種含義:
1. 表示合並樹表引擎家族2. 表示合並樹表引擎家族中最基礎的 MergeTree 表引擎
而在整個家族中,除了基礎表引擎 MergeTree 之外,常用的表引擎還有 ReplacingMergeTree、SummingMergeTree、AggregatingMergeTree、CollapsingMergeTree、VersionedCollapsingMergeTree。從名字也能看出來,每一種合並樹的變種,在繼承了 MergeTree 的基礎能力后,又增加了獨有的特性,而這些獨有的特性都是在觸發合並的過程中被激活的。
MergeTree
MergeTree 作為家族系列最基礎的表引擎,提供了數據分區、一級索引和二級索引等功能,至於它們的運行機理我們之前已經介紹過了。這里我們來介紹一下 MergeTree 的另外兩個功能:數據 TTL 和 存儲策略。
數據 TTL
TTL 即 Time To Live,表示數據的存活時間,而在 MergeTree 中可以為某個列字段或整張表設置 TTL。當時間到達時,如果是列字段級別的 TTL,則會刪除這一列的數據;如果是整張表級別的 TTL,則會刪除整張表的數據;如果同時設置,則會以先到期的為主。
無論是列級別還是表級別的 TTL,都需要依托某個 DateTime 或 Date 類型的字段,通過對這個時間字段的 INTERVAL 操作來表述 TTL 的過期時間,下面我們看一下設置的方式。
1)列級別設置 TTL
如果想要設置列級別的 TTL,則需要在定義表字段的時候為它們聲明 TTL 表達式,主鍵字段不能被聲明 TTL,舉個栗子:
CREATE TABLE ttl_table_v1 (
id String,
create_time DateTime,
code String TTL create_time + INTERVAL 10 SECOND,
type UInt8 TTL create_time + INTERVAL 10 SECOND
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(create_time)
ORDER BY id
其中 create_time 是日期類型,列字段 code 和 type 均被設置了 TTL,它們的存活時間在 create_time 取值的基礎之上向后延續 10 秒。假設某一條數據的 create_time 的值為 dt,那么當系統時間超過了 dt + 10 秒,該條數據的 code、type 就會過期。
除了 SECOND 之外,還有 MINUTE、HOUR、DAY、WEEK、MONTH、QUARTER 和 YEAR。
現在寫入兩條測試數據,其中第一條的 create_time 取當前的系統時間,第二條的 create_time 比第一條多 5 分鍾。
INSERT INTO TABLE ttl_table_v1
VALUES ('A000', now(), 'C1', 1),
('A000', now() + INTERVAL 5 MINUTE, 'C1', 1)
然后馬上進行查詢(手速要快),然后等 10 秒過后(從寫入數據的那一刻起),再查詢一次。

再次查詢 ttl_table_v1 會看到,由於第一條數據滿足 TTL 過期時間(當前系統時間 >= create_time + 10 秒),它們的 code 和 type 會被還原為數據類型的零值。
如果想要修改列字段的 TTL,或者為已有字段添加 TTL(不可以是主鍵字段),都可以使用 ALTER 語句,舉個栗子:
ALTER TABLE ttl_table_v1 MODIFY COLUMN code String TTL create_time + INTERVAL 1 DAY
2)表級別設置 TTL
如果想為整張表設置 TTL,需要在 MergeTree 的表參數中增加 TTL 表達式,舉個栗子:
CREATE TABLE ttl_table_v2 (
id String,
create_time DateTime,
code String TTL create_time + INTERVAL 1 MINUTE,
type UInt8
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(create_time)
ORDER BY create_time
TTL create_time + INTERVAL 1 DAY
ttl_table_v2 整張表被設置了 TTL,當觸發 TTL 清理時,那些滿足過期時間的數據行將被整行刪除。同樣,表級別的 TTL 也支持修改,方法如下:
ALTER TABLE ttl_table_v2 MODIFY TTL create_time INTERVAL + 3 DAY
另外表級別的 TTL 也不支持取消。
3)TTL 運行機理
在了解了列級別和表級別 TTL 的運行機理后,現在簡單聊一聊 TTL 的運行機理。如果一張 MergeTree 表被設置了 TTL 表達式,那么在寫入數據時會以分區為單位,在每個分區目錄內生成 ttl.txt 文件。以上面的 ttl_table_v2 為例,它被設置了列級別的 TTL,也被設置了表級別的 TTL,那么在寫入數據之后,它的每個分區目錄內都會生成 ttl.txt 文件。

我們查看 ttl.txt 的內容發現,原來 MergeTree 是通過一串 JSON 保存了 TTL 的相關信息,其中:
columns 用於保存列級別的 TTL 信息table 用於表級別的 TTL 信息min 和 max 則保存了當前數據分區內,TTL 指定日期字段的最小值和最大值分別與 INTERVAL 表達式計算后的時間戳
如果將 table 屬性中的 min 和 max 時間戳格式化,並分別與 create_time 最小值與最大值進行對比:

則能夠印證,ttl.txt 中記錄的極值區間恰好等於當前分區內 create_time 的最小值、最大值加 1 天(86400 秒),與 TTL 表達式 create_time + INTERVAL 1 DAY 相符合,同理 ttl_min 和 ttl_max 分別減去一天即可得到 create_time 這一列的最小值和最大值。
在知道了 TTL 信息的記錄方式之后,再來看看它的處理邏輯。
1. MergeTree 以分區目錄為單位,通過 ttl.txt 文件記錄過期時間,並將其作為后續的判斷依據2. 每當寫入一批數據時,都會基於 INTERVAL 表達式的計算結果為這個分區生成 ttl.txt 文件3. 只有 MergeTree 在對屬於相同分區的多個分區目錄進行合並時,才會觸發刪除 TTL 過期數據的邏輯4. 在選擇刪除的分區時,會使用貪婪算法,它的算法規則是盡可能找到會最早過期的、同時年紀又是最老的分區(合並次數更多,MaxBlockNum 更大的)5. 如果一個分區內某一列數據因為 TTL 到期全部被刪除了,那么在合並之后生成的新分區目錄中,將不會再包含該列對應的 bin 文件和 mrk 文件,如果列數據分開存儲的話
TTL 默認的合並頻率由 MergeTree 的 merge_with_ttl_timeout 參數所控制,默認為 86400 秒、也就是 1 天。它維護的是一個專有的 TTL 任務隊列,有別於 MergeTree 的常規合並任務,這個值如果設置的過小,可能會帶來性能損耗。當然除了被動觸發 TTL 合並外,也可以使用 optimize 強制觸發合並:
optimize TABLE table_name PARTITION 分區名:觸發一個分區合並optimize TABLE table_name FINAL:觸發所有分區合並
最后,ClickHouse 雖然沒有提供刪除 TTL 的聲明方法,但是提供了控制 TTL 合並任務的啟停方法。
SYSTEM STOP/START TTL MERGES:控制全局 MergeTree 表啟停SYSTEM STOP/START TTL MERGES table_name:控制指定 MergeTree 啟停
多路徑存儲策略
在 ClickHouse 19.15 版本之前,MergeTree 只支持單路徑存儲,所有的數據都會被寫入 config.xml 配置中的 path 指定的路徑下。

即使服務器掛載了多塊磁盤,也無法有效利用這些存儲空間。為了解決這個痛點,從 19.15 版本開始,MergeTree 實現了自定義存儲策略的功能,支持以數據分區為最小移動單元,將分區目錄寫入多塊磁盤目錄。
而根據配置策略的不同,目前大致有三類存儲策略。
- 默認策略:MergeTree 原本的存儲策略,無需任何配置,所有分區會自動保存到 config.xml 配置中 path 指定的路徑下。
- JBOD 策略:這種策略適合服務器掛載了多塊磁盤,但沒有做 RAID 的場景。JBOD 的全稱是 Just a Bunch of Disks,它是一種輪詢策略,每執行一次 INSERT 或者 MERGE,所產生的新分區會輪詢寫入各個磁盤。這種策略的效果類似於 RAID 0,可以降低單塊磁盤的負載,在一定條件下能夠增加數據並行讀寫的性能。如果單塊磁盤發生故障,則會丟掉應用 JBOD 策略寫入的這部分數據,但這又會造成數據丟失,因此我們還需要利用副本機制來保障數據的可靠性(副本機制后面說)。
- HOT/COLD 策略:這種策略適合服務器掛載了不同類型磁盤的場景,將存儲磁盤分為 HOT 和 COLD 兩類區域。HOT 區域使用 SSD 這類高性能存儲媒介,注重存儲性能;COLD 區域則使用 HDD 這類高容量存儲媒介,注重存儲經濟性。數據在寫入 MergeTree 之初,會在 HOT 區域創建分區目錄用於保存數據,當分區數據大小累積到閾值時,數據會自動移動到 COLD 區域。而在每個區域的內部,也支持定義多個磁盤,所以在單個區域的寫入過程中,也能應用 JBOD 策略。
存儲配置需要預先定義在 config.xml 配置文件中,由 storage_configuration 表示,而 storage_configuration 之下又分為 disks 和 policies 兩組標簽,分別表示磁盤與存儲策略。格式如下:
<storage_configuration>
<disks>
<disk_name_a> <!-- 自定義磁盤名稱 -->
<path>/ch/data1</path>
<keep_free_space_bytes>1073741824</keep_free_space_bytes>
</disk_name_a>
<disk_name_b> <!-- 自定義磁盤名稱 -->
<path>/ch/data2</path>
<keep_free_space_bytes>1073741824</keep_free_space_bytes>
</disk_name_b>
</disks>
<policies>
<policie_name_a> <!-- 自定義策略名稱 -->
<volumes>
<volume_name_a> <!-- 自定義卷名稱 -->
<disk>disk_name_a</disk>
<disk>disk_name_b</disk>
<max_data_part_size_bytes>disk_name_a</max_data_part_size_bytes>
</volume_name_a>
</volumes>
<move_factor>0.2</move_factor>
</policie_name_a>
<policie_name_b>
</policie_name_b>
</policies>
</storage_configuration>
解釋一下里面標簽的含義,首先是 disks 標簽:
<disk_name_*>,必填項,必須全局唯一,表示磁盤的自定義名稱,顯然可以定義多塊磁盤<path>,必填項,用於指定磁盤路徑<keep_free_space_bytes>:選填項,以字節為單位,用於定義磁盤的預留空間
然后是 policies 標簽,在 policies 標簽里面需要引用已經定義的 disks 磁盤,並且同樣支持定義多個策略:
<policie_name_*>,必填項,必須全局唯一,表示策略的自定義名稱<volume_name_*>,必須填,比如全局唯一,表示卷的自定義名稱<disk>,必填項,用於關聯 <disks> 配置內的磁盤,可以聲明多個 disk,MergeTree 會按照聲明的順序選擇 disk<max_data_part_size_bytes>,選填項,以字節為單位,表示在這個卷的單個 disk 磁盤中,一個數據分區的最大分區閾值。如果當前分區的數據大小超過閾值,則之后的分區會寫入下一個 disk 磁盤<move_factor>,選填項,默認為 0.1,如果當前卷的可用空間小於 factor 因子,並且定義了多個卷,則數據會自動向下一個卷移動
1. JBOD 策略演示
注意:storage_configuration 在 config.xml 里面是沒有的,我們需要手動加進去。
<storage_configuration>
<disks>
<disk_hot1> <!-- 自定義磁盤名稱 -->
<path>/root/hotdata1/</path>
</disk_hot1>
<disk_hot2> <!-- 自定義磁盤名稱 -->
<path>/root/hotdata2/</path>
</disk_hot2>
<disk_cold> <!-- 自定義磁盤名稱 -->
<path>/root/colddata/</path>
<keep_free_space_bytes>1073741824</keep_free_space_bytes>
</disk_cold>
</disks>
<!-- 配置存儲策略,在 volumes 卷下面引用上面定義的兩塊磁盤,組成磁盤組 -->
<policies>
<jbod_policies> <!-- 自定義策略名稱 -->
<volumes>
<jbod> <!-- 自定義卷名稱 -->
<disk>disk_hot1</disk>
<disk>disk_hot2</disk>
</jbod>
</volumes>
</jbod_policies>
</policies>
</storage_configuration>
至此一個支持 JBOD 的存儲策略就配置好了,但在正式應用之前我們還需要做一些准備工作。首先我們要將目錄創建好,然后將路徑授權,讓 ClickHouse 用戶擁有相應的讀寫權限:
[root@satori ~]# mkdir hotdata1 hotdata2 colddata
[root@satori ~]# sudo chown clickhouse:clickhouse -R /root
由於存儲配置不支持動態更新,為了使配置生效,還需要重啟 ClickHouse 服務,直接 clickhouse restart 即可。重啟之后可以查詢系統表來驗證配置是否生效:

通過 system.disks 系統表可以看到剛才聲明的三塊磁盤配置已經生效,接着驗證配置策略:

通過 system.storage_policies 系統表可以看到剛才配置的存儲策略也已經生效了,現在創建一張 MergeTree 表,用於測試 jbod_policies 存儲策略的效果。

在定義 MergeTree 數據表時,可以使用 storage_policy 配置項指定剛才的 jbod_policies 存儲策略,注意:存儲策略一旦設置,就不能再修改了。下面來測試一下效果:

可以看到第一塊分區寫入了第一塊磁盤 disk_hot1,然后我們再來寫入第二批數據,此時會創建第二個分區目錄:

插入數據之后再次查看分區系統表,可以看到第二個分區寫入了第二塊磁盤。最后再觸發一次分區合並動作,生成一個合並后的新分區目錄:

還是查詢分區系統表,可以看到合並后生成的 all_1_2_1 分區再次寫入了第一塊磁盤 disk_hot1。
相信此時應該解釋清除 JBOD 策略的工作方式了,在這個策略中,由多個磁盤組成一個磁盤組,即 volume 卷。每當生成一個新數據分區的時候,分區目錄會依照 volume 卷中磁盤定義的順序,依次輪詢並寫入各個磁盤。
2. HOT/COLD 策略演示
現在介紹 HOT/COLD 策略的使用方法,我們將 JBOD 策略對應的配置原封不動的拷貝過來,然后在里面加一個新策略。
<storage_configuration>
<disks>
<disk_hot1>
<path>/root/hotdata1/</path>
</disk_hot1>
<disk_hot2>
<path>/root/hotdata2/</path>
</disk_hot2>
<disk_cold>
<path>/root/colddata/</path>
<keep_free_space_bytes>1073741824</keep_free_space_bytes>
</disk_cold>
</disks>
<policies>
<jbod_policies>
<volumes>
<jbod>
<disk>disk_hot1</disk>
<disk>disk_hot2</disk>
</jbod>
</volumes>
</jbod_policies>
<!-- 添加新策略 -->
<moving_from_hot_to_cold> <!-- 自定義策略名稱 -->
<volumes>
<hot> <!-- 自定義名稱,hot 區域磁盤 -->
<disk>disk_hot1</disk>
<max_data_part_size_bytes>1048576</max_data_part_size_bytes>
</hot>
<cold> <!-- 自定義名稱,cold 區域磁盤 -->
<disk>disk_cold</disk>
</cold>
</volumes>
<move_factor>0.2</move_factor>
</moving_from_hot_to_cold>
</policies>
</storage_configuration>
用新配置將之前的 JBOD 配置給替換掉,或者直接將我們新加的部分添加到配置文件中即可,然后重啟 ClickHouse。

可以看到新配置的存儲策略已經生效了,moving_from_hot_to_cold 存儲策略擁有 hot 和 cold 兩個磁盤卷,在每個卷下各擁有一塊磁盤。注意:hot 磁盤卷的 max_data_part_size 列顯示的值為 1MB,這個值的含義為,在這個磁盤卷下,如果一個分區的大小超過 1MB,則它需要被移動到緊鄰的下一個磁盤。當然這里為了演示效果,實際工作中不會配置的這么小的。
那么下面還是創建一張 MergeTree 表,用於測試 moving_from_hot_to_cold 存儲策略的效果。
CREATE TABLE hot_cold_table (id UInt64)
ENGINE = MergeTree()
ORDER BY id
SETTINGS storage_policy = 'moving_from_hot_to_cold'
在定義 MergeTree 時,使用 storage_policy 配置項指定剛才定義的存儲策略,當然存儲策略一旦定義就不能再修改了。那么接下來就來測試一下效果,首先寫入第一批數據(小於 1MB),創建一個分區目錄:

查詢分區系統表,可以看到第一個分區寫入了 hot 卷。那么下面就來寫入第二批數據,數據大小和上次一樣,當然此時會創建第二個分區目錄:

這是我們看到第二個分區仍然寫入了 hot 卷,因為 hot 卷的 max_data_part_size 是 1MB,而每次寫入數據的大小沒有超過 1MB,所以自然都保存到了該磁盤下。那么接下來觸發一次分區的合並動作,會生成一個新的分區目錄。

當兩個分區合並之后,所創建的新分區的大小超過了 1MB,所以它會被寫入 cold 卷。當然一次性寫入大於 1MB 的數據,分區也會被寫入 cold 卷。
至此我們算是明白了 HOT/COLD 策略的工作方式了,在這個策略中,由多個磁盤卷(volume 卷)組成一個 volume 組。每當生成一個新數據分區的時候,按照閾值大小(max_data_part_size),分區目錄會依照 volume 組中磁盤定義的順序,依次輪詢並寫入各個卷下的磁盤。
另外,雖然 MergeTree 的存儲策略是不能修改的,但分區目錄卻支持移動,例如將某個分區移動至當前存儲策略中 volume 卷下的其它 disk 磁盤:
ALTER TABLE hot_cold_table MOVE PART 'all_1_2_1' TO DISK 'disk_hot1'
或者將某個分區移動至當前存儲策略中其它的 volume 卷:
ALTER TABLE hot_cold_table MOVE PART 'all_1_2_1' TO VOLUME 'cold'

ReplacingMergeTree
雖然 MergeTree 擁有主鍵,但是它的主鍵卻沒有唯一的約束,這意味着即便多行數據的主鍵相同,依舊能夠正確寫入。而在某些場合我們不希望數據表中有重復的數據,那么這個時候 ReplacingMergeTree 就登場了,它就是為數據去重而設計的,可以在合並分區時刪除重復的數據。因此它的出現,確實在一定程度上解決了重復數據的問題,啊嘞嘞,為啥是一定程度?先賣個關子。
創建一張 ReplacingMergeTree 數據表的語法和創建普通 MergeTree 表別無二致,只需要將 ENGINE 換一下即可:
ENGINE = ReplacingMergeTree(ver)
里面的參數 ver 是選填的,可以指定一個整型、Date、DateTime 的字段作為版本號,這個參數決定了去除重復數據時所使用的算法。那么下面我們就來創建一張 ReplacingMergeTree 數據表:
CREATE TABLE replace_table (
id String,
code String,
create_time DateTime
) ENGINE = ReplacingMergeTree()
PARTITION BY toYYYYMM(create_time)
ORDER BY (id, code)
PRIMARY KEY id
這里的 ORDER BY 是去除重復數據的關鍵,不是 PRIMARY KEY,ORDERR BY 聲明的表達式是后續判斷數據是否重復的依據。在這個栗子中,數據會基於 id 和 code 兩個字段進行去重,我們寫入幾條數據:
INSERT INTO replace_table
VALUES ('A001', 'C1', '2020-11-10 15:00:00'),
('A001', 'C1', '2020-11-11 15:00:00'),
('A001', 'C100', '2020-11-12 15:00:00'),
('A001', 'C200', '2020-11-13 15:00:00'),
('A002', 'C2', '2020-11-14 15:00:00'),
('A003', 'C3', '2020-11-15 15:00:00')
我們插入了 6 條數據,但 create_time 為 2020-11-10 15:00:00、2020-11-11 15:00:00 的兩條數據的 id 和 code 是重復的,因此會進行去重,只保留重復數據的最后一條,所以最終只會有 5 條數據。但需要注意的是,我們這 6 條數據是使用一個 INSERT 語句導入的,所以在導入的時候直接就去重了。

我們看到只保留了最后一條重復數據,因為使用的是一個 INSERT,所以這批數據會寫入到同一個分區目錄。如果是同一分區的不同分區目錄(分多批導入),那么數據是不會去重的,只有在進行合並的時候才會進行去重。舉個栗子,我們再寫入幾條數據:
INSERT INTO replace_table
VALUES ('A001', 'C1', '2020-11-03 15:00:00'),
('A001', 'C1', '2020-11-02 15:00:00')
顯然這兩條數據會寫入新的分區目錄,但它們的 id 和 code 也是重復的,因此會去進行去重,最終新生成的分區目錄中只會有一條數據。

ClickHouse 的控制台做的還是很人性化的,不同分區目錄的數據是分開顯示的,當然我們在獲取到的數據本身是連在一起的,只是 ClickHouse 的控制台方便你觀察而分開顯示了。我們看到第二個分區目錄中只有一條數據,因為導入的兩條數據的 id 和 code 是重復的,在寫入同一個分區目錄的時候會先對數據進行去重。但是不同分區目錄的之間的數據是可以重復的,因為去重是以分區目錄為單位的,而一個分區可以對應多個分區目錄,所以上面出現了兩個 A001、C1,因為它們位於不同的分區目錄。只有當這些分區目錄進行合並、生成新的分區目錄時才會進行去重。

當不同分區目錄的數據進行合並時,數據再次進行了去重,會保留后創建的分區目錄中的數據,因此 create_time 為 2020-11-02 15:00:00 的數據保留了下來。並且我們也可以看到,ReplacingMergeTree 在去除重復數據時,確實是以排序鍵為單位的。如果以主鍵去重的話,那么就不會有 3 條 A001 了。
所以暫時可以得出如下結論:
1. 去重是以排序鍵為准2. 當數據寫入同一個分區目錄時,會直接對重復數據進行去重,並且保留的是最后一條3. 同一分區、但位於不同分區目錄的數據不會進行去重,只有在合並成新的分區目錄時才會進行去重,並且保留的是最后一個分區的數據
不過問題來了,要是不同分區的數據會不會去重呢?其實在開頭我們就已經埋下伏筆了,因為我們在開頭說了 ReplacingMergeTree 是在一定程度上解決了數據重復的問題,所以不同分區的數據重復它是無法解決的。
我們上面所有的數據都位於 2020-11 這個分區中,那么下面再插入一條數據、創建一個新的分區:
INSERT INTO replace_table VALUES ('A001', 'C1', '2010-11-17 15:00:00')
我們將 2020 改成 2010,然后測試一下:

因此不同分區的數據是無法進行去重的,這也算是 ReplacingMergeTree 的一個局限性。當然說局限性感覺也不是很合適,因為分區的目的就是為了減小查詢時的數據量,如果往一個分區導入數據還要在乎其它分區、看數據是否在其它分區中已出現,那這不就相當於喪失了分區的意義了嗎?
但是問題來了,這里不同分區的數據先不考慮,因為它無法去重,我們再談一下同一個分區中數據去重的邏輯。我們說當數據重復時會保留最后一條,但有時我們希望某個字段的值最大的那一條保留下來,這時該怎么做呢?還記得我們之前說在指定 ReplacingMergeTree 的時候可以指定參數嗎?
DROP TABLE replace_table;
CREATE TABLE replace_table (
id String,
code String,
create_time DateTime
-- 指定參數,以后去重的時候會保留 create_time 最大的那一條數據
) ENGINE = ReplacingMergeTree(create_time)
PARTITION BY toYYYYMM(create_time)
ORDER BY (id, code)
PRIMARY KEY id
然后插入幾條數據:
INSERT INTO replace_table
VALUES ('A001', 'C1', '2020-11-10 15:00:00'),
('A001', 'C1', '2020-11-21 15:00:00'),
('A001', 'C1', '2020-11-11 15:00:00')
顯示此時會保留 create_time 為 2020-11-21 15:00:00 的記錄,因為的值最大,我們測試一下:

然后再插入兩條記錄:
INSERT INTO replace_table
VALUES ('A001', 'C1', '2020-11-28 15:00:00');
INSERT INTO replace_table
VALUES ('A001', 'C1', '2020-11-27 15:00:00');
注意:我們要分兩批導入,然后進行合並,顯然 2020-11-28 15:00:00 這條會保留下來,而不是最后一個分區目錄中數據。

所以最后再總結一下 ReplacingMergeTree 的使用邏輯:
1. 使用 ORDER BY 排序鍵作為判斷數據重復的唯一鍵2. 當導入同一分區目錄時,會直接進行去重3. 當導入不同分區目錄時,不會進行去重,只有當分區目錄合並時,屬於同一分區內的重復數據才會去重;但是不同分區內的重復數據不會被刪除4. 在進行數據去重時,因為分區內的數據已經是基於 ORDER BY 排好序的,所以能很容易地找到那些相鄰的重復的數據5. 數據去重策略有兩種:如果沒有設置 ver 版本號,則保留同一組重復數據中的最后一條;如果設置了 ver 版本號,則保留同一組重復數據中 ver 字段取值最大的那一行
SummingMergeTree
假設有這樣一種查詢需求,終端用戶只需要查詢數據的匯總結果,不關心明細數據,並且數據的匯總條件是預先明確的(GROUP BY 條件明確,且不會隨意改變)。對於這樣的查詢場景,ClickHouse 要如何解決呢?
最直接的方案就是使用 MergeTree 存儲數據,然后通過 GROUP BY 聚合查詢,並利用 SUM 函數匯總結果。這種方案本身完全行的通,但是有兩個不完美之處:
存在額外的存儲開銷:終端用戶不會查詢任何明細數據,只關心匯總結果,所以不應該一直保存所有的明細數據存在額外的查詢開銷:終端用戶只關心匯總結果,雖然 MergeTree 性能強大,但是每次查詢都進行實時聚合計算也是一種性能消耗
而 SummingMergeTree 就是為了應對這類查詢場景而生的,顧名思義它能夠在合並分區的時候按照預先定義的的條件匯總數據,將同一分組下的多行數據匯總成一行,這樣既減少了數據行,又降低了后續匯總查詢的開銷。
在之前我們說過,MergeTree 的每個分區內,數據都會按照 ORDER BY 表達式排好序,主鍵索引都會按照 PRIMARY KEY 取值並排好序。而默認情況下 ORDER BY 可以代指 PRIMARY KEY,所以一般情況下我們只需要聲明 ORDER BY 即可。但如果需要同時定義 ORDER BY 和 PRIMARY KEY,通常只有一種可能,那就是明確希望 ORDER BY 和 PRIMARY KEY 不同,而這種情況只會在使用 SummingMergeTree 和 AggregatingMergeTree 時才會出現,因為這兩者的聚合都是根據 ORDER BY 進行的。
假設有一張 SummingMergeTree 數據表,里面有 A、B、C、D、E、F 六個字段,如果需要按照 A、B、C、D 匯總,那么在創建表結構的時候需要指定:
ORDER BY (A, B, C, D)
但是這樣主鍵也被定義成了 A、B、C、D,而在業務層面其實只需要對業務字段 A 進行查詢過濾,所以應該只使用 A 字段創建主鍵。所以我們應該這么定義:
ORDER BY (A, B, C, D)
PRIMARY KEY A
但如果同時聲明了 ORDER BY 和 PRIMARY KEY,那么 MergeTree 會強制要求 PRIMARY KEY 必須是 ORDER BY 的前綴,所以:
-- 不行
ORDER BY (B, C)
PRIMARY KEY A
-- 行
ORDER BY (B, C)
PRIMARY KEY B
這種強制約束保障了即便在定義不同的情況下,主鍵仍然是排序鍵的前綴,不會出現索引與數據順序混亂的問題。假設現在業務發生了細微的變化,需要減少字段,將先前的 A、B、C、D 改為按照 A、B 匯總,則可按照如下方式修改排序鍵:
ALTER TABLE table_name MODIFY ORDER BY (A, B)
但是需要注意,如果減少字段的話,只能從右往左減少。怎么理解呢?我們之前是按照 A、B、C、D 進行的匯總,那么減少字段的話,最終可以按照 A、B、C 匯總、可以按照 A、B 匯總、可以按照 A 匯總,但是不能按照 A、C 或者 A、D、或者 A、C、D 等等進行匯總。所以減少字段一定是從右往左依次減少,不能出現跳躍。
除此之外,ORDER BY 只能在現有字段的基礎上減少字段,如果新增字段,則只能添加通過 ALTER ADD COLUMN 新增的字段。但 ALTER 是一種元數據級別的操作,修改成本很低,相比不能修改的主鍵,已經非常便利了。
那么介紹 SummingMergeTree 數據表的創建方式,顯然都已經猜到了,因為 MergeTree 家族的表引擎創建方式都是類似的,只不過引擎不同罷了。
ENGINE = SummingMergeTree((col1, col2, col3, ...))
其中 col1、col2 為 columns 參數值,這是一個選填參數,用於設置除主鍵外的其它數值類型字段,以指定被 SUM 匯總的列字段。如果不填寫此參數,則會將所有非主鍵的數值類型字段進行匯總,下面就來創建一張 SummingMergeTree 表:
CREATE TABLE summing_table (
id String,
city String,
v1 UInt32,
v2 Float64,
create_time DateTime
) ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(create_time)
PRIMARY KEY id
ORDER BY (id, city)
接下來插入幾條數據:
INSERT INTO summing_table
VALUES ('A001', 'beijing', 10, 20.1, '2020-05-10 17:00:00'),
('A001', 'beijing', 20, 30.2, '2020-05-20 17:00:00'),
('A001', 'shanghai', 20, 30, '2020-05-10 17:00:00');
INSERT INTO summing_table
VALUES ('A001', 'beijing', 10, 20, '2020-05-01 17:00:00');
INSERT INTO summing_table
VALUES ('A001', 'beijing', 60, 50, '2020-10-10 17:00:00');
顯然此時會創建三個分區目錄,202005_1_\1_0、202005_2_\2_0、202010_1_\1_0。另外 SummingMergeTree 和 ReplacingMergeTree 類似,如果導入同一分區目錄的數據有重復的,那么直接就聚合了,不同分區目錄則不會聚合,而是在合並生成新分區目錄的時候,再對屬於同一分區的多個分區目錄里的數據進行聚合。

我們看到第一個分區目錄中的三條數據聚合成了兩條,然后手動觸發合並動作:

不同分區目錄(屬於同一分區)里的數據聚合在一起了,至於不在匯總字段之列的 create_time 則取了同組內第一行數據的值;而不同分區對應的分區目錄就不會被聚合了,因為不在同一個分區內。
另外 SummingMergeTree 也支持嵌套類型的字段,在使用嵌套類型字段時,需要被 SUM 匯總的字段必須以以 Map 后綴結尾,例如:
CREATE TABLE summing_table_nested (
id String,
nestMap Nested (
id UInt32,
key UInt32,
val UInt64
),
create_time DateTime
) ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(create_time)
ORDER BY id
在使用嵌套數據類型時,默認會以嵌套類型中第一個字段作為聚合條件 Key,寫入測試數據:

我們看到寫入的時候就聚合了,並且按照 nestMap 里面的 id 聚合的,之前我們說過:嵌套類型本質是一種多維數組的結構,里面的每個字段都是一個數組,並且長度要相等。

然后我們再寫一條數據:
INSERT INTO summing_table_nested VALUES ('A001', [2], [300], [600], '2020-08-10 17:00:00')
顯然此時會新創建一個分區目錄,然后我們手動觸發合並:

合並的結果顯然符合我們的預期,當然如果分區不同,那么就無法合並了。
當然我們上面默認是按 id 進行聚合的,或者說是按嵌套類型中的第一個字段進行聚合,但 ClickHouse 也支持使用復合字段(Key)作為數據聚合的條件。為了使用復合 Key,在嵌套類型的字段中,除了第一個字段以外,任何名稱是以 Key、Id 或者 Type 結尾的字段,都將和第一個字段一起組成復合 Key。例如我們將上面的建表邏輯改一下,將小寫 key 改成大寫 Key:
CREATE TABLE summing_table_nested (
id String,
nestMap Nested (
id UInt32,
Key UInt32, -- 大寫 Key
val UInt64
),
create_time DateTime
) ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(create_time)
ORDER BY id
該栗子中會以 id 和 Key 作為聚合條件,因此以上就是 SummingMergeTree,我們再來總結一下它的處理邏輯:
只有 ORDER BY 排序鍵作為聚合數據的條件 Key寫入同一分區目錄的數據會聚合之后在寫入,而屬於同一分區的不同分區目錄的數據,則會在合並觸發時進行匯總不同分區的數據不會匯總到一起如果在定義引擎時指定了 columns 匯總列(非主鍵的數值類型字段),則 SUM 會匯總這些列字段;如果未指定,則聚合所有非主鍵的數值類型字段在進行數據匯總時,因為分區內的數據已經基於 ORDER BY 進行排序,所以很容易找到相鄰也擁有相同 Key 的數據在匯總數據時,同一分區內相同聚合 key 的多行數據會合並成一行,其中匯總字段會進行 SUM 計算;對於那些非匯總字段,則會使用第一行數據的取值支持嵌套結構,但列字段名稱必須以 Map 后綴結尾,並且默認以第一個字段作為聚合 Key。並且除了第一個字段以外,任何名稱以 key、Id 或者 Type 為后綴結尾的字段都會和第一個字段組成復合 Key
AggregatingMergeTree
有過數倉建設經驗的你一定知道數據立方體的概念,這是一個在數倉領域十分常見的模型,它通過以空間換時間的方式提升查詢性能,將需要聚合的數據預先計算出來(預聚合)並保存,在后續需要聚合查詢到的時候,直接使用保存好的結果數據。
Kylin 就是一個典型的使用預聚合的數據倉庫,提供 Hadoop/Spark 之上的 SQL 查詢接口及多維分析(OLAP)能力以支持超大規模數據。它的核心邏輯就是在數據集上定義一個星形模型或者雪花模型,然后基於模型搭建數據立方體(cube)並將結果存儲在 HBase 中,最后使用標准 SQL 以及其它 API 進行查詢,由於數據已經提前計算好,所以僅需亞秒級響應時間即可獲得查詢結果。
AggregatingMergeTree 就有些數據立方體的意思,它能夠在合並分區的時候按照預先定義的條件聚合數據。同時,根據預先定義的聚合函數計算數據並通過二進制的格式存入表內。通過將同一分組下的多行數據預先聚合成一行,既減少了數據行,又降低了后續聚合查詢的開銷。可以說 AggregatingMergeTree 是 SummingMergeTree 的升級版,它們的許多設計思路和特性是一致的,例如同時定義 ORDER BY 和 PRIMARY KEY 的原因和目的。但是在用法上兩者存在明顯差異,應該說 AggregatingMergeTree 的定義方式是 MergeTree 家族中最為特殊的一個。聲明使用 AggregatingMergeTree 的方式如下:
ENGINE = AggregatingMergeTree()
AggregatingMergeTree 沒有任何額外的設置參數,在分區合並時,在每個數據分區內,會按照 ORDER BY 聚合。而使用何種聚合函數,以及針對哪些列字段進行計算,則是通過定義 AggregateFunction 數據類型實現的。以下面的語句為例:
CREATE TABLE agg_table (
id String,
city String,
code AggregateFunction(uniq, String),
value AggregateFunction(sum, UInt32),
create_time DateTime
) ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMM(create_time)
ORDER BY (id, city)
PRIMARY KEY id
上述的 id 和 city 是聚合條件,等同於在 SQL 語句中指定 GROUP BY id, city;而 code 和 value 聚合字段,其語義等同於 uniq(code)、sum(value)。
AggregateFunction 是 ClickHouse 提供的一種特殊的數據結構,它能夠以二進制的形式存儲中間狀態結果。其使用方法也十分特殊,對於 AggregateFunction 類型的列字段,數據的查詢和寫入都與眾不同。在寫入數據時需要調用 *State 函數,查詢數據時則調用相應的 *Merge 函數。其中 * 表示定義時使用的聚合函數,例如上面的建表語句中使用了 uniq 和 sum 函數。
那么在寫入數據時,需要調用對應的 uniqState 和 sumState 函數,並使用 INSERT SELECT 語法:
INSERT INTO agg_table
SELECT ('A000', 'beijing', uniqState('code1'), sumState(toUInt32(100)), '2020-08-10 17:00:00'),
('A000', 'beijing', uniqState('code1'), sumState(toUInt32(100)), '2020-08-10 17:00:00')
而在查詢數據時,如果使用列名 code、value 進行訪問的話,雖然也能查詢到數據,只不過顯示的是無法閱讀的二進制,我們需要調用對應的 uniqMerge 和 sumState 函數。
SELECT id, city, uniqMerge(code), sumMerge(value)
FROM agg_table
-- 在 SQL 語句中聚合語句肯定要用 GROUP BY
-- 但在定義表結構的時候,聚合字段是使用 ORDER BY 表示的,當然它指定的也是排序字段
GROUP BY id, city
下面來測試一下:

看到這里你可能覺得 AggregatingMergeTree 使用起來有些過去繁瑣了,連正常數據寫入還要借助 INSERT SELECT、並且調用特殊函數才能實現,沒錯,如果是上面這種做法的話,確實有些麻煩了。不過無須擔心,當前這種用法並不是主流用法。
AggregatingMergeTree 的主流用法是結合物化視圖使用,將它作為物化視圖的表引擎,這里的物化視圖是作為其它數據表上層的一種查詢視圖。

接下來用一組示例進行說明,首先創建明細數據表,也就是俗稱的底表:
CREATE TABLE agg_table_basic (
id String,
city String,
code String,
value UInt32
) ENGINE = MergeTree()
PARTITION BY city
ORDER BY (id, city)
通常使用 MergeTree 作為底表,用於存儲全量的明細數據,並以此對外提供實時查詢。接着,創建一張物化視圖:
CREATE MATERIALIZED VIEW agg_view
ENGINE = AggregatingMergeTree()
PARTITION BY city
ORDER BY (id, city)
AS SELECT
id, city,
uniqState(code) AS code,
sumState(value) AS value
FROM agg_table_basic
GROUP BY id, city
物化視圖使用 AggregatingMergeTree 表引擎,用於特定場景的數據查詢,相比 MergeTree,它擁有更高的性能。但在新增數據時,面向的對象是底表 MergeTree:
INSERT INTO agg_table_basic
VALUES ('A000', 'beijing', 'code1', 100),
('A000', 'beijing', 'code2', 200),
('A000', 'shanghai', 'code1', 200)
數據會自動同步到物化視圖,並按照 AggregatingMergeTree 的引擎的規則進行處理。而在查詢數據時,面向的對象是物化視圖 AggregatingMergeTree:

以上就是 AggregatingMergeTree 的整個流程,最常見的用法是作為普通物化視圖的表引擎,和普通 MergeTree 數據表搭配使用。
CollapsingMergeTree
假設現在需要設計一款數據庫,該數據庫支持需要支持對已經存在的數據實現行級粒度的修改和刪除,你會怎么設計呢?一種最常見的想法是:首先找到保存數據的文件,接着修改這個文件,比如修改或刪除那些需要變化的數據行。然而在大數據領域,對於 ClickHouse 這類高性能分析數據庫而言,對數據源文件進行修改是一件非常奢侈且代價昂貴的操作。相較於直接修改源文件,將修改和刪除操作轉換為新增操作會更合適一些,也就是以增代刪。
CollapsingMergeTree 就是一種通過以增代刪的思路,支持行級數據修改和刪除的表引擎。它通過定義一個 sign 標記位字段,記錄數據行的狀態。如果 sign 標記為 1,則表示這是一行有效數據;如果 sign 標記為 -1,則表示這行數據要被刪除。當 CollapsingMergeTree 分區合並時,同一數據分區內,sign 標記為 1 和 -1 的一組數據(ORDER BY 字段對應的值相同)會被抵消刪除。這種 1 和 -1 相互抵消的操作,猶如將一張瓦楞紙折疊了一般,這種直觀的比喻,想必也是折疊合並樹(CollapsingMergeTree)的由來。
聲明 CollapsingMergeTree 的方式如下:
ENGINE = CollapsingMergeTree(sign)
其中,sign 用於指定一個 Int8 類型的標志位字段,一個完整的 CollapsingMergeTree 數據表聲明如下:
CREATE TABLE collapse_table (
id String,
code Int32,
create_time DateTime,
sign Int8
) ENGINE = CollapsingMergeTree(sign)
PARTITION BY toYYYYMM(create_time)
ORDER BY id
與其它的 MergeTree 變種引擎一樣,CollapsingMergeTree 同樣是以 ORDER BY 排序鍵作為后續判斷數據唯一性的依據。按照之前的介紹,對於上述 collapse_table 數據表而言,除了常規的新增操作之外,還能支持其它兩種操作:
其一:刪除一行數據
-- 插入一條數據,后續對它進行刪除
INSERT INTO collapse_table VALUES ('A000', 100, '2020-02-20 00:00:00', 1)
刪除一條數據,顯然不能像關系型數據庫那樣使用 DELETE,正確做法是插入一條"要刪除的數據"的鏡像數據,ORDER BY 字段與原數據相同(其它字段可以不同),然后 sign 取反為 -1,它會和原數據折疊,然后相互抵消。
INSERT INTO collapse_table VALUES ('A000', 100, '2020-02-20 00:00:00', -1)
測試一下:
satori :) SELECT * FROM collapse_table
SELECT *
FROM collapse_table
Query id: f02e3e84-7837-4db7-af2b-d42957c5a63b
┌─id───┬─code─┬─────────create_time─┬─sign─┐
│ A000 │ 100 │ 2020-02-20 00:00:00 │ 1 │
└──────┴──────┴─────────────────────┴──────┘
┌─id───┬─code─┬─────────create_time─┬─sign─┐
│ A000 │ 100 │ 2020-02-20 00:00:00 │ -1 │
└──────┴──────┴─────────────────────┴──────┘
2 rows in set. Elapsed: 0.002 sec.
其二:修改一行數據
-- 插入一條數據,后續對它進行修改
INSERT INTO collapse_table VALUES ('A001', 100, '2020-02-20 00:00:00', 1)
其中 code 的值是 100,我們要將其修改成 120,該怎么做呢?顯然不能像關系型數據那樣使用 UPDATE,正確的做法是以增代刪。先創建鏡像數據將原數據折疊,然后將修改后的原數據再插入到表中即可。
INSERT INTO collapse_table
VALUES ('A001', 100, '2020-02-20 00:00:00', -1),
-- 然后將原數據修改之后作為新數據,插入到表中,sign 為 1
('A001', 120, '2020-02-20 00:00:00', 1)
測試一下:
satori :) SELECT * FROM collapse_table
SELECT *
FROM collapse_table
Query id: bfb8afec-e672-416f-a7b8-5fcdf6470e59
┌─id───┬─code─┬─────────create_time─┬─sign─┐
│ A000 │ 100 │ 2020-02-20 00:00:00 │ 1 │
└──────┴──────┴─────────────────────┴──────┘
┌─id───┬─code─┬─────────create_time─┬─sign─┐
│ A000 │ 100 │ 2020-02-20 00:00:00 │ -1 │
└──────┴──────┴─────────────────────┴──────┘
┌─id───┬─code─┬─────────create_time─┬─sign─┐
│ A001 │ 100 │ 2020-02-20 00:00:00 │ 1 │
└──────┴──────┴─────────────────────┴──────┘
┌─id───┬─code─┬─────────create_time─┬─sign─┐
│ A001 │ 100 │ 2020-02-20 00:00:00 │ -1 │
│ A001 │ 120 │ 2020-02-20 00:00:00 │ 1 │
└──────┴──────┴─────────────────────┴──────┘
5 rows in set. Elapsed: 0.003 sec.
satori :)
還是很好理解的,然后 CollapsingMergeTree 在折疊數據時遵循如下規則:
如果 sign = 1 比 sign = -1 的數據多一行,則保留最后一行 sign = 1 的數據如果 sign = -1 比 sign = 1 的數據多一行,則保留第一行 sign = -1 的數據如果 sign = 1 和 sign = -1 的數據行一樣多,並且最后一行是 sign = 1,則保留第一行 sign = -1 和最后一行 sign = 1 的數據如果 sign = 1 和 sign = -1 的數據行一行多,並且最后一行是 sign = -1,則什么也不保留其余情況,ClickHouse 會打印告警日志,但不會報錯,在這種情形下打印結果不可預知
當然折疊數據並不是實時觸發的,和所有的其它 MergeTree 變種表引擎一樣,這項特性只有在多個分區目錄合並的時候才會觸發,觸發時屬於同一分區的數據會進行折疊。而在分區合並之前,用戶還是可以看到舊數據的,就像上面演示的那樣。
如果不想看到舊數據,那么可以在聚合的時候可以改變一下策略:
-- 原始 SQL 語句
SELECT id, sum(code), count(code), avg(code), uniq(code)
FROM collapse_table GROUP BY id
-- 改成如下
SELECT id, sum(code * sign), count(code * sign), avg(code * sign), uniq(code * sign)
FROM collapse_table GROUP BY id HAVING sum(sign) > 0
或者在查詢數據之前使用 optimize TABLE table_name FINAL 命令強制分區合並,但是這種方法效率極低,在實際生產環境中慎用。
satori :) SELECT * FROM collapse_table
SELECT *
FROM collapse_table
Query id: 0cf9d813-5dcc-4a58-a02a-de3d6fb38c60
┌─id───┬─code─┬─────────create_time─┬─sign─┐
│ A001 │ 120 │ 2020-02-20 00:00:00 │ 1 │
└──────┴──────┴─────────────────────┴──────┘
1 rows in set. Elapsed: 0.002 sec.
satori :)
我們看到 A000 的數據已經沒有了,只剩下了 A001,並且 code 是 120,不是原來的 100。
另外只有相同分區內的數據才有可能被折疊,不過這項限制對於 CollapsingMergeTree 來說通常不是問題,因為修改或刪除數據的時候,這些數據的分區規則通常都是一致的,並不會改變。但 CollapsingMergeTree 還有一個非常致命的限制,那就是對數據的寫入順序有着嚴格要求,舉個例子:
-- 先寫入 sign = 1
INSERT INTO collapse_table VALUES ('A002', 102, '2020-02-20 00:00:00', 1)
-- 先寫入 sign = -1
INSERT INTO collapse_table VALUES ('A002', 102, '2020-02-20 00:00:00', -1)
顯然此時是可以正常折疊的,我們剛才已經實驗過了,但如果將寫入的順序置換一下,就無法折疊了。
-- 先寫入 sign = 1
INSERT INTO collapse_table VALUES ('A003', 102, '2020-02-20 00:00:00', -1)
-- 先寫入 sign = -1
INSERT INTO collapse_table VALUES ('A003', 102, '2020-02-20 00:00:00', 1)
我們測試一下,執行 optimize TABLE collapse_table FINAL,然后進行查詢:
satori :) select * from collapse_table
SELECT *
FROM collapse_table
Query id: 3aaf02d2-7089-42f6-9d3b-a697b196bd42
┌─id───┬─code─┬─────────create_time─┬─sign─┐
│ A001 │ 120 │ 2020-02-20 00:00:00 │ 1 │
│ A003 │ 102 │ 2020-02-20 00:00:00 │ -1 │
│ A003 │ 102 │ 2020-02-20 00:00:00 │ 1 │
└──────┴──────┴─────────────────────┴──────┘
3 rows in set. Elapsed: 0.002 sec.
satori :)
我們看到兩個 A003 沒辦法進行折疊,原因就是這兩條數據的 sign = -1 在前、sign = 1 在后,如果我們在寫入一條 A003、sign = -1 會有什么結果呢?顯然會和 sign = 1 的 A003 進行合並,只留下一條 sign = -1 的 A003。
satori :) select * from collapse_table
SELECT *
FROM collapse_table
Query id: 82226926-6f2a-4ab8-80b4-ce8980ec1eec
┌─id───┬─code─┬─────────create_time─┬─sign─┐
│ A001 │ 120 │ 2020-02-20 00:00:00 │ 1 │
│ A003 │ 102 │ 2020-02-20 00:00:00 │ -1 │
└──────┴──────┴─────────────────────┴──────┘
2 rows in set. Elapsed: 0.002 sec.
satori :)
這種現象是 CollapsingMergeTree 的處理機制所導致的,因為它要求 sign = 1 和 sign = -1 的數據相鄰,而分區內的數據嚴格按照 ORDER BY 排序,要實現 sign = 1 和 sign = -1 的數據相鄰,則只能嚴格按照順序寫入。
如果數據的寫入順序是單線程執行的,則能夠比較好的控制寫入順序;但如果需要處理的數據量很大,數據的寫入程序通常是多線程的,那么此時就不能保障數據的寫入順序了。而在這種情況下,CollapsingMergeTree 的工作機制就會出現問題,而為了解決這個問題,ClickHouse 額外提供了一個名為 VersionedCollapsingMergeTree 的表引擎。
VersionedCollapsingMergeTree
VersionedCollapsingMergeTree 表引擎的作用和 CollapsingMergeTree 完全相同,它們的不同之處在於 VersionedCollapsingMergeTree 對數據的寫入順序沒有要求,在同一個分區內,任意順序的數據都可以完成折疊操作。那么 VersionedCollapsingMergeTree 是如何做到這一點的呢?其實從它的名字就能看出來,因為相比 CollapsingMergeTree 多了一個 Versioned,那么顯然就是通過版本號(version)解決的。
在定義 VersionedCollapsingMergeTree 數據表的時候,除了指定 sign 標記字段之外,還需要額外指定一個 UInt8 類型的 ver 版本號字段。
ENGINE = VersionedCollapsingMergeTree(sign, ver)
一個完整的 VersionedCollapsingMergeTree 表定義如下:
CREATE TABLE ver_collapse_table (
id String,
code Int32,
create_time DateTime,
sign Int8,
ver UInt8
) ENGINE = CollapsingMergeTree(sign, ver)
PARTITION BY toYYYYMM(create_time)
ORDER BY id
那么 VersionedCollapsingMergeTree 是如何使用版本號字段的呢?其實很簡單,在定義 ver 字段之后,VersionedCollapsingMergeTree 會自動將 ver 作為排序條件並增加到 ORDER BY 的末端。以上面的 ver_collapse_table 為例,在每個分區內,數據會按照 ORDER BY id, ver DESC 排序。所以無論寫入時數據的順序如何,在折疊處理時,都能回到正確的順序。
-- 首先是刪除數據
INSERT INTO ver_collapse_table VALUES ('A000', 101, '2020-02-20 00:00:00', -1, 1);
INSERT INTO ver_collapse_table VALUES ('A000', 101, '2020-02-20 00:00:00', 1, 1);
-- 然后是修改數據
INSERT INTO ver_collapse_table VALUES ('A001', 101, '2020-02-20 00:00:00', -1, 1);
INSERT INTO ver_collapse_table VALUES ('A001', 102, '2020-02-20 00:00:00', 1, 1);
INSERT INTO ver_collapse_table VALUES ('A001', 103, '2020-02-20 00:00:00', 1, 2);
以上數據均能正常折疊。
各種 MergeTree 之間的關系總結
經過上述介紹是不是覺得 MergeTree 特別豐富呢?但還是那句話,任何事都有兩面性,功能豐富就意味着很容易被這么多表引擎弄暈,那么下面我們就以繼承和組合這兩種關系來理解整個 MergeTree。
繼承關系
首先為了便於理解,可以使用繼承關系來理解 MergeTree,MergeTree 表引擎向下派生出 6 個變種表引擎。

在 ClickHouse 底層的實現方法中,上述 7 種表引擎的區別主要體現在 Merge 合並的邏輯部分,簡化后的對象關系如下圖所示:

可以看到在具體的實現部分,7 種 MergeTree 共用一個主體,而在觸發 Merge 動作時,它們調用了各自獨有的合並邏輯。
MergeTree 之外的其它 6 個變種表引擎的 Merge 合並邏輯,全部都是建立在 MergeTree 基礎之上的,且均繼承於 MergeTree 的 MergingSortedBlockInputStream,如下圖所示:

MergingSortedBlockInputStream 的主要作用是按照 ORDER BY 的規則保證分區內數據的有序性,而其它 6 種變種 MergeTree 的合並邏輯,則是在有序的基礎之上各有所長,例如將排序后相鄰的重復數據消除,或者將重復數據累加匯總等等。
組合關系
了解完 7 種 MergeTree 的關系,下面再來說一下它們的組合,我們說如果 MergeTree 加上 Replicated 的話,則表示支持副本,那么 ReplicatedMergeTree 和普通的 MergeTree 有什么區別呢?

上圖中的虛線框部分是 MergeTree 的能力邊界,而 ReplicatedMergeTree 則在 MergeTree 能力的基礎之上增加了分布式協同的能力,其借助 zookeeper 的消息日志廣播功能,實現了副本實例之間的數據同步功能。
ReplicatedMergeTree 系列可以用組合關系來理解,如下圖所示:

當我們為 7 種 MergeTree 加上 Replicated 前綴之后,又能組合出 7 種新的表引擎,而這些 ReplicatedMergeTree 擁有副本協同的能力。關於 ReplicatedMergeTree,后續會詳細說。
以上我們就介紹完了 MergeTree 以及整個家族系列的表引擎,MergeTree 系列表引擎在生產中是使用頻率最高的表引擎,我們是非常有必要徹底掌握它的。但我們說除了 MergeTree,還有很多其它表引擎,雖然使用頻率不是那么高,不過還是有適合自身的場景的,所以我們也需要掌握,那么后續就來看一看其它種類的表引擎。
