執行計划
如果要在 ClickHouse 20.6 版本之前查看 SQL 語句的執行計划,需要在 config.xml 里面將日志級別設置為 trace。
<!-- 新版本默認是 trace -->
<logger>
<level>trace</level>
</logger>
然后還要真正執行相應的 SQL 語句,在執行日志里面查看,很明顯這是非常不方便的。於是 ClickHouse 在 20.6 版本里面引入了原生的執行計划的語法(此時處於試用期階段),並在 20.6.3 版本中正式轉正。
我們當前系列都是基於 ClickHouse 的 21.7.3.14 版本。
然后我們來介紹如何查看執行計划,不過介紹之前我們先創建一張數據表,這次我們采用真實的數據。首先 ClickHouse 官方提供了兩個數據集,其中數據行數和字段數都非常的大,不亞於一些公司生產環境上的數據,我們來下載一下。
# 下載數據集,這里的數據集不是 CSV、JSON,而是 .bin、.mrk 等物理文件
# 也就是說數據集本身就是符合 ClickHouse 物理存儲的
curl -O https://datasets.clickhouse.tech/hits/partitions/hits_v1.tar
# 所以我們直接解壓到拷貝到 /var/lib/clickhouse 目錄下即可
tar -xvf hits_v1.tar -C /var/lib/clickhouse
然后我們就可以使用 hits_v1 這張表了,我們之前說過,必須要先創建表然后再導入數據,因為創建表的時候會生成一些元信息,存儲在 /var/lib/clickhouse/metadata 目錄下,而光有數據沒有元信息是不行的。但對於當前而言則不用事先創建表,因為 ClickHouse 將元信息也准備好了,所以我們直接拷貝過去即可。壓縮包解壓之后,會有一個 data 目錄和一個 metadata 目錄,所以我們解壓到 /var/lib/clickhouse 中,會自動將 data 目錄里面的內容合並到 /var/lib/clickhouse 的 data 目錄中,將 metadata 目錄里面的內容合並到 /var/lib/clickhouse 的 metadata 目錄中。
[root@satori data]# ls
datasets default system
[root@satori data]# ls datasets
hits_v1
我們看到里面多了一個 datasets 目錄,datasets 目錄下才是 hits_v1,顯然我們后續需要使用 datasets.hits_v1 進行查詢。當然數據集還有一份,我們按照相同的套路即可。
curl -O https://datasets.clickhouse.tech/visits/partitions/visits_v1.tar
tar -xvf visits_v1.tar -C /var/lib/clickhouse
我們來確認一下:
[root@satori datasets]# ls
hits_v1 visits_v1
顯然數據集已經准備完畢,不過我們當前使用的是 root 用戶,還應該要確保 clickhouse 用戶有相應的操作權限。
chown clickhouse:clickhouse /var/lib/clickhouse/data -R
# 然后重啟 ClickHouse,因為我們不是通過 CREATE TABLE 創建的表
# 因此要重啟,不然 ClickHouse 是不知道我們通過拷貝文件的方式新增了兩張表
clickhouse restart
重啟之后,執行 SQL 語句進行查看:

數據量還是不少的,datasets.hits_v1 有將近 900 萬條數據、字段數 130 多個,datasets.visit_v1 有 160 多萬條數據、字段數為 180 多個,還是很大的。
具體都有哪些字段,可以通過 /var/lib/clickhouse/metadata/datasets 下的 .sql 文件進行查看。
有了數據集,我們就可以介紹查詢計划了。當然使用這種規模的數據集有些小題大做,不過既然 ClickHouse 是為大數據准備的,那么使用大一點的數據集也無妨,而且我們后面也會經常使用這些數據集。
基本語法
在 MySQL 中查看執行計划使用的語法是什么呢?沒錯,EXPLAIN,在 ClickHouse 中也是如此,只不過 ClickHouse 將 EXPLAIN 變得更加豐富。
EXPLAN [AST | SYNTAX | PLAN | PIPELINE] [SETTINGS = value, ...]
SELECT ... [FORMAT ...]
我們看一下第一個中括號里面的內容,ClickHouse 除了可以讓我們查看執行計划之外,還可以查看很多其它內容。
1)AST:查看編譯之后的語法樹,這個不是很常用
EXPLAIN AST
SELECT UserID, count() FROM datasets.hits_v1
WHERE EventDate < toDate('2014-03-17') GROUP BY UserID;
執行一下,查看生成的語法樹:

2)SYNTAX:用於優化語法,有時我們指定的查詢語句未必是最優的,那么 ClickHouse 在底層會進行優化,EXPLAIN SYNTAX 可以返回對一條 SQL 語句進行優化后的結果。通過對比優化前和優化后的 SQL 語句,可以有助於我們理解 ClickHouse 的優化機制
EXPLAIN SYNTAX
SELECT UserID, count() count FROM datasets.hits_v1
WHERE EventDate < toDate('2014-03-17') GROUP BY UserID ORDER BY count LIMIT 10;

我們看到僅僅是做了一些格式上調整,但優化前和優化后的語句本質上沒差別,證明對於當前查詢而言,我們寫的 SQL 語句就是最優的。因為這條語句太簡單了,ClickHouse 沒有什么可優化的。
然后我們來寫幾個非常規的語句,比如三元表達式:
-- 這是嵌套的三元表達式,那么 ClickHouse 會怎么優化呢?
EXPLAIN SYNTAX
SELECT number < 5 ? '小於 5' : (number = 5 ? '等於 5' : '大於 5') FROM numbers(10);

注意:這里並沒有開啟優化,只不過是將三元表達式使用 if 語句替換了,因為沒有嵌套的三元表達式在底層就是對應 if 函數的一個調用。只不過 ClickHouse 將一些比較特殊的函數調用,抽象成了一些語法糖,但本質上是沒有變化的,所以當前的 SQL 語句並沒有得到優化。
事實上,ClickHouse 對三元表達式的優化默認是關閉的,我們可以將其打開。
-- 見名知意,就是當出現 if 的嵌套時,優化成 multiIf
SET optimize_if_chain_to_multiif = 1;

SYNTAX 還是很常用的,我們寫完一條 SQL 語句之后,可以直接 EXPLAIN SYNTAX 一下,然后將返回的結果替換掉我們原來的 SQL 語句。
PLAN:查看執行計划,默認選項
EXPLAIN
SELECT UserID, count() count FROM datasets.hits_v1
WHERE EventDate < toDate('2014-03-17') GROUP BY UserID ORDER BY count LIMIT 10;

我們看到圖中的 EXPLAIN 后面並沒有帶上 PLAN,說明 PLAN 是默認選項,然后查看執行計划時還可以設置一些額外的參數:
header:打印計划中各個步驟的 head 說明,默認值為 0 表示關閉,如果開啟,設置為 1description:打印計划中各個步驟的描述,就是圖中括號里面的部分,默認值為 1 表示開啟,如果關閉,設置為 0actions:打印計划中各個步驟的詳細信息,默認值為 0 表示關閉,如果開啟,設置為 1
EXPLAIN header = 1, actions = 1
SELECT UserID, count() count FROM datasets.hits_v1
WHERE EventDate < toDate('2014-03-17') GROUP BY UserID ORDER BY count LIMIT 10;
輸出的內容非常多,可以測試一下。
PIPELINE:查看 PIPELINE 計划,類似於 PLAN
EXPLAIN PIPELINE SELECT sum(number) FROM numbers(100000) GROUP BY number % 20;

類似於 PLAN,查看 PIPELINE 計划時還可以設置一些額外的參數:
header:打印計划中各個步驟的 head 說明,默認值為 0 表示關閉,如果開啟,設置為 1graph:用 DOT 圖形語言描述管道圖,默認關閉,actions:表示當開啟 graph 之后是否緊湊打印,默認開啟
EXPLAIN PIPELINE header = 1, graph = 1
SELECT sum(number) FROM numbers(100000) GROUP BY number % 20;
可以自己查看一下輸出。
建表優化
我們在創建表的時候,需要指定的內容比較多,比如 ORDER BY、表引擎、表參數、分區字段等等,這些對后續數據的查詢效率都是有影響的,當然指定合適的數據類型也是非常重要的。下面就來介紹一下常見的優化手段。
數據類型
在建表的時候能用數值類型和日期時間類型表示的字段就不要使用字符串,雖然字符串類型在以 Hive 為中心的數倉建設中非常常見,但 ClickHouse 卻並非如此。我們知道在 Hive 中,日期一般都用字符串,不會特意使用 Date 類型。但在 ClickHouse 中,能不要 String 就不要用,因為后期還要轉換。
對於 DateTime,ClickHouse 底層會轉成時間戳進行存儲,但我們不要顯式地使用 UInt64 類型來存儲。因為 DateTime 不需要經過函數轉換處理,執行效率高,可讀性好。
CREATE TABLE test_t (
id UInt32,
product String,
amount Decimal(16, 2),
create_time UInt32 -- 這里使用了整數存儲時間
) ENGINE = ReplacingMergeTree(create_time)
PARTITION BY toYYYYMMDD(toDate(create_time)) -- 需要轉換一次,否則報錯
PRIMARY KEY id
ORDER BY id
除了日期類型和數值類型不用字符串表示之外,Null 也是拖累性能的一個罪魁禍首,因為官方已經指出 Null 會影響性能了。因為存儲 Nullable 類型的列時,需要創建一個額外的文件來存儲 Null 標記,並且 Nullable 類型的列無法被索引。因此除了極特殊的情況,否則不要將列設置為 Nullable,可以用一個不可能出現的默認值、或者在業務中無意義的來代指空,例如將 id 設置為 -1 表示該商品沒有 id,而不是使用 Null。
分區和索引
分區粒度根據業務特點決定,但不宜過粗或者過細,如果數據之間是嚴格按照時間來划分,比如經常要按天、按月或者按年匯總處理,那么不妨選擇按天分區或者按月分區;如果數據按照地區來划分,比如經常針對不同的地區單獨匯總,那么不妨按照地區分區。那么分區到底要分多少個區呢?以單表一億條數據為例,分區大小控制在 10 到 30 個最好。所以如果按照時間分區,那么我們一般都會按天、按月分區,至於按分鍾分區則 dark不必,因為這樣分區目錄就太多了。
還有指定索引列,默認通過 ORDER BY 指定。ORDER BY 在 ClickHouse 中是最重要的,因為分區內的排序通過 ORDER BY 指定,主鍵(索引)默認也是由 ORDER BY 指定,即使我們顯式地使用 PRIMARY KEY 不使用 ORDER BY,那么主鍵也必須是排序鍵的前綴。當然這里的 ORDER BY 指的是建表時的 ORDER BY,不是查詢語句中的 ORDER BY。
然后我們在通過 ORDER BY 指定索引列的時候,應該指定查詢中經常被用來充當篩選條件的列,可以是單一維度,也可以是組合維度,如果是組合維度,那么索引列要滿足查詢頻率大的在前原則。還有基數特別大的不適合做索引列,基數大指的就是那些重復數據非常少的列。
表參數
index_granularity 是用來控制索引粒度的,默認是 8192,如非必須不建議調整。另外,如果一張表不是必須要保留全量歷史數據,則建議指定 TTL,可以免去手動清理過期歷史數據的麻煩,TTL 也可以通過 ALTER TABLE 語句隨時修改。
寫入和刪除優化
盡量不要執行單條或小批量刪除、插入操作,這樣會產生小分區文件,給后台 Merge 任務帶來巨大壓力。
不要一次寫入太多分區,或者數據寫入太快,數據寫入太快會導致 Merge 速度跟不上而報錯,一般建議每秒鍾發起 2 ~ 3 此寫入操作,每次操作寫入 2w ~ 5 w 條數據(依服務器性能而定)。
常見配置
我們知道配置文件位於 /etc/clickhouse-server 目錄下,里面有 config.xml 和 users.xml,我們之前一直說 config.xml,但其實 users.xml 也非常重要。它們都表示服務端的配置,而區別主要在於 config.xml 里面的配置是無法覆蓋的,我們在命令行經常會使用 set 命令將某個參數進行修改,這些參數則是放在 users.xml 中。當然一個設置即可以在 users.xml 中出現,也可以在 config.xml 中出現,服務端首先會從 config.xml 中找,找不到再去 config.xml 中找。
而我們修改配置主要是為了調整 CPU、內存、IO,瓶頸主要在這里。因為 ClickHouse 會有后台線程 Merge 數據,所以非常的吃 CPU;當然加載數據,對內存也是一個考量;同理還有 IO,因為要從磁盤上讀取大量數據。
下面來介紹與這三個配置有關的參數。
1)CPU
background_pool_size:位於 users.xml 中,非常重要的一個參數,表示后台線程池內的線程數量,Merge 線程就是在該線程池中執行,該線程池不僅僅是給 Merge 線程用的。默認值為 16,允許的前提下建議改成 CPU 個數的二倍。所以 ClickHouse 不建議和 HDFS、Yarn 等一起部署,因為 ClickHouse 太吃資源了,不然也達不到如此可觀的速度
background_schedule_pool_size:位於 users.xml 中,表示執行后台任務的線程數,默認值為 128,允許的前提下建議改成 CPU 個數的二倍
background_distributed_schedule_pool_size:位於 users.xml 中,表示分布式發送執行后台任務的線程數,默認值為 16,允許的前提下建議改成 CPU 個數的二倍
max_concurrent_queries:位於 config.xml 中,表示最大並發處理的請求數(包含 SELECT、INSERT 等等),默認值為 100,推薦 150 ~ 300,不夠再加
max_threads:位於 users.xml 中,表示單個查詢所能使用的最大 CPU 個數,默認是 CPU 核數
**以上是關於 CPU 相關的設置,如果發現機器吃不消了,那么不妨減少一下線程數。 **
2)Memory
max_memory_usage:位於 users.xml 中,表示單次 Query 占用內存的最大值,該值可以設置的大一些,這樣可以提高集群查詢的上限。當然也要保留一些給 OS,比如 128G 的內存,設置為 100G 即可

max_bytes_before_external_group_by:表示 GROUP BY 使用的內存的最大值,一旦超過這個最大值,那么會刷新到磁盤進行 GROUP BY,一般按照 max_memory_usage 的一半設置即可。因為 ClickHouse 聚合分兩個階段,查詢並建立中間數據、合並中間數據
max_bytes_before_external_sort:表示 ORDER BY 使用的內存的最大值,一旦超過這個最大值,那么會刷新到磁盤進行 ORDER BY。如果不設置該值,那么當內存不夠的時候直接報錯,設置了該值,ORDER BY 在內存不夠的時候可以基於磁盤完成,但是速度相對就慢了(實際測試發現慢得多,甚至無法接受)。該參數和上一個參數都在 users.xml 中設置。
max_table_size_to_drop:位於 config.xml 中,應用於需要刪除表或分區的情況,默認是 50 GB,意思是如果刪除 50 GB 以上的數據會失敗。建議設置為 0,表示無論分區表 多大都可以刪除
3)IO
和 HDFS 不同,ClickHouse 不支持設置多數據目錄,為了提升 IO 性能,可以掛載虛擬券組(將多塊磁盤虛擬成一塊磁盤),通過一個券組綁定多塊物理磁盤提升讀寫性能。或者使用 SSD,但是成本就比較高了。
ClickHouse 語法優化規則
很多數據庫底層都內置了優化器,定義好了許多的優化規則,用於給我們的 SQL 語句進行優化,比如大小表 JOIN、謂詞下推等等,就是為了避免開發人員執行慢查詢。
那么 ClickHouse 會對哪些查詢進行優化呢?我們來看一下。
COUNT 優化
我們說如果統計一張表有多少行,那么使用 count() 或者 count(*) 即可,此時會直接讀取 count.txt。還記得這個 count.txt 文件嗎?我們在介紹 MergeTree 的時候說過,該文件里面存儲了表的行數,當使用 count() 或者 count(*) 的時候,直接讀取該文件即可,此時是不需要全表掃描的。類似於關系型數據庫也是如此,MySQL 在使用 count() 的時候也是直接計算的 B+ 樹的葉子結點個數。
但當我們 count 一個字段的時候,那么就必須要全表掃描了,而且我們說過 count 字段的時候統計的是該字段中非空的值的個數。如果該字段中沒有空值,count(字段) 的結果和 count()、count(*) 是相等的。

對比輸出信息的話,我們看到 count(字段) 進行了全表掃描。
再比如 count(1),我們看看它會不會被優化:
satori :) EXPLAIN SYNTAX SELECT count(1) FROM datasets.hits_v1;
EXPLAIN SYNTAX
SELECT count(1)
FROM datasets.hits_v1
Query id: f59337ec-58e3-4a37-b00d-eaa796f54f65
┌─explain───────────────┐
│ SELECT count() │
│ FROM datasets.hits_v1 │
└───────────────────────┘
2 rows in set. Elapsed: 0.004 sec.
因為 1 是一個整型,沒有什么實際意義,所以直接變成了 count()。
謂詞下推
在 SQL 中,謂詞就是返回 boolean 值的函數,或隱式轉換為 bool 的函數,說白了你就簡單理解為 WHERE 語句即可。而謂詞下推指的是將過濾表達式盡可能移動至靠近數據源的位置,從事后過濾變成事前過濾。
舉個最簡單的栗子就是 WHERE 和 HAVING,我們知道 WHERE 是發生在 GROUP BY 之前的,HAVAING 發生在 GROUP BY 之后。
SELECT UserID, count() FROM datasets.hits_v1
GROUP BY UserID HAVING UserID = 1785640464950496314;
/*
┌──────────────UserID─┬─count()─┐
│ 1785640464950496314 │ 105 │
└─────────────────────┴─────────┘
*/
上面這行 SQL 語句執行的時候雖然沒有任何問題,但很明顯這是一個糟糕的 SQL 語句,因為要先對將近 900 萬的數據進行聚合,然后選擇 UserID 為 1785640464950496314 的記錄。既然如此,那我們為什么不能先把 UserID 為 1785640464950496314 的記錄選出來,然后再單獨進行聚合呢?這樣的話數據量會少很多。

我們看到在優化之后的 SQL 語句將條件從 HAVING 移到了 WHERE,所以將過濾表達式盡可能移動至靠近數據源的位置,在計算之前先將無用數據過濾掉,這個過程就是謂詞下推。
當然謂詞下推不僅僅是這里的 HAVING,子查詢也支持,舉個栗子,我們要根據 UserID 從 hits_v1 表中查詢幾個用戶的記錄,但是這些值必須存在於 visits_v1 的 UserID 字段中。
SELECT UserID, URL FROM datasets.hits_v1
WHERE UserID IN (329024891984319329, 3341630990649416532, 3444082748272603552);
顯然這是非常簡單的,但如果我們規定 UserID 還必須要出現在 visits_v1 表的 UserID 字段中,那么要怎么做呢?最簡單的做法就是一個條件即可。
SELECT UserID, URL
FROM datasets.hits_v1
WHERE UserID IN (329024891984319329, 3341630990649416532, 3444082748272603552)
AND UserID IN (SELECT UserID FROM datasets.visits_v1);
但很明顯這條語句就不是最優解,因為子查詢會掃描全表,也就是 visits_v1 會全量讀取。既然 UserID 要在兩個表中都出現,那么就應該優先把過濾條件放在子查詢里面。
SELECT UserID, URL
FROM datasets.hits_v1
WHERE UserID IN
(SELECT UserID -- 數據量大的話,還可以進行去重
FROM datasets.visits_v1
WHERE UserID IN (329024891984319329, 3341630990649416532, 3444082748272603552));
這種做法顯然更優,因為 visits_v1 不需要全量讀取,但 ClickHouse 目前還做不了這種優化,ClickHouse 所能做的子查詢謂詞下推還是很有限的。當然不光是子查詢,相比 Hive,ClickHouse 所做的優化非常有限,不同的 SQL 語句效率相差十倍以上都是很正常的,因為我們寫 SQL 就不可以肆無忌憚。
作為表進行 JOIN 的子查詢會消除重復字段
如果子查詢中重復選擇了某個字段,那么當它作為表進行 JOIN 的時候,會去除重復字段。
EXPLAIN SYNTAX
SELECT a.UserID, b.VisitID, a.URL
FROM datasets.hits_v1 a LEFT JOIN (
SELECT UserID, UserID, VisitID
FROM datasets.visits_v1
) b
USING(UserID) LIMIT 3
這里進行 JOIN 的右表是一個子查詢,而在這個子查詢里面我們選擇了兩次 UserID,那么 ClickHouse 會進行優化,變成只選擇一次。

可能有人好奇,如果我給第二個 UserID 起一個別名會怎么樣呢?答案是即使起了別名,仍然只會選擇一次。
注意:這里的子查詢在 JOIN 的時候會刪除重復字段,但如果不是在 JOIN 的時候就不一樣了。
EXPLAIN SYNTAX
SELECT UserID, URL
FROM (
SELECT UserID, UserID, URL
FROM datasets.hits_v1
) LIMIT 3
這里的子查詢當中我們選擇了兩個 UserID,那么 ClickHouse 會不會變成一個呢?

我們看到並沒有優化掉,所以 ClickHouse 所做的優化還是比較有限的。
聚合計算外推
什么是聚合計算外推呢?舉個栗子:
SELECT sum(RequestNum * 2) FROM datasets.hits_v1;
你覺得上面的 SQL 有能夠優化的地方嗎?我們看看 ClickHouse 是如何做的。

沒優化的時候,相當於是在 sum 之前先給每一條數據做一次乘法運算,然后進行 sum;優化之后則是先進性 sum,最后只對總和進行一次乘法運算,顯然后者更優。
聚合函數消除
我們在使用 GROUP BY 的時候有一個限制,那就是 SELECT 中沒有使用聚合函數的字段必須出現在分組字段中。舉幾個栗子:
-- 字段 b 出現在了 SELECT 中,並且沒有使用聚合函數,所以它一定要出現在分組字段(GROUP BY)中
-- 但沒有出現,所以報錯
SELECT a, b, count(c) FROM t GROUP BY a;
-- 此時沒有問題,a 和 b 都出現在 GROUP BY 中
-- 至於字段 c,它是以 count(c) 的形式出現的,使用了聚合函數,所以沒問題
SELECT a, b, count(c) FROM t GROUP BY a, b;
-- 這條語句也是非法的,因為 b 沒有出現在 GROUP BY 中,至於 count(b) 和 b 無關
-- 既然 SELECT 中出現了沒有使用聚合函數的字段 b,那么它就必須要出現在 GROUP BY 中
SELECT a, b, count(b), count(c) FROM t GROUP BY a;
當然這些屬於基礎內容了,我主要想表達的是,我們可不可以對分組字段使用聚合函數呢?比如說:
SELECT max(UserID), max(Age), min(Age), sum(Age) FROM datasets.hits_v1 GROUP BY Age
很明顯是可以的,但是這么做沒有任何意義,因為分組就是把分組字段對應的值相同的歸為一組,比如這里的 age,所以每一組的 age 的值都是一樣的。既然都一樣,那么做聚合就沒有太大意義,因此 ClickHouse 會那些對分組字段使用 min、max、any 的聚合函數給刪掉。因為每一組的所有值都是一樣的,最小值、最大值、第一行的值,三者之間沒差別。

我們看到聚合函數 min、max 被剝掉了,只留下了 Age 字段,因為分組字段的值都是一樣的,min、max、any 沒有意義。但聚合函數僅限於 max、min、any,如果是 sum 就不會了,雖然從業務的角度上來說也沒有太大意義,但畢竟 sum 涉及到加法運算,所以它不會被剝掉。
刪除重復的 ORDER BY KEY
類似於消除重復字段,如果指定了多個相同的排序字段,那么只會保留一個。
EXPLAIN SYNTAX SELECT UserID FROM datasets.hits_v1
ORDER BY Age, Age, Age DESC, Age DESC;

我們看到即使不同的排序,也會只保留相同排序字段的第一個,因為同一個字段即升序又降序本身就很奇怪。
刪除重復的 LIMIT BY KEY
還記得 LIMIT BY 嗎?"LIMIT N BY 字段" 表示按照字段進行分組,然后選出每組的前 N 條數據。如果 BY 后面的字段重復了,那么也會刪除掉。
EXPLAIN SYNTAX SELECT UserID, URL FROM datasets.hits_v1
LIMIT 3 BY UserID, UserID

刪除重復的 USING KEY
USING 也是如此,直接看例子吧。
EXPLAIN SYNTAX
SELECT a.UserID, a.UserID, b.VisitID, a.URL, b.UserID
FROM datasets.hits_v1 a LEFT JOIN datasets.visits_v1 b USING(UserID, UserID);
USING 里面指定了兩個 UserID,那么會變成一個。

USING 里面指定了兩個 UserID,那么會變成一個,即使我們指定了前綴,比如 USING(a.UserID, b.UserID),那么依舊會被優化成一個 UserID。
但是說實話,刪除重復的 ORDER BY KEY、LIMIT BY KEY、USING KEY,正常情況下很難出現,因為誰會沒事故意將一個字段重復寫兩遍啊。當然字段多了倒是有可能發生,因為字段一多就可能忘記某個字段已經寫過一遍了,但是字段少的情況下幾乎不可能發生。
所以這個 ClickHouse 的優化機制有點把人當傻子,大概感覺就是當你寫了一個 1 + 1 = 3,那么它能幫你改成 1 + 1 = 2,然而查詢一旦復雜,它就無法優化了。所以一切還需要我們來保證,當然后續 ClickHouse 的優化機制會變得越來越完善。
標量替換
這個主要體現在 WITH 子句上面,我們最開始介紹 WITH 子句的時候說過,WITH 子句可以給一個普通的表達式賦值,也可以給一個查詢賦值,但查詢只能返回一行數組。最終會將其作為一個標量,后續查詢時直接用這個標量進行替換即可。這背后也是 ClickHouse 給我們做的優化,舉個例子:
WITH (SELECT sum(data_uncompressed_bytes) FROM system.columns) AS total_bytes
SELECT database,
(sum(data_uncompressed_bytes) / total_bytes) * 100 AS database_disk_usage
FROM system.columns
GROUP BY database
ORDER BY database_disk_usage DESC
注意 WITH 語句中的 total_bytes,它是對 data_uncompressed_bytes 進行 sum 所得到的結果,如果在查詢中多次使用 total_bytes,那么難道每次都要計算一遍嗎?顯然不是的,這個值是提前算好的,是一個標量,因為只有一行數據,如果都多列就是一個元組。后續使用的都是已經算好的值。
三元運算符優化
如果開啟了 optimize_if_chain_to_multiif 參數,那么三元運算符會被替換成 ,multiIf 函數,之前說過,這里就不再贅述了。
查詢優化
介紹了一些 ClickHouse 的優化規則,我們來看一下在編寫 SQL 時如何手動進行優化,或者說有哪些可以優化的點。因為 ClickHouse 的優化規則(或者說內置的優化器)實在太簡單了,但凡有點關系型數據庫經驗的人都不會那么寫,因此在編寫 SQL 語句的時候只能靠我們保證質量。那么來看看都有哪些注意的點。
PREWHERE 替代 WHERE
PREWHERE 和 WHERE 語句的作用相同,用來過濾數據,但是 PREWHERE 只支持 MergeTree 系列的引擎。WHERE 語句是讀取所有的字段,然后進行數據過濾,而 PREWHERE 則是指定了哪些字段就讀取哪些字段。比如 age > 18 and length > 160,WHERE 的話會讀取全部字段,然后進行數據過濾,再根據 SELECT 中指定的字段進行丟棄。PREWHERE 則是只讀取 age 和 length 兩個字段,因為過濾條件只有這兩個字段,而將數據過濾之后,再根據 SELECT 中指定的字段進行補全(或丟棄)。
所以兩者的區別在於讀取的數據量不同,當查詢列明顯多於篩選列時,使用 PREWHERE 可以十倍提升性能。當然這些我們之前在介紹子句的時候說過了,並且我們說過 ClickHouse 會自動將 WHERE 優化成 PREWHERE,因此我們直接用 WHERE 就好。當然我們還說了有幾種情況,ClickHouse 不會自動優化,因為在這幾種情況下,優化帶來的性能提升不大,具體可以回去看看。
EXPLAIN SELECT * FROM datasets.hits_v1 WHERE UserID = 610708775678702928

在使用 * 的時候,ClickHouse 會自動展開成所有字段,然后重點是我們看到 WHERE 被替換成了 PREWHERE,證明確實會自動優化,當然我們說過可以通過設置 optimize_move_to_prewhere 為 1、0 進行開啟、關閉,默認是開啟的。另外這個配置可以通過 set 設置,那么它位於哪里呢?沒錯,顯然是 users.xml 中。
數據采樣
當我們要求數據的實時性高於數據的准確性時,數據采樣就很有用了。記得在大四實習的時候,當時負責給各大上市公司做審計,由於數據量非常龐大,算一次要花上好幾個小時。所以每次都先采樣,只算百分之 10 到百分之 20 的數據,如果得出來的結果符合正常預期,那么再跑全量數據,這樣會穩妥一些。如果上來就跑全量數據,最后發現結果算的不對就尷尬了。
當然我當時選擇采樣只是簡單的對程序的准確性進行一些檢測,但實際生產中的程序基本上都是准確的,這個時候如果用戶執行了一個查詢,那么為了很快的給出結果,選擇隨機采樣是最合適的方式。以我之前的經驗,如果數據傾斜不嚴重的話,那么采樣 10% 的數據和全量數據計算出來的結果差別很小,當然具體怎么做還是要取決於你的業務。
SELECT ... FROM ... SAMPLE 0.1
WHERE ...
SAMPLE 放在 FROM 之后、WHERE 之前,至於具體用法之前已經說過了,可以回頭看一下。
列裁剪與分區裁剪
ClickHouse 非常適合存儲大數據量的寬表,因此我們應該避免使用 SELECT * 操作,這是一個非常影響的操作。應當對列進行裁剪,只選擇你需要的列,因為字段越少,消耗的 IO 資源就越少,從而性能就越高。
而分區裁剪就是只讀取需要分區,在過濾條件中指定,所以設計一個合適的分區表對后期查詢是非常有幫助的。。
ORDER BY 應當於 WHERE、LIMIT 一起使用
對千萬級以上的數據集進行排序的時候一定要搭配 WHERE 或 LIMIT 使用,可能有人覺得我只是排個序而已,為啥還要有這么多限制。因為事實上我們很少會對數據進行全局排序,而且數據量一大,全局排序的話內存很容易爆掉;如果設置了 max_bytes_before_external_sort,那么全局排序會在磁盤上進行,此時速度又是一個難以忍受的地方。因此在使用 ORDER BY 的時候,需要搭配 WHERE、LIMIT。
避免構建虛擬列
虛擬列指的就是我們自己構造出來的字段,而在原表中是沒有的,舉個栗子:
SELECT A, A + 1 FROM table;
A 是表中的字段,但 A + 1 明顯不是,它是我們構造出來的,所以叫虛擬列。但如果非必須的話,最好不要再結果集上構造虛擬機列,因為虛擬列非常消耗資源性能,可以考慮在拿到數據之后由前后端進行處理。
uniqCombined 替代 count(DISTINCT)
我們之前說過 ClickHouse 提供了一些語法糖,例如這里的 count(DISTINCT column) 實際上就是 countDistinct(column),只不過 ClickHouse 提供了類似於關系型數據庫中 count(DISTINCT) 語法。並且在具體執行的時候,底層都對應 uniqExact 函數,舉個例子:
SELECT count(DISTINCT UserID), countDistinct(UserID), uniqExact(UserID)
FROM datasets.hits_v1;

但還是建議使用 count(DISTINCT),因為這幾個都是等價的,那么自然選一個看起來最熟悉的。那么這個我們說的 uniqCombined 有什么關系呢?原因是 uniqExact 是精確去重並統計數量,如果我們在數量上對統計的數據的誤差有一定的容忍性,那么可以使用 uniqCombined,該函數使用類似 HyperLogLog 的算法,在速度上可以提升 10 倍以上,但犧牲了一些准確率。

JOIN 操作
在介紹 JOIN 之前我先說兩句,很多公司在設計關系型數據庫的表結構時,都會遵循相應的范式,但對於數據倉庫而言是完全不需要的,數倉的重點在於分層。對於 OLAP 型的列式存儲數據庫而言,尤其是 ClickHouse,能不用 JOIN 就不用 JOIN,最好是單表操作,因此這就需要我們保證數據有冗余度,但這在數倉建設中完全 OK 。並且對於 ClickHouse 而言,它的 JOIN 也是比較奇葩的,那么它是怎么做的呢?
首先不管是 LEFT 還是 RIGHT,當 A 表和 B 表進行 JOIN 的時候,ClickHouse 都會將 B 表加載到內存,然后遍歷 A 表數據,查詢 B 表中有沒有能與之關聯上的數據,因此這就引出了第一個優化的原則:當大小表 JOIN 的時候,要保證小表在右側。
-- hits_v1 的數據量要遠大於 visits_v1,然后我們來 JOIN 試一下
-- 這里為了避免輸出大量信息,我們使用 count(*) 代替
SELECT count(*) FROM datasets.hits_v1 a
LEFT JOIN datasets.visits_v1 b
USING(CounterID);
-- 上面是 hits_v1 作為左表進行左關聯,等價於如下:將 hits_v1 作為右表進行右關聯
SELECT count(*) FROM datasets.visits_v1 b
RIGHT JOIN datasets.hits_v1 a
USING(CounterID);

我們看到效率確實有差異,而 JOIN 之后的數據量比 hits_v1 表還要多,說明中間產生了笛卡爾積。如果不想產生笛卡爾積,那么只需要在 LEFT JOIN 和 RIGHT JOIN 的前面加上 ANY 即可,默認是 ALL。
但還是上面那句話,能不用 JOIN 就不要用 JOIN,當涉及到兩張表的時候,看看是否可以用子查詢來替代。
然后是謂詞下推,我們舉個栗子:
SELECT a.UserID, a.Age, b.CounterID
FROM datasets.hits_v1 a
LEFT JOIN datasets.visits_v1 b
USING(CounterID) WHERE a.EventDate = '2020-04-17'
上面會先對兩張表進行 JOIN,完事之后再進行過濾,既然如此的話,那么為什么不能先過濾然后再進行 JOIN 呢?
SELECT a.*, b.CounterID
FROM (SELECT UserID, Age FROM datasets.hits_v1 WHERE EventDate = '2020-04-17') a
LEFT JOIN datasets.visits_v1 b
USING(CounterID)
下面的做法會比上面要快,因為在 JOIN 之前就將數據過濾掉了一部分,不要小看這一點,有時對性能的影響很大。並且 ClickHouse 不會主動幫我們發起謂詞下推的操作,需要我們自己手動完成。可能有人好奇,如果將過濾條件放在 ON 子句后面會怎么樣,答案是會報錯,因為 ClickHouse 不允許 ON 子句中出現過濾條件。
小結
以上就是 ClickHouse 關於優化方面的內容,說實話都比較簡單,總之重點就是我們在寫 SQL 的時候一定要注意,不能夠隨心所欲,因為 ClickHouse 所能做到的優化是非常有限的。
