摘要:本文將系統介紹在GaussDB(DWS)系統中影響性能的壞味道SQL及SQL模式,幫助大家能夠從原理層面盡快識別這些壞味道SQL,在調優過程中及時發現問題,進行整改。
數據庫的應用中,充斥着壞味道的SQL,非常影響查詢的性能。壞味道SQL,即由於開發者寫的隨意,導致執行性能較差,需要通過優化SQL語句進行調優的SQL。在GaussDB(DWS)分布式場景下,相對於單機環境,將出現更多的壞味道SQL語句。本文將系統介紹在GaussDB(DWS)系統中影響性能的壞味道SQL及SQL模式,幫助大家能夠從原理層面盡快識別這些壞味道SQL,在調優過程中及時發現問題,進行整改。
從大的方面來看,主要包含不支持下推導致的壞味道、不支持重分布導致的壞味道、數據類型轉換導致的壞味道、全局性操作導致的壞味道、NestLoop類低效運算導致的壞味道和冗余操作導致的壞味道。本文將介紹每一類壞味道的原因,以及如何進行SQL改寫及調優。
一.不支持下推導致的壞味道
在GaussDB(DWS)分布式場景下,數據運算應該全部下推到DN上執行,才能獲得比較好的性能收益。但對於某些場景,數據必須在CN上執行,導致語句無法全部下推到DN運算,會導致兩個主要的瓶頸點:
(1)只有基表掃描在DN執行,需要將大量數據傳輸到CN上,網絡開銷增大。
(2)原先可以在DN上分布式執行的數據,均由CN單個執行,瓶頸加大。
通常情況下,我們不支持不下推函數、復合類型、復雜語法及組合(例如:某些場景的with recursive語法,rollup函數+多count(distinct)語法)的下推,所以應該盡量避免在語句中使用以上元素。在客戶場景中,經常遇到函數不能下推導致的問題,本篇博文重點以函數下推為例,講述如何解決類似的問題。如下圖計划所示,在語句中包含了不支持下推的函數unship_func(),導致整個計划不能下推,計划中出現“_REMOTE_TABLE_QUERY_”的字樣,即會出現上述的瓶頸問題。遇到類似問題,需要根據具體應用場景,為函數設置合理的下推屬性,使其可以下推。
通常來說,函數可以通過可變性和下推維度進行划分,主要包含以下函數屬性:
以上兩個屬性可以通過系統表pg_proc的provalitile和proshippable字段查詢。目前GaussDB以CN/DN行為是否一致作為下推標准,支持大部分immutable和stable函數的下推,以及特定場景少量volatile函數的下推。對於用戶自定義函數,由於數據庫無法知曉函數的行為,因為不知道函數的屬性,因為默認是volatile和unshippable的。包含對應函數的語句將無法下推到DN執行。用戶可以根據函數的行為,判斷返回結果是否恆定,以及是否可以下推,設置對應的屬性。具體的設置方法為:
(1)如果函數的返回結果是恆定的,比如數字計算函數,日期計算函數,則可以為其設置immutable屬性。
(2)如果函數中使用了數據表,且數據表均是復制表的只讀操作且不涉及事務操作(所以DN數據均相同,可以下推到一個DN上執行),則可以為其設置shippable屬性。其余情況則還是不能下推,如果錯誤設置,會引發不可預知錯誤,因此需要慎重設置。
如果無法使函數下推,可以對語句進行改寫,使不涉及函數的部分能夠部分下推到DN執行。例如,對於以下SQL語句:
select unship_func() from t1 join t2 on t1.a=t2.a;
可以改寫為:
select unship_func() from (select * from t1 join t2 on t1.a=t2.a);
這樣,t1和t2 join的部分可以推到DN執行,只有unship_func()的計算是逐行在CN執行的,流程變化如下圖所示,在join結果集較小的情況下,性能也可以得到明顯的提升。
二.不支持重分布導致的壞味道
在share-nothing架構的分布式場景下,數據使用哈希分布在不同DN上,並且通過數據重分布使得中間結果均勻分布在各個DN上進行並行計算,進行執行查詢的加速。所以在執行過程中,一直保持數據能夠均勻分布在DN上,是保證性能的關鍵。
通常情況下,我們需要進行表的關聯(Join)和聚集(Agg)操作,這就需要關聯和聚集列能夠支持重分布,從而進行靈活的重分布操作。在GaussDB(DWS)中,不支持重分布的類型主要有real和double類型,因此使用這兩種類型進行操作時,將導致無法生成重分布的計划,在實際使用時要盡量避免。首先,在進行表定義時,要盡量避免使用這兩種類型,使用numeric類型進行替代。同時,還要避免使用返回值為這兩種類型的函數,進行關聯和聚集運算,例如:ceil, floor, pow, sqrt, 以及一些分析類聚集函數如stddev_samp等。如果必須使用此類函數進行相關運算,需要在計算完對這些類型進行類型轉換,轉換成numeric類型。例如下面的語句:
select count(distinct ceil(a)) from t1;
由於ceil()返回值為double,原始生成的計划是不能下推的:
如果我們顯式將ceil()轉換成numeric類型,得到的下推計划如下:
三.數據類型轉換導致的壞味道
數據庫在進行不同列的比較、計算運算時,如果類型不同,需要進行類型轉換。通常情況下,由優化先低的類型往優先級高的類型轉換,字符串的優先級較數字較低,同時數字類型,精度低的會向精度高的轉換。在應用中,經常遇到的是,字符串和數字進行比較,導致字符串需要轉成數字進行比較操作。由於數據庫的基本調優都是基於基表列的,數據轉換后就會帶來以下性能問題:
(1)無法使用索引。由於索引是基於列的排序構造的,字符串轉換成數字后的排序性與字符串不一致(’2’與’12’,字符串’2’大,數字12大),故無法使用索引,對於返回數據量少的場景,使用全表掃描帶來性能問題。
(2)無法進行分區剪枝。GaussDB(DWS)支持range分區,即根據分區鍵值的范圍創建不同的分區。當需要進行分區鍵上的范圍操作時,進行分區剪枝。而字符串轉換成數字后,道理與(1)類似,打破排序性,導致分區剪枝失效。
(3)需要進行網絡重分布操作。在進行表的關聯,聚集操作時,如果涉及表分布鍵的操作,可以在本地並行進行。但如果涉及到類型轉換,則hash值發生變化,需要進行網絡重分布,增加了網絡開銷。
(4)估算不夠准確,可能造成計划的性能問題。我們收集的統計信息都是基於基表列的,如果進行類型轉換,則缺少轉換后的統計信息,同樣可能造成計划不准。
因此,在表設計之初就要把數據類型定義好,數字類型盡量使用整型或numeric(浮點型),盡量少使用字符串數據類型,除了與數字比較產生上述開銷外,變長的字符串在處理時還產生了額外的空間申請釋放,內存拷貝的開銷,都是無形中性能的損耗。
四.全局性操作導致的壞味道
前面提到,GaussDB(DWS)分布式數據庫的優勢,就是利用多DN的資源進行並行計算,提高吞吐量。但有些SQL在這些方面不夠注意,導致執行過程中由於全局性操作僅能在一個DN或CN上執行,造成了性能瓶頸。不能下推即屬於這一類型問題。除了不能下推,本章節主要討論在DN上進行全局操作導致的問題。客戶場景遇到的主要問題,是需要對全量大數據量進行排序的問題,比如:windowagg函數沒有partition by,但包含order by的問題。例如如下語句:
select * from (select ss_sold_date_sk, sum(ss_sales_price) over (order by ss_sold_date_sk rows 2 preceding) sum_2, sum(ss_sales_price) over (order by ss_sold_date_sk rows 5 preceding) sum_5 from store_sales), date_dim where ss_sold_date_sk=d_date_sk and d_year=2000 and d_moy=5 order by 1;
執行計划如下:
計划中第4層將所有DN的數據廣播到一個DN上進行全局排序,並計算sum窗口函數的值,導致性能瓶頸。對於類似情況,在語義允許的情況下,盡量給窗口函數增加partition by的字段,這樣分組計算時,GaussDB(DWS)可以將計算分配到不同的DN上進行,提高執行效率。
五.NestLoop類低效運算導致的壞味道
NestLoop是最簡單的表關聯手段,當然也是最低效的,每條元組之間都要進行匹配,數據量大的時候經常執行不出來。所以在GaussDB(DWS)中,我們經常使用HashJoin進行表的關聯。但有些SQL語句從語義上決定,只能使用NestLoop的方式執行,導致性能問題。總結起來,有以下幾方面:
(1)無等值關聯條件場景
在GaussDB(DWS)中,我們推薦使用等值關聯,這樣可以使用HashJoin進行執行加速。但對於非等值關聯條件,只能使用NestLoop的方式進行連接,同時需要將其中一個表進行Broadcast廣播到所有DN進行。如果兩個表都比較大,將導致性能瓶頸。例如如下:
select * from t1 join t2 on t1.a<t2.a;
計划如下:
類似的場景還有:<1> select * from t1 join t2 on 1=1;這個語句無關聯條件,我們稱為笛卡爾積關聯,返回結果行數為t1和t2行數的乘積。<2> select * from t1 join t2 on t1.a=t2.a and t1.a=5;這個語句雖然有等值關聯條件,但關聯條件還有個過濾條件t1.a=5,所以實際上t1.a=t2.a=5,是在a=5過濾基礎上的笛卡爾積。這類語句都會導致性能瓶頸。
(2)相關子查詢場景
相關子查詢,即子查詢中需要依賴父查詢的列值進行迭代計算的場景。例如如下語句:select (select ss_item_sk from store_sales where ss_item_sk<i_item_sk limit 1) from item;
計划如下:
在分布式框架下,為了保證每一行父查詢元組均能夠在子查詢中迭代計算出結果,需要所有DN均維護一份子查詢表的全局數據,在上面的計划中,子查詢中的store_sales表進行了廣播和物化,將數據存在所有DN上,見8層算子。第3層算子每一行數據均需要通過迭代子查詢計算(第4層及之下的SubPlan 1)獲得是否匹配的信息。這個計划執行非常慢且耗費資源,原因是:
a) 在DN節點非常多的分布式環境下,將數據廣播到所有DN上進行物化,將導致網絡資源和IO資源耗費巨大,同時大數據量的廣播耗時也很長。
b) 對於父查詢的每一行元組,均需要迭代計算子查詢的值,類似於NestLoop的執行方式,效率極低。
以上兩個問題帶來的語句性能問題在各個客戶現場均被識別,因此需要進行通用子鏈接提升轉成join的查詢重寫來解決該問題。
在之前的版本中,我們已經支持了如下場景的子鏈接提升,即將子鏈接轉化為join獲得性能提升,這些子鏈接均出現在where條件里,包括:
a) IN/NOT IN的非相關子查詢。
b) EXISTS/NOT EXISTS的等值相關子查詢。
c) 包含Agg表達式的等值相關子查詢。
d) 以上場景子查詢的OR場景。
當然,目前我們還不支持目標列上出現相關子查詢的場景,以及相關子查詢中出現不等值比較的場景,需要首先識別出壞味道,進行語義等價的整改。
(3)Not in場景
當查詢語句中包含NOT IN謂詞時,例如如下語句:
select * from t t1 where t1.a not in (select b from t);
其計划如下圖所示:
我們發現其走了NestLoop計划,原因是NOT IN的特殊語義,NULL值和任意值的比較結果是NULL,不是true,所以需要單獨在Join條件上加上IS NULL的條件,導致等值關聯變成非等值關聯。一般客戶場景下,使用NOT IN並不是為了處理NULL值,而是對NOT IN語義的誤解,因此需要將兩側的NULL值去除(驗證無NULL值,或建立臨時表)后,使用下面兩種方法解決性能問題:
a) 如果確認關聯列沒有NULL值,需要在關聯列上建立NOT NULL約束,比如示例SQL語句,要在t表的a列和b列上均建立NOT NULL約束。
b) 可以將語句改寫成NOT EXISTS,例如示例語句可以改寫為:
select * from t t1 where not exists (select 1 from t where t.b=t1.a);
(4)IN list場景
IN list場景是指語句中包含大量常數的IN條件,例如如下語句:
select count(*) from customer where c_customer_sk in (1, 101, 201, 1001, 2001, 10001, 20001, 100001, 200001, 500001, 800001);
通常情況下,會生成基表掃描的查詢計划,即對於customer表的每一條,需要檢查c_customer_sk列是否會和IN list中的某個值匹配,匹配即返回該條元組。當IN list中的條件比較多時,匹配近似於NestLoop的操作。針對這種場景,GaussDB(DWS)實現了In list to Join的查詢優化規則,根據代價估算,針對In list中的值較多的場景,生成Hash Join的計划,極大提升性能,如下圖所示:
但代價估算存在估算不准的情況,對於列存表有min/max過濾,有時轉成Join並不一定性能最好,因此我們增加了GUC參數:qrw_inlist2join_optmode,用戶可以手動設置該參數的值進行調優,其值說明如下:
a) cost_base,即根據代價估算,默認值。
b) rule_base,強制使用轉換成Join的優化規則。
c) 不小於2的整數,表示當In list中的常量個數不小於N時,使用轉換成Join的優化規則。
六.冗余操作導致的壞味道
在GaussDB(DWS)場景中,經常會遇到一類SQL,其中存在大量的冗余操作,導致執行時進行了大量無效計算。這類語句的場景很多,需要根據performance數據詳細分析,以下舉幾個例子簡單看一下。
(1)語句中存在大量冗余case when語句的場景
例如如下語句,語句中有大量case when語句,大多數條件都是一樣的,只是default值存在不同,但執行時,每個分支的case when均需要執行,導致時間成倍增加。
這種情況下需要對語句進行深入分析,根據語義進行等價改寫。通常我們可以把case when加到過濾條件,分成多個子查詢分別求值,或提取公共部分進行改寫。經過優化后,消除了case when,性能得到了提升,修改后的語句如下所示。
再看一個例子,下圖的例子中,會進行聚集函數的計算,但在下方的case when中頻繁引用這些聚集函數,導致聚集函數計算多遍。
經過等價優化后,在子查詢中僅計算一遍聚集函數,在父查詢的case when中,直接使用計算好的聚集函數的值,避免多次計算,優化后的語句如下圖所示。
(2)排序僅取部分數據的場景
在應用中,經常出現提取前若干條數據的場景。例如如下語句:
select a from (select a, row_number() over (order by a desc) rid from t1) where rid between <start> and <end>;
即取按a排序后的從start到end的行數,對應的執行計划如下所示:
我們發現對所有的數據進行了排序,然后返回了前10條數據,數據量較大時,所有數據量全排將大量耗費時間。這種情況下,我們可以在子查詢中加入limit語句,這樣排序變為top N排序,減少了排序的時間,修改后的語句為:
select a from (select a, row_number() over (order by a desc) rid from t1 limit <end>-<start>+1 offset <start>-1 ) where rid between 1 and 10;
對應的計划變為:
(3)聚集操作順序出現問題的場景
在GaussDB(DWS)場景中,通常為分析類應用,最終均需要進行聚集操作。通常聚集都是在語句最后進行,起到去重統計的效果。但是,如果去重前的重復值較多,但會顯著影響關聯的性能,如SQL語句:
select t1.c1, count(*) from t1 join t2 on t1.c1=t2.c1 group by t1.c1;
其計划如下圖所示:
t1.c1和t2.c1有大量重復值,導致Join完之后行數激增,Join性能較差,因此需要將Agg下推到Join之前進行,通過提前的Agg操作減少Join結果的行數,修改后的語句為:
select t1.a, c1*c2 from (select t1.a, count(*) c1 from t1 group by t1.a) t1 join (select t2.a, count(*) c2 from t2 group by t2.a) t2 on t1.a=t2.a;
我們稱這個改寫規則為Eager Agg,相反,如果改寫后的語句,在子查詢中的Agg去重效果不明顯,但耗時較長,則可以做反向改寫,去除冗余的Agg操作,我們稱之為Lazy Agg。
以上只列出了客戶場景經常出現的需要改寫的語句,當然,要想深得SQL改寫精髓,還是要深入了解GaussDB(DWS)實現原理,找到性能瓶頸,才能進行針對性的改寫,起到事半功倍的效果。
本文分享自華為雲社區《GaussDB(DWS)性能調優系列實戰篇四:十八般武藝之SQL改寫》,原文作者:兩杯咖啡 。