ClickHouse內幕(3)基於索引的查詢最佳化

京东云开发者發表於2024-06-11

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後:

1.對於計算方面:演算法中只有一個MergingSortedTransform,省略了單個stream內排序的步驟
2.由於記憶體方面:由於MergeSortingTransform是消耗記憶體最大的步驟,所以最佳化後可以節約大量的記憶體
3.對於poipeline阻塞:MergeSortingTransform會阻塞整個pipeline,所以最佳化後也消除了對pipeline的阻塞

二、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,DistinctSortedChunkTransformDistinctTransform

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過濾的演算法到基於連續相同值的演算法。

1.對於計算方面:最佳化後的演算法,省去了Hash計算,但多了判斷相等的步驟,在不同資料基數集大小下,各有優劣。
2.由於記憶體方面:最佳化後的演算法,不需要儲存HashSet
3.對於poipeline阻塞:最佳化前後都不會阻塞pipeline

三、聚合運算元

如果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阻塞三個方面均有不同程度的最佳化。

相關文章