本文介紹了PolarDB IMCI中GroupJoin算子的一些限制條件和實現方式,以及其他數據的一些相關實現。閱讀本文前請先了解基礎的HASH JOIN與HASH GROUP BY算法。
背景
SELECT
key1,
SUM(sales) as total_sales
FROM
fact_table LEFT JOIN dimension_table ON fact_table.key1 = dimension_table.key1
GROUP BY
fact_table.key1
ORDER BY
total_sales
LIMIT 100;
在PolarDB IMCI中,類似以上查詢語句的執行計劃是先執行一遍HASH JOIN,再執行HASH GROUP BY key1。在這兩個操作中,都會使用key1創建哈希表(注意fact_table.key1 = dimension_table.key1),執行計劃說明如下:
HASH JOIN:使用dimension_table.key1建哈希表,使用fact_table.key1查哈希表并輸出數據;
HASH GROUP BY:使用fact_table.key1建哈希表,寫入哈希表的過程中做聚合運算。
從執行效率的角度來看,這兩個操作可以合并成一個,使用dimension_table.key1建哈希表以及做聚合運算,使用fact_table.key1查哈希表以及做聚合運算,因此節省了使用fact_table.key1建哈希表的時間。這種將HASH JOIN與HASH GROUP BY兩個算子合并成一個的操作,就是GroupJoin。
從執行效率的角度來看,將這兩個操作合并成一個操作,不僅可以減少一次建哈希表的操作,還可以減小中間結果大小。因為JOIN是一個可能使“結果集膨脹”的運算,一張表的一行可能會匹配上另一張表的多行,最壞情況下便是笛卡兒積:N行的表與M行的表JOIN的結果最大可能是N×M的結果集。因此在HASH JOIN+HASH GROUP BY的執行方式中,一張N行的哈希表可能會輸出N×M×S行結果(S代表selectivity,0≤S≤1),然后在HASH GROUP BY的grouping操作中再被聚合成一張新的哈希表,這會造成資源浪費。即使是上面例子中“事實表”(大表,大小為M)與“維度表”(小表,大小為N)的LEFT OUTER JOIN,且key1都是unique key,也是從一張N行的哈希表, 經過HASH JOIN輸出M行結果,然后聚合成M行的哈希表。相對而言,GroupJoin只需要在N行的哈希表中完成join&aggr運算,不僅中間結果變少了,同時內存占用也變小了。
基于以上考慮,PolarDB MySQL版在PolarDB IMCI中增加了GroupJoin算子。
算法設計
概述
IMCI里的GroupJoin實現,是HashJoin與HashGroupby兩個算子的融合:
先使用左表(小表)建立哈希表,涉及左表的aggr函數會在建哈希表的時候直接運算掉。這個過程與對左表聚合(i.e., HashGroupby left_table)的操作是相同的。
使用右表(大表)查哈希表,查詢命中則在hash table entry上運算涉及右表的aggr函數,否則丟棄或者直接輸出。
以上介紹了IMCI GroupJoin算法的基本思路,下文會對算法進行詳細的描述以及介紹簡化的方法。
限制條件
出于實現的復雜度考慮,相對于理論上最完備的GroupJoin實現,PolarDB MySQL版做了如下幾點限制:
group by key要完全匹配某一邊,且只能是某一邊的join key,雖然某些情況下join key的一部分,也能唯一定義這個key(i.e., functional dependency);
RIGHT JOIN、GROUP BY RIGHT的場景,要求right keys是unique keys。否則可能會轉成LEFT JOIN、GROUP BY LEFT的方式,或者不使用GroupJoin;
任意一個aggr函數只能單獨引用左表,或者單獨引用右表;如果原GROUP BY算子中的aggr函數同時引用了左右兩個表(e.g., SUN(t1.a+t2.a)),則不適用GroupJoin。
算法
INNER JOIN/GROUP BY LEFT
此場景如下SQL所示:
l_table INNER JOIN r_table
ON l_table.key1 = r_table.key1
GROUP BY l_table.key1
假設實際執行順序與SQL描述一樣,且Join過程中不會動態換邊。
使用左表建哈希表,并且創建哈希表的過程中直接運算涉及左表的aggr函數;涉及右表的aggr函數,對應設一個“repeat count”,這等同于一個hash table entry對應的payload的數量;
在join過程中,使用右表查哈希表,如果不匹配,則右表的行直接被丟棄;如果匹配,左表的aggr context的“repeat count”會增加1,右表的aggr函數直接進行運算;
join完成后,只輸出曾經被匹配上的hash table entry的aggr結果,沒有被匹配上的hash table entry全部忽略;
輸出aggr結果時,要考慮“repeat count”,例如如果一個SUM(expr)的結果是200,“repeat count”是5,則最終結果是1000。
INNER JOIN/GROUP BY RIGHT
此場景如下SQL所示:
l_table INNER JOIN r_table
ON l_table.key1 = r_table.key1
GROUP BY r_table.key1
考慮到l_table.key1=r_table.key1,這種情況被歸到“INNER JOIN, GROUP BY LEFT”里。
LEFT OUTER JOIN/GROUP BY LEFT
此場景如下SQL所示:
l_table LEFT OUTER JOIN r_table
ON l_table.key1 = r_table.key1
GROUP BY l_table.key1
使用左表建哈希表,建哈希表的過程中運算左表的aggr函數;涉及右表的aggr函數,對應設一個“repeat count”;
在join過程中,使用右表查哈希表,如果不匹配,則右表的行直接被丟棄;如果匹配,左表的aggr context的“repeat count”會增加1,右表的aggr函數直接進行運算;
與INNER JOIN不同,此場景中join完成后,被匹配上的hash table entry的aggr結果直接輸出,沒有被匹配上的每個hash table entry單獨成為一個GROUP,對應的右表的aggr函數的輸入都是NULL。
LEFT OUTER JOIN/GROUP BY RIGHT
此場景如下SQL所示:
l_table LEFT OUTER JOIN r_table
ON l_table.key1 = r_table.key1
GROUP BY r_table.key1
使用左表建哈希表,建哈希表的過程中運算左表的aggr函數;涉及右表的aggr函數,對應設一個 “repeat count”;
在join過程中,使用右表查哈希表,如果不匹配,則右表的行直接被丟棄;如果匹配,左表的aggr context的“repeat count”會增加1,右表的aggr函數直接進行運算;
與其他場景不同,此場景中join完成后,被匹配上的hash table entry的aggr結果直接輸出,沒有被匹配上的所有hash table entry成為一個GROUP,對應的右表的aggr函數的輸入都是NULL。
RIGHT OUTER JOIN/GROUP BY LEFT
此場景如下SQL所示:
l_table RIGHT OUTER JOIN r_table
ON l_table.key1 = r_table.key1
GROUP BY l_table.key1
使用左表建哈希表,創建哈希表的過程中運算左表的aggr函數;涉及右表的aggr函數,對應設一個“repeat count”;
與其他場景不同,此場景在join過程中,使用右表查哈希表,如果匹配,左表的aggr context的“repeat count”會增加1,右表的aggr函數直接進行運算;如果不匹配,則右表的所有不匹配的行成為一個GROUP,對應的左表的aggr函數結果都是NULL;
與其他場景不同,此場景在join完成后,被匹配上的hash table entry的aggr結果直接輸出,沒有被匹配上的所有hash table entry全都忽略。
RIGHT OUTER JOIN/GROUP BY RIGHT
此場景如下SQL所示:
l_table RIGHT OUTER JOIN r_table
ON l_table.key1 = r_table.key1
GROUP BY r_table.key1
限制條件
要求r_table.key1必須是distinct的,否則這種join是不合法的;如果不能確定r_table.key1是distinct的,則需要在優化器里將這種join+groupby轉成LEFT OUTER JOIN、GROUP BY LEFT。
執行步驟
使用左表建哈希表,建哈希表的過程中運算左表的aggr函數;涉及右表的aggr函數,對應設一個“repeat count”;
與其他場景不同,此場景在join過程中,使用右表查哈希表,如果匹配,直接輸出左右表的aggr結果;如果不匹配,也輸出aggr結果,此時左表的aggr結果都是NULL;
與其他場景不同,此場景在join完成后,GroupJoin即完成,不需要處理任何hash table entry。
運行時落盤(spilling)處理
GroupJoin的落盤處理,類似于partition-style的HashJoin&HashGroupby的落盤處理,方法如下:
GroupJoin的整體算法采用分區(partition)的方式;
使用左表構建哈希表時,內存中的partition,構建hash table的算法與算法一節描述一致;
使用左表構建哈希表時,不在內存中的partition,刷到磁盤中對應的臨時文件,后續新寫入這個partition 的數據也會直接刷到磁盤中對應的臨時文件;落盤的partition會建立一個bloomfilter,方便后續查找的時候快速過濾掉不可能匹配的右表數據;
完成左表的哈希表構建后,使用右表數據查哈希表:
相關實現
2011年的一篇論文Accelerating Queries with Group-By and Join by Groupjoin(簡稱 paper_1)從理論角度闡述了GroupJoin在不同查詢計劃中的可行性,但是不涉及太多實現的細節。論文描述了GroupJoin運算時的約束,以及適用GroupJoin的場景,比如不同aggr函數如何處理等,描述得比較抽象,整體可讀性不是很好。
2021年有一篇論文A Practical Approach to Groupjoin and Nested Aggregates (簡稱 paper_2)描述了如何在(內存)數據庫中高效地實現GroupJoin算子。這篇論文的幾個重點是:
1. 解關聯子查詢的算法中使用GroupJoin
在解決“關聯項上方有 GROUP BY”這種關聯子查詢時,有一種方式是引入“MagicSet”操作(也就是 table distinct)并在上方增加一個JOIN+GROUP BY,從而完成子查詢的去關聯。這種模樣的執行計劃,恰好符合GroupJoin的適用場景。由于IMCI目前也使用了類似的解關聯算法,但是目前暫時不能生成此類shared children的執行計劃。
2. Eager aggregation
簡單來說,就是在hash build的過程中,把hash build那一邊的aggr函數直接計算掉,而不是為每個hash table entry保留完整的payload,最后再運算aggr函數。這與IMCI的實現思路是一樣的。
3. 使用memoizing的方式解決并發查哈希表做聚合運算時的沖突
舉個極端的例子:hash probe的過程中,所有數據都命中hash table的同一個entry,因此要在此entry進行聚合運算(比如SUM(2 * col)),因此需要使用同一個“aggr context”運行aggr函數;例如,對于 SUM() 聚合函數來說,就是不斷使用同一個sum_value進行加法操作。即使是原子變量的加法操作,遇到contention也是有性能問題的,更何況是通用的聚合函數運算。這篇論文的思路是為所有“沒有搶到entry所有權”的線程建立各自的local hash table進行運算,對于每個entry,利用CAS指令設置一個owner thread id,除了owner thread外,后續到來的thread都使用自己的local hash table進行運算,最后再將這些local hash table合并到global hash table里。
4. 并不是所有場景都適用于GroupJoin
有些場景更適用于Join+Groupby。舉個例子,假設hash build一邊的selectivity很低,hash probe完成后,這一邊的大多數行都不會被選中,那么就會遇到了一個兩難的境地了:
如果在hash build的時候,同時把這一邊的聚合運算做掉(i.e.,eager aggregation),則不需要保留hash table的payload,節省了內存,但是因為join的selectivity很低,大多數行最后都不會被選中,因此這些提前運算的agg運算都浪費掉了;
如果不提前做aggr運算,則需要多花一些內存;但是在join的selectivity很高(大多數行都被選中)的情況下,這些內存本可以通過提前進行的aggr運算節省掉。
所以,如果join的selectivity很低,那么更好的方式或許是:join完后,得到非常少的group,然后在HashGroupby中做局部性很強的聚合操作。因此,這篇論文提出了幾種場景下不同的實現。而為了知道一個查詢適用于哪種場景,需要優化器提供selectivity和cardinality的估算,因此論文后面配套提供了一些優化器里用到的估算方法。
從實現者的角度看來,上面所說的“兩難的境地”其實并不是太大的問題,這是因為:
PolarDB IMCI幾乎總是用小的一邊(小表)建哈希表;
即使JOIN的selectivity很低,使用eager aggregation提前運算聚合函數的策略,雖然浪費了針對一個小表的運算時間,但是無論如何也節省了內存,并不是一無所獲;這種情況下,相對于HashJoin+HashGroupby,也算是時間換空間。
在IMCI的實現里面,除了上文說的RIGHT JOIN+GROUP BY RIGHT場景,PolarDB IMCI幾乎總是認為GroupJoin的執行效率是優于HashJoin+HashGroupby。
從作者以及論文里面的測試情況來看,上述的兩篇論文應該都來自慕尼黑大學的hyper數據庫團隊。除hyper外,目前還沒有見到其他數據庫實現GroupJoin算子,但是應該有“shared hash table”操作的其他實現方式,后續再進一步討論。
GroupJoin在TPCH中的應用
TPCH是一個常用的測試一個AP系統的分析查詢能力的benchmark。在TPCH的22條查詢中,有不少都是適用GroupJoin算子的。不過,除了TPCH Q13,其他的查詢語句都需要經過一定改造才能適用GroupJoin算子。
Q13
TPCH Q13,可以直接適用GroupJoin算子:
select
c_count,
count(*) as custdist
from
(
select
c_custkey,
count(o_orderkey) as c_count
from
customer
left outer join orders on c_custkey = o_custkey
and o_comment not like '%pending%deposits%'
group by
c_custkey
) c_orders
group by
c_count
order by
custdist desc,
c_count desc;
在IMCI中,如果不使用GroupJoin,則執行計劃如下:
如果使用GroupJoin,執行計劃如下:
Q3
對TPCH的Q3而言,GroupJoin的優化需要經過一系列等價變換:
select
l_orderkey,
sum(l_extendedprice * (1 - l_discount)) as revenue,
o_orderdate,
o_shippriority
from
customer,
orders,
lineitem
where
c_mktsegment = 'BUILDING'
and c_custkey = o_custkey
and l_orderkey = o_orderkey
and o_orderdate < date '1995-03-15'
and l_shipdate > date '1995-03-15'
group by
l_orderkey,
o_orderdate,
o_shippriority
order by
revenue desc,
o_orderdate
limit
10;
Q3的一種可行的執行計劃如下(IMCI中的執行計劃): DERKEY,TEMPTABLE
由于此SQL的grouping keys是l_orderkey、o_orderdate、o_shippriority,與任何一個join keys都不相同,因此并不能直接適用GroupJoin。通過一些等價推導,可得出以下結論:
由于lineitem與orders表的join predicate是l_orderkey=o_orderkey,而且是INNER JOIN,因此可以判斷出,這個join的結果集里面,l_orderkey=o_orderkey;
由于l_orderkey=o_orderkey,因此 GROUP BY l_orderkey、o_orderdate、o_shippriority 等價于 GROUP BY o_orderkey、o_orderdate、o_shippriority;
由于o_orderkey是orders表的PRIMARY KEY,因此每一個o_orderkey都能直接確定唯一的o_orderdate和o_shippriority (i.e.,o_orderdate and o_shippriority functionally depend on o_orderkey);
由于o_orderkey能唯一確定o_orderdate和o_shippriority,因此GROUP BY o_orderkey、o_orderdate、o_shippriority等價于GROUP BY o_orderkey;
由上面的推導,可以將Q3的group by clause等價變換成GROUP BY o_orderkey,如此可適用于GroupJoin了:KEY,TEMPTABLE3.SUM(LINETTEM.EXTENDEDPRTCE*1.00-LUNETEM._DLSCOL
這種“functional dependency”的推導,對優化器有一定要求。目前MySQL優化器中,實現了部分functional dependency的推導,但是依然無法推導出上面的GROUP BY o_orderkey變換。經過嘗試,發現SQL SERVER是可以推導出GROUP BY o_orderkey變換的,這方面有比較完備的理論,但是IMCI目前在這方面還沒有完全實現。在TPCH里面,Q3/Q4/Q10/Q13/Q18/Q20/Q21都有這種特征,如果能做這種等價推導,將可以縮短GROUP BY的grouping keys,提高聚合操作的速度。
Q10
TPCH的Q10也不能直接適用GroupJoin:
select
c_custkey,
c_name,
sum(l_extendedprice * (1 - l_discount)) as revenue,
c_acctbal,
n_name,
c_address,
c_phone,
c_comment
from
customer,
orders,
lineitem,
nation
where
c_custkey = o_custkey
and l_orderkey = o_orderkey
and o_orderdate >= date '1993-10-01'
and o_orderdate < date '1993-10-01' + interval '3' month
and l_returnflag = 'R'
and c_nationkey = n_nationkey
group by
c_custkey,
c_name,
c_acctbal,
c_phone,
n_name,
c_address,
c_comment
order by
revenue desc
limit
20;
如果要使用GroupJoin,需要做以下兩個變換:
通過等價變換把grouping keys變成c_custkey(customer表的PRIMARY KEY),這個變換與上文的Q3類似;
Join order要調整,使得customer表的JOIN在最外層。
其中1總是有益的,但是2中join order的調整,不一定是有益的。
Q17
TPCH的Q17包含一條關聯子查詢:
select
sum(l_extendedprice) / 7.0 as avg_yearly
from
lineitem,
part
where
p_partkey = l_partkey
and p_brand = 'Brand#44'
and p_container = 'WRAP PKG'
and l_quantity < (
select
0.2 * avg(l_quantity)
from
lineitem
where
l_partkey = p_partkey
);
其去關聯的方式有幾種,目前IMCI針對scalar aggr實現的兩種去關聯算法得到的執行計劃分別是:
RE
這些執行計劃都不適用GroupJoin算子。如果采用MagicSet算子的去關聯方式,在移除MagicSet算子之前,會得到一個適合GroupJoin的中間態:
也就是paper_2中所描述的過程: NERALNESTING:DEEORRELATIONOFDEPENDENTSUB-
因此可以適用GroupJoin。目前IMCI部分實現了采用MagicSet算子的去關聯方式,但是不會生成hared children的執行計劃,因此IMCI里面無法對TPCH Q17適用GroupJoin。
Q18
TPCH Q18也是可以適用GroupJoin的,不過依然要利用等價變換轉換執行計劃,才能得到適用GroupJoin的執行計劃。為了方便描述,不失一般性,此處把Q18里的IN子查詢以及最后的ORDER BY去掉:
select
c_name,
c_custkey,
o_orderkey,
o_orderdate,
o_totalprice,
sum(l_quantity)
from
customer,
orders,
lineitem
where
c_custkey = o_custkey
and o_orderkey = l_orderkey
group by
c_name,
c_custkey,
o_orderkey,
o_orderdate,
o_totalprice
對于這個查詢,做如下等價推導:
因為c_custkey是customer表的PRIMARY KEY,因此c_name可以由c_custkey唯一確定(functional dependency);同理o_orderkey是orders表的PRIMARY KEY,o_orderdate與o_totalprice都可以由o_orderkey唯一確定。因此,group by clause可以被等價轉換為GROUP BY c_custkey, o_orderkey;
由于customer表與orders表的join predicate是c_custkey=o_custkey,因此可以斷言,join的結果集中,c_custkey=o_custkey;
由于c_custkey=o_custkey,因此group by clause可以被等價轉換為GROUP BY o_custkey, o_orderkey;
由于o_orderkey唯一確定o_custkey (o_orderkey是orders表的主鍵),因此group by clause可以被等價改寫為GROUP BY o_orderkey。
經過以上等價推導,整個查詢可以被等價改成成類似如下一個SQL:
select
ANY_VALUE(c_name),
ANY_VALUE(c_custkey),
o_orderkey,
ANY_VALUE(o_orderdate),
ANY_VALUE(o_totalprice),
sum(l_quantity)
from
customer,
orders,
lineitem
where
c_custkey = o_custkey
and o_orderkey = l_orderkey
group by
o_orderkey
不帶GroupJoin的執行計劃
帶GroupJoin的執行計劃
上面的等價推導,因為能減少GROUP BY的grouping keys的長度,因此針對常規的執行計劃,也是有用的。
Q20
TPCH Q20的關聯子查詢的pattern與Q17是類似的:采用MagicSet算子的去關聯方式,在移除MagicSet算子之前,會得到一個適合GroupJoin的中間態。
select
...
and ps_availqty > (
select
0.5 * sum(l_quantity) < ! --- scalar aggr --->
from
lineitem
where
l_partkey = ps_partkey < ! --- 關聯項 1 --->
and l_suppkey = ps_suppkey < ! --- 關聯項 2 --->
and l_shipdate >= '1993-01-01'
and l_shipdate < date_add('1993-01-01', interval '1' year)
)
其他
按論文paper_1和paper_2所述,Q5/Q9/Q16/Q21這4條SQL都適用GroupJoin算子,但是暫時還沒找到合適的轉換路徑;通過查詢hyper數據庫的執行計劃(https://hyper-db.de/interface.html#),它的優化器也沒有為這幾條SQL生成帶有GroupJoin的執行計劃。
相關實現
TPCH benchmark里面的許多query都是JOIN+GROUP BY的模式,因此TPCH里有不少的query都能通過GroupJoin優化掉。在論文paper_1里,作者列出Q3/Q5/Q9/Q10/Q13/Q16/Q17/Q20/Q21這些query在使用GroupJoin與不使用GroupJoin兩種情況下的性能:
此處使用的是TPCH 1 GB的數據量。可以看出GroupJoin對優化TPCH類型query有一定的作用(總體時延1932ms降到1295ms)。
在論文paper_2中,則是分別列出了Q3/Q13/Q17/Q18這些query在使用GroupJoin與不使用GroupJoin幾種情況下的性能(TPCH 10 GB數據量):
圖中的幾組線條含義如下:
圖中 “seperate”這一組線條代表“分別做JOIN和GROUP BY”,也就是不使用GroupJoin;
圖中“eager”代表上文所說的“eager aggregation”這一優化;
圖中“memoizing”代表上文所說的“如何處理并發查哈希表做aggr運算時的沖突”這一優化。 可以看到,在 Q3/Q13/Q17/Q18 這4條query中:
"memoizing" 的方式幾乎總是與一般的HASH JOIN+HASH GROUP BY的方式有著類似的性能;
eaegr aggregation的方式只在Q13這一條query中占有優勢,其余都不占優勢。
按照圖中的數據可得:不同的處理方法,在不同場景中差別很大。因此這個數據呼應了作者提出的 “GroupJoin 的執行方式,需要優化器提供更準確的統計信息,以選擇最優的執行方式”這一觀點,而不是無差別地選擇某一種GroupJoin算法,甚至無差別地選擇使用GroupJoin。
雖然如此,對于這個結論,PolarDB有不同的觀點:
文章中使用tuples per second這個指標來衡量算法的好壞,但是與PolarDB IMCI中得到的結論卻不太一樣。使用IMCI在并發度=32的情況下測試Q3/Q13/Q18這3條query的GroupJoin算子的throughput(單位tuples/s),結果如下:
Query
HashJoin+HashGroupby
GroupJoin
Q3
130 MB
152 MB
Q13
11 MB
33 MB
Q18
315 MB
1 GB
說明Q17在IMCI里暫時無法使用GroupJoin。
這個測試數據與上圖中的數據在量級上是相似的,但是每條query都稍有不同。也許是實現方式的不同,從PolarDB的測試數據中可以觀察到,除了上文說的right join+groupby right情況外,GroupJoin幾乎總是優于HashJoin+HashGroupby的。
對于上面3.a的結論,即“memoizing”的方式幾乎總是與一般的HASH JOIN+HASH GROUP BY的方式有著類似的性能,根據我們的觀察,TPCH的這幾條query只有非常少量的contention,因此memoizing的方式所用的local hash table等,在實際運行時基本不會用到,因此在這幾條query里面,這個算法得到的性能與HASH JOIN+HASH GROUP BY是類似的;論文里引用這幾條query的性能作為對比,其實不說明問題。PolarDB是通過直接加鎖的方式來測試運行時的contention的。
總結
從效果來講,因為GroupJoin在運行時能避免的重復的工作,因此在某些場景能得到比較大的性能提升。這個效果已經在實際應用中得到驗證。因此從結果的角度,GroupJoin是值得實現的。
從通用性來講,GroupJoin并不通用。GroupJoin只適用于equal join+group by且要求grouping keys與任意一邊join keys相同,而且對aggr函數、實現方式等有諸多限制;這是一種特化,而隨之而來的是比較大的實現和維護代價。從開發的角度來說,應該花更大力氣去優化“通用路徑”,利用通用路徑的性能提升來達到優化SQL查詢效率的目的,而不是通過為某個場景尋求定制性的解法。因此從這個角度來說,GroupJoin不是一個好方法。
因此在實現的時候,應該做一定的裁剪或簡化,不追求在一個特化實現里面實現最完備的功能,但是追求最常見場景的效用(性能)最大化。