ClickHouse索引採用唯一聚簇索引的方式,即Part內資料按照order by keys有序,在整個查詢計劃中,如果運算元能夠有效利用輸入資料的有序性,對運算元的執行效能將有巨大的提升。本文討論ClickHouse基於索引的查詢運算元最佳化方式。
在整個查詢計劃中Sort、Distinct、聚合這3個運算元相比其他運算元比如:過濾、projection等有如下幾個特點:1.運算元需要再記憶體中儲存狀態,記憶體代價高;2.運算元計算代價高;3.運算元會阻斷執行pipeline,待所有資料計算完整後才會向下遊輸出資料。所以上運算元往往是整個查詢的瓶頸運算元。
本文詳細討論,3個運算元基於索引的查詢最佳化前後,在計算、記憶體和pipeline阻斷上的影響。
實驗前準備:
後續的討論主要基於實驗進行。
CREATE TABLE test_in_order
(
`a` UInt64,
`b` UInt64,
`c` UInt64,
`d` UInt64
)
ENGINE = MergeTree
ORDER BY (a, b);
表中總共有3個part,每個part資料量4條。
PS: 使用者可以在插入資料前提前關閉後臺merge,以避免part合併成一個,如果part合併成一個將影響查詢並行度,可能對實驗有影響,以下查詢可以關閉後臺merge:system stop merges test_in_order
一、Sort運算元
如果order by查詢的order by欄位與表的order by keys的字首列匹配,那麼可以根據資料的有序特性對Sort運算元進行最佳化。
1.Sort運算元實現方式
首先看下不能利用主鍵有序性的場景,即對於order by查詢的order by欄位與表的order by keys的字首列不匹配。比如下面的查詢:
query_1: EXPLAIN PIPELINE SELECT b FROM read_in_order ORDER BY b ASC
它的執行計劃如下:
┌─explain───────────────────────────────┐
│ (Expression) │
│ ExpressionTransform │
│ (Sorting) │
│ MergingSortedTransform 3 → 1 │
│ MergeSortingTransform × 3 │
│ LimitsCheckingTransform × 3 │
│ PartialSortingTransform × 3 │
│ (Expression) │
│ ExpressionTransform × 3 │
│ (ReadFromMergeTree) │
│ MergeTreeThread × 3 0 → 1 │
└───────────────────────────────────────┘
排序演算法由3個Transform組成,其中
1)PartialSortingTransform對單個Chunk進行排序;
2)MergeSortingTransform對單個stream進行排序;
3)MergingSortedTransform合併多個有序的stream進行全域性sort-merge排序
如果查詢的order by欄位與表的order by keys的字首列匹配,那麼可以根據資料的有序特性對查詢進行最佳化,最佳化開關:optimize_read_in_order。
2.匹配索引列的查詢
以下查詢的order by欄位與表的order by keys的字首列匹配
query_3: EXPLAIN PIPELINE SELECT b FROM test_in_order ORDER BY a ASC, b ASCSETTINGS optimize_read_in_order = 0 -- 關閉read_in_order最佳化
檢視order by語句的pipeline執行計劃
┌─explain───────────────────────────┐
│ (Expression) │
│ ExpressionTransform │
│ (Sorting) │
│ MergingSortedTransform 3 → 1 │
│ MergeSortingTransform × 3 │
│ (Expression) │
│ ExpressionTransform × 3 │
│ (ReadFromMergeTree) │
│ MergeTreeThread × 3 0 → 1 │
└───────────────────────────────────┘
此時order by運算元的演算法
1)首先MergeSortingTransform對輸入的stream進行排序
2)然後MergingSortedTransform將多個排好序的stream進行合併,並輸出一個整體有序的stream,也是最終的排序結果。
這裡有個疑問在關閉read_in_order最佳化的查詢計劃中,系統直接預設了MergeSortingTransform的輸入在Chunk內是有序的,這裡其實是一個預設最佳化,因為order by查詢的order by欄位與表的order by keys的字首列匹配,所以資料在Chunk內部一定是有序的。
3. 開啟最佳化optimize_read_in_order
┌─explain──────────────────────────┐
│ (Expression) │
│ ExpressionTransform │
│ (Sorting) │
│ MergingSortedTransform 3 → 1 │
│ (Expression) │
│ ExpressionTransform × 3 │
│ (ReadFromMergeTree) │
│ MergeTreeInOrder × 3 0 → 1 │
└──────────────────────────────────┘
4. 最佳化分析
開啟optimize_read_in_order後:
二、Distinct運算元
如果distinct查詢的distinct欄位與表的order by keys的字首列匹配,那麼可以根據資料的有序特性對Distinct運算元進行最佳化,最佳化開關:optimize_distinct_in_order。透過以下實驗進行說明:
1. Distinct運算元實現方式
檢視distinct語句的pipeline執行計劃
query_2: EXPLAIN PIPELINE SELECT DISTINCT * FROM woo.test_in_order SETTINGS optimize_distinct_in_order = 0 -- 關閉distinct in order最佳化
┌─explain─────────────────────────────┐
│ (Expression) │
│ ExpressionTransform │
│ (Distinct) │
│ DistinctTransform │
│ Resize 3 → 1 │
│ (Distinct) │
│ DistinctTransform × 3 │
│ (Expression) │
│ ExpressionTransform × 3 │
│ (ReadFromMergeTree) │
│ MergeTreeThread × 3 0 → 1 │
└─────────────────────────────────────┘
Distinct運算元採用兩階段的方式,首先第一個DistinctTransform在內部進行初步distinct,其並行度為3,可以簡單的認為有3個執行緒在同時執行。然後第二個DistinctTransform進行final distinct。
每個DistinctTransform的計算方式為:首先構建一個HashSet資料結構,然後根據HashSet,構建一個Filter Mask(如果當前key存在於HashSet中,則過濾掉),最後過濾掉不需要的資料。
2.開啟最佳化optimize_distinct_in_order
┌─explain────────────────────────────────┐
│ (Expression) │
│ ExpressionTransform │
│ (Distinct) │
│ DistinctTransform │
│ Resize 3 → 1 │
│ (Distinct) │
│ DistinctSortedChunkTransform × 3 │
│ (Expression) │
│ ExpressionTransform × 3 │
│ (ReadFromMergeTree) │
│ MergeTreeThread × 3 0 → 1 │
└────────────────────────────────────────┘
可以看到初步distinct和final distinct採用了不同的transform,DistinctSortedChunkTransform和DistinctTransform。
DistinctSortedChunkTransform:對單個stream內的資料進行distinct操作,因為distinct列跟表的order by keys的字首列匹配,scan運算元讀取資料的時候一個stream只從一個part內讀取資料,那麼每個distinct transform輸入的資料就是有序的。所以distinct演算法有:
DistinctSortedChunkTransform演算法一:
Transform中保留最後一個輸入的資料作為狀態,對於每個輸入的新資料如果跟保留的狀態相同,那麼忽略,如果不同則將上一個狀態輸出給上一個運算元,然後保留當前的資料最為狀態。這種演算法對於在整個stream內部全域性去重時間和空間複雜度都有極大的降低。
DistinctSortedStreamTransform演算法二:(ClickHouse採用的)
Transform對與每個Chunk(ClickHouse中Transform資料處理的基本單位,預設大約6.5w行),首先將相同的資料劃分成多個Range,並設定一個mask陣列,然後將相同的資料刪除掉,最後返回刪除重複資料的Chunk。
3. 最佳化分析
開啟optimize_distinct_in_order後:主要對於第一階段的distinct步驟進行了最佳化,從基於HashSet過濾的演算法到基於連續相同值的演算法。
三、聚合運算元
如果group by查詢的order by欄位與表的order by keys的字首列匹配,那麼可以根據資料的有序特性對聚合運算元進行最佳化,最佳化開關:optimize_aggregation_in_order。
1.聚合運算元實現方式
檢視group by語句的pipeline執行計劃:
query_4: EXPLAIN PIPELINE SELECT a FROM test_in_order GROUP BY a SETTINGS optimize_aggregation_in_order = 0 -- 關閉read_in_order最佳化
┌─explain─────────────────────────────┐
│ (Expression) │
│ ExpressionTransform × 8 │
│ (Aggregating) │
│ Resize 3 → 8 │
│ AggregatingTransform × 3 │
│ StrictResize 3 → 3 │
│ (Expression) │
│ ExpressionTransform × 3 │
│ (ReadFromMergeTree) │
│ MergeTreeThread × 3 0 → 1 │
└─────────────────────────────────────┘
對於聚合運算元的整體演算法沒有在執行計劃中完整顯示出來,其宏觀上採用兩階段的聚合演算法,其完整演算法如下:1.AggregatingTransform進行初步聚合,這一步可以平行計算;2.ConvertingAggregatedToChunksTransform進行第二階段聚合。(PS:為簡化起見,忽略two level HashMap,和spill to disk的介紹)。
2.開啟最佳化optimize_aggregation_in_order
執行計劃如下:
┌─explain───────────────────────────────────────┐
│ (Expression) │
│ ExpressionTransform × 8 │
│ (Aggregating) │
│ MergingAggregatedBucketTransform × 8 │
│ Resize 1 → 8 │
│ FinishAggregatingInOrderTransform 3 → 1 │
│ AggregatingInOrderTransform × 3 │
│ (Expression) │
│ ExpressionTransform × 3 │
│ (ReadFromMergeTree) │
│ MergeTreeInOrder × 3 0 → 1 │
└───────────────────────────────────────────────┘
可以看到開啟optimize_aggregation_in_order後aggregating演算法由三個步驟組成:
1)首先AggregatingInOrderTransform會將stream內連續的相同的key進行預聚合,預聚合後在當前stream內相同keys的資料只會有一條;
2)FinishAggregatingInOrderTransform將接收到的多個stream內的資料進行重新分組使得輸出的chunk間資料是有序的,假設前一個chunk中group by keys最大的一條資料是5,當前即將輸出的chunk中沒有大於5的資料;
3)MergingAggregatedBucketTransform的作用是進行最終的merge aggregating。
FinishAggregatingInOrderTransform的分組演算法如下:
假設有3個stream當前運算元會維護3個Chunk,每一次選取在當前的3個Chunk內找到最後一條資料的最小值,比如初始狀態最小值是5,然後將3個Chunk內所有小於5的資料一次性取走,如此反覆如果一個Chunk被取光,需要從改stream內拉取新的Chunk。
這種演算法保證了每次FinishAggregatingInOrderTransform向下遊輸出的Chunk的最大值小於下一次Chunk的最小值,便於後續步驟的最佳化。
3.最佳化分析
開啟optimize_aggregation_in_order後:主要對於第一階段的聚合步驟進行了最佳化,從基於HashMap的演算法到基於連續相同值的演算法。
1.對於計算方面:最佳化後的演算法,減少了Hash計算,但多了判斷相等的步驟,在不同資料基數集大小下,各有優劣。
2.由於記憶體方面:最佳化前後無差別
3.對於poipeline阻塞:最佳化前後無差別
四、最佳化小結
在整個查詢計劃中Sort、Distinct、聚合這3個運算元運算元往往是整個查詢的瓶頸運算元,所以值得對其進行深度最佳化。ClickHouse透過利用運算元輸入資料的有序性,最佳化運算元的演算法或者選擇不同的演算法,在計算、記憶體和pipeline阻塞三個方面均有不同程度的最佳化。