摘要:本文將圍繞WITH,以及更高階的WITH RECURSIVE表達式介紹其語法特徵和具體使用規範,以及在GaussDB(DWS)中如何進行WITH表達式的調優
本文分享自華爲雲社區《GaussDB(DWS) SQL進階之SQL操作之WITH表達式》,作者: 兩杯咖啡 。
SQL標準1999中,在傳統SQL語法的基礎上增加了with表達式的使用,使得SQL語句的編程可以更加靈活和具備可擴展性。本文將圍繞with,以及更高階的with recursive表達式介紹其語法特徵和具體使用規範,以及在GaussDB(DWS)中如何進行with表達式的調優。同時,對Oracle的connect by語法進行探討,研究其使用with recursive進行遷移改寫的方法。
一. WITH表達式及其應用
WITH表達式用於定義查詢中公用語句塊,每個語句塊稱爲CTE,即common table expr,可以理解爲一個帶名稱的子查詢,之後該查詢可以以其名稱在查詢中被多次引用,類似於高級編程語言中的函數。TPC-DS benchmark測試集中有很多包含WITH表達式的SQL語句,99個查詢中有24個相關語句。對於查詢複雜的AP場景,WITH表達式的應用場景非常廣泛,很多客戶現場都在使用WITH表達式,尤其對於多年維護的應用程序,使用WITH表達式是進行SQL編寫演進的一個優秀實踐。
以TPC-DS Q1爲例:
with customer_total_return as (select sr_customer_sk as ctr_customer_sk ,sr_store_sk as ctr_store_sk ,sum(SR_FEE) as ctr_total_return from store_returns ,date_dim where sr_returned_date_sk = d_date_sk and d_year =2000 group by sr_customer_sk ,sr_store_sk) select c_customer_id from customer_total_return ctr1 ,store ,customer where ctr1.ctr_total_return > (select avg(ctr_total_return)*1.2 from customer_total_return ctr2 where ctr1.ctr_store_sk = ctr2.ctr_store_sk) and s_store_sk = ctr1.ctr_store_sk and s_state = 'TN' and ctr1.ctr_customer_sk = c_customer_sk order by c_customer_id limit 100;
該查詢中定義了一個名稱爲customer_total_return的CTE,該CTE查詢2000年退貨的相關信息。在主查詢中該CTE被調用了兩遍,如果不使用CTE,則customer_total_return定義的SQL需要在該查詢中寫兩遍,使得查詢更長更難以維護。
WITH表達式的語法如下:
[WITH [RECURSIVE] with_query [,…] ] SELECT …
其中,with_query的語法爲:
with_query_name [ ( column_name [, ...] ) ] AS ( {select | values | insert | update | delete} )
關鍵要點如下:
- 每個CTE的AS語句指定的SQL語句,必須是可以返回查詢結果的語句,可以是普通的SELECT語句,也可以是INSERT、UPDATE、DELETE、VALUES等其它語句,需要通過RETURNING子句返回元組。例如:
WITH s AS (INSERT INTO t VALUES(1) RETURNING a) SELECT * FROM s;
- 單個WITH表達式表示一個SQL語句塊中的CTE定義,可以同時定義多個CTE,每個CTE可以指定列名,也可以默認使用查詢輸出列的別名。例如:
WITH s1(a, b) AS (SELECT x, y FROM t1), s2 AS (SELECT x, y FROM t2) SELECT * FROM s1 JOIN s2 ON s1.a=s2.x;
該語句中定義了兩個CTE,s1和s2,其中s1指定了列名爲a, b,s2未指定列名,則列名爲輸出列名x, y。
- 每個CTE可以在主查詢中引用0次、1次或多次。
- 同一個語句塊中不能出現同名的CTE,即不支持高級語言的重載。但不同語句塊中可以出現同名的CTE。此時,語句中引用的CTE則是距離引用位置最近的語句塊中的CTE。
- 除非使用WITH RECURSIVE,否則CTE不允許自引用,即CTE的定義中引用當前CTE。
- 由於SQL語句中可能包含多個SQL語句塊,每個語句塊都可以包含一個WITH表達式,每個WITH表達式中的CTE可以在當前語句塊、當前語句塊的後續CTE中,以及子層語句塊中引用,但不能在父層語句塊中引用。由於每個CTE的定義也是個語句塊,因此也支持在該語句塊中定義WITH表達式。例如:
WITH tmp AS (SELECT a FROM t) -- 1st tmp SELECT SUM(a) FROM (WITH tmp AS (SELECT a * 2 AS a FROM tmp) -- 2nd tmp SELECT a FROM tmp t1 -- 3rd tmp WHERE EXISTS(SELECT a FROM tmp t2 WHERE t2.a=t1.a)); -- 4th tmp
注:
<1> 該語句中定義了兩個同名CTE-tmp,一個定義在最外層主語句中,另一個定義在內層子查詢中。
<2> 語句中一共引用了三次tmp,其中第三次和第四次的引用都是引用子查詢中的tmp,而子查詢tmp中使用的tmp(第二次的引用)則引用最外層的tmp。(想想看,爲什麼?)
特殊地,如果CTE出現在相關子查詢中,也可以使用父層的列或表達式,此時引用CTE的地方都視爲使用父層的列或表達式。例如:
update relate_table_010 set c_birth_month = (with tmp1 as (select s_store_sk, s_company_id, s_market_id from store where s_market_id = c_birth_day) select cc_mkt_id from call_center where cc_mkt_id + 1 in (select web_mkt_id from web_site inner join tmp1 on web_site_sk = s_store_sk where s_market_id = cc_mkt_id)) where c_birth_day = 9;
該語句中,CTE tmp1中使用了外層relate_table_010的列c_birth_day。
二. With recursive
WITH表達式極大的方便了語句內相同SQL實現的複用,向高級編程語言邁進了一步,但相比高級編程語言而言,仍然缺少一個重要的語法支持,即循環。SQL仍然無法像高級編程語言使用for, while一樣,支持不確定循環次數的執行。爲此,SQL支持了with recursive語法,來解決這一問題,可以用在樹和圖的拓撲搜索上。以下圖的樹爲例:
在GaussDB(DWS)中,可以使用表tree來存儲所有節點及父子信息,表定義語句如下:
CREATE TABLE tree(id INT, parentid INT);
表中數據如下:
通過以下WITH RECURSIVE語句,我們可以返回從頂層1號節點開始,整個樹的節點,以及層次信息:
WITH RECURSIVE nodeset AS ( -- recursive initializing query SELECT id, parentid, 1 AS level FROM tree WHERE id = 1 UNION ALL -- recursive join query SELECT tree.id, tree.parentid, level + 1 FROM tree, nodeset WHERE tree.parentid = nodeset.id ) SELECT * FROM nodeset ORDER BY id;
上述查詢中,我們可以看出,一個典型的WITH RECURSIVE表達式包含至少一個遞歸查詢的CTE,該CTE中的定義爲一個UNION ALL集合操作,第一個分支爲遞歸起始查詢,第二個分支爲遞歸關聯查詢,需要自引用第一部分進行不斷遞歸關聯。該語句執行時,遞歸起始查詢執行一次,關聯查詢執行若干次並將結果疊加到起始查詢結果集中,直到某一些關聯查詢結果爲空,則返回。
上述查詢的執行結果如下:
起始查詢結果包含level=1的結果集,關聯查詢執行了五次,前四次分別輸出level=2,3,4,5的結果集,在第五次執行時,由於沒有parentid和輸出結果集id相等的記錄,也就是再沒有多餘的孩子節點,因此查詢結束。
從WITH RECURSIVE的執行過程來看,是典型的層次遍歷(廣度優先)的執行方式,因此WITH RECURSIVE也可以稱爲層次查詢。除了典型的樹、圖的拓撲查找應用,WITH RECURSIVE還可以用於模擬多數的複雜循環操作,只要我們正確定義起始條件、循環條件和終止條件。
例如:下例將整數1000-1001轉化成二進制串。
WITH RECURSIVE integer AS ( SELECT x AS orig, x, '' AS binary_text FROM GENERATE_SERIES(1000, 1010) AS set(x) UNION ALL SELECT orig, FLOOR(x/2)::int, CASE WHEN x % 2 = 1 THEN '1' ELSE '0' END || binary_text FROM INTEGER WHERE x > 0 ) SELECT orig, binary_text FROM integer WHERE x = 0 ORDER BY orig;
執行結果如下:
三. GaussDB(DWS)的實現
在PG中,CTE的掃描使用了專門的執行算子WorkTableScan,用於將數據集中緩存起來,供其它引用使用,做到了一次掃描,多次使用的效果。對於GaussDB(DWS),不下推的計劃繼承了PG的計劃。TPC-DS Q1的計劃,如下圖所示:
第15號算子即CTE Scan,對CTE customer_total_return的結果進行緩存,供第8號和第14號CTE scan算子使用。
對於GaussDB(DWS)分佈式系統,數據是分佈存儲在各個DN的,因此這樣的做法是不適合的。在GaussDB(DWS)中,目前將CTE的實現inline到各個調用的地方進行,保證計劃的分佈式下推執行。TPC-DS Q1的計劃,如下圖所示:
紅框中的兩個計劃即是兩個CTE的執行部分。
GaussDB(DWS)嵌入的執行方式,對於CTE多次執行,根據不同的過濾條件可以生成不同的計劃,某些場景是適合的。後續需要結合PG的共享執行機制,對過濾條件相同的執行語句塊進行一次執行,結果共享的改進,減少數據處理和運算量。
對於WITH RECURSIVE表達式,GaussDB(DWS)也支持其分佈式執行,計劃如下所示:
同時,由於WITH RECURSIVE涉及到循環運算,在語句寫得不好的時候,可能出現循環次數過多導致數據庫執行異常,因此GaussDB(DWS)引入了參數max_recursive_times,用於控制WITH RECURSIVE的最大循環次數,默認值爲200,超過該次數則報錯。
四. Oracle CONNECT BY的遷移
讀到這裏,可能細心的讀者已經發現了,WITH RECURSIVE和Oracle支持的CONNECT BY特性功能很相似,都是用於進行不定次數的循環運算,但語法不同。
Oracle CONNECT BY功能的基本語法如下:
SELECT * FROM tablename [START WITH <condition1>] CONNECT BY <condition2>;
其中START WITH子句用於指定起始條件,即<condition1>,循環關聯條件爲<condition2>,其中可以使用PRIOR關鍵字來表示來自於上一循環的列。例如上節中所述的樹遍歷的例子,使用Oracle的Connect By語法,語句如下:
SELECT * FROM tree START WITH id = 1 CONNECT BY PRIOR id = parentid;
可以看出,Oracle的CONNECT BY實現了基本的樹和圖拓撲關係查找的功能,用法較簡單,但相較於WITH RECURSIVE,不如其靈活,對於一些複雜的循環語句,尤其是起始語句和循環關聯語句的輸出列不相同的場景,無法支持。
但由於GaussDB(DWS)目前很多客戶都是從Oracle系統遷移而來,因此面臨着將Oracle的CONNECT BY語法改寫爲WITH RECURSIVE的需求。對於基本語法,我們可以進行如下基本的改寫以滿足其功能:
WITH RECURSIVE tmp_cte AS ( SELECT * FROM table WHERE <condition1> UNION ALL SELECT table.* FROM table JOIN tmp_cte ON <condition2> ) SELECT * FROM tmp_cte;
其中<condition2>需要對Oracle的PRIOR表達式進行改寫,明確PRIOR修飾的列爲table表的列,非PRIOR修飾的列爲tmp_cte對應的列。
爲了更準確地表示遍歷的層次關係,Oracle的CONNECT BY功能還支持一些僞列和其它表達式,其基本語義和改寫方式如下表所示,請讀者下來思考具體的改寫方法。
- 終止循環嵌套選項
【語法】CONNECT BY NO CYCLE <condition>
【語義】通過在循環關聯條件前指定NO CYCLE,在遇到循環嵌套重複行時,主動終止重複行的重複循環。
【示例】SELECT * FROM tree START WITH id = 1 CONNECT BY NOCYCLE PRIOR id = parentid;
【改寫方式】GaussDB(DWS)中支持在WITH RECURSIVE表達式定義的語句塊中使用UNION,而非UNION ALL,此時會對輸出行去重,自動終止循環,但要求輸出行完全來自初始行,不能增加其它表達式,否則一併參與去重。例如:
WITH RECURSIVE nodeset AS ( SELECT id, parentid, 1 AS level FROM tree WHERE id = 1 UNION SELECT tree.id, tree.parentid, level + 1 FROM tree, nodeset WHERE tree.parentid = nodeset.id ) SELECT * FROM nodeset ORDER BY id;
注:此改寫仍與Oracle有區別,即Oracle可以重複輸出重複行一次,而本改寫自動跳過;另外本改寫不能增加其它僞列及表達式,例如:level等。
- 層次排序選項
【語法】ORDER SIBLINGS BY <column>[, …]
【語義】CONNECT BY默認深度遞歸遍歷並輸出,此選項修改排序順序爲層次,<column>。
【示例】SELECT * FROM tree START WITH id = 1 CONNECT BY PRIOR id = parentid ORDER SIBLINGS BY id;
【改寫方式】可以在WITH RECUSIVE的語句塊輸出列增加僞列LEVEL(見下方說明), path_array(),然後按照該兩列排序。其中path_array()的入參爲排序列,含義爲從根到當前節點的值。
- 僞列LEVEL/ CONNECT_BY_ISLEAF/CONNECT_BY_ISCYCLE
【語義】LEVEL表示當前行的遍歷層次/CONNECT_BY_ISLEAF表示當前行是否爲遍歷終止節點(葉子節點)/ CONNECT_BY_ISCYCLE表名當前行是否爲循環重複行,與NO CYCLE搭配使用纔有意義
【示例】SELECT id, parentid, LEVEL, CONNECT_BY_IS_LEAF, CONNECT_BY_IS_CYCLE FROM tree START WITH id = 1 CONNECT BY NO CYCLE PRIOR id = parentid;
【改寫方式】LEVEL可以通過增加僞列實現,例如上文示例。CONNECT_BY_ISLEAF則需要與輸出結果集的遞歸join列關聯,根據關聯結果判斷。由於不支持NO CYCLE,CONNECT_BY_ISCYCLE不支持改寫。
- 操作符CONNECT_BY_ROOT(column)
【語義】返回遍歷開始行對應的column值
【示例】SELECT id, parentid, CONNECT_BY_ROOT(id) FROM tree START WITH id = 1 CONNECT BY PRIOR id = parentid;
【改寫方式】可以在WITH RECUSIVE的語句塊輸出列增加標識起始行的列,在嵌套過程中該列值始終繼承第一行的值。
- 函數SYS_CONNECT_BY_PATH(column, char)
【語義】返回從起始行到當前行嵌套的所有column的值,以char分隔。
【示例】SELECT id, parentid, SYS_CONNECT_BY_PATH(id, ‘/’) FROM tree START WITH id = 1 CONNECT BY PRIOR id = parentid;
【改寫方式】可以在WITH RECUSIVE的語句塊輸出列增加標識起始行到當前行的相應列的字符串,在嵌套過程中通過字符串連接增加當前行的值。
五. 總結
本文中所講到的WITH表達式及WITH RECURSIVE表達式的用法,涉及很多SQL中複雜的操作,當然掌握其語法也在熟練掌握SQL的過程中更進了一步。
想了解GuassDB(DWS)更多信息,歡迎微信搜索“GaussDB DWS”關注微信公衆號,和您分享最新最全的PB級數倉黑科技,後臺還可獲取衆多學習資料哦~