SQL 語言是一種強大而且靈活的語言,在使用 SQL 語言來執行某個關係查詢的時候,用戶可以寫出很多不同的 SQL 語句來獲取相同的結果。也就是說,語法 (syntactical) 不同的 SQL 語句,有可能在語義 (semantical) 上是完全相同的。但是儘管這些 SQL 語句最後都能返回同樣的查詢結果,它們在 DB2 中執行所需要的時間卻有可能差別很大。這是爲什麼?
衆所周知,DB2 數據庫具有強大的功能,可以自動地把用戶輸入的 SQL 語句改寫爲多個語義相同的形式並從中選取一個耗時最少的語句來執行。但是 DB2 並不能夠永遠對所有的 SQL 語句都成功的改寫來取得最優的執行方案。其中一個方面的原因就是數據庫應用程序的開發人員在寫 SQL 語句的時候有一些習慣性的“小問題”,而正是這些小問題帶來了 SQL 語句運行時性能上的大問題。正如平時所說“條條大路通羅馬”,但是並非所有通往羅馬的路都是坦途,我們應該找到那條最有效的道路。
這裏我們將介紹在編寫 SQL 語句時可能影響 DB2 查詢性能的一些常見問題,並給出相應的編寫高效 SQL 語句的最佳實踐(best-practices)。
像“SELECT *”這樣的寫法在用戶使用中可能很常見,它表示把滿足查詢條件的每一條記錄(Row)的所有列都返回。但是有時候這種用法很可能導致數據庫查詢時候的性能問題。假定 Sale 是一個包括 25 個列(column)的表,那麼下面這條查詢語句就有可能在執行時性能較差,其中一部分原因就是在 SELECT 中使用了"*".
SELECT *FROM Sales WHERE YEAR(Date) > 2004 AND Amount > 1000 |
如果 SQL 語句使用了“SELECT *”,DB2 就需要把表的所有列都從外部存儲介質上(如磁帶或者硬盤)複製到 DB2 的內存中來進行處理並且返回給用戶,這顯然會增加 I/O 和 CPU 的開銷。而且如果這條 SQL 語句還包括了排序(Sort)操作(比如 ORDER BY),那麼對全部這些列進行排序也可能會影響到性能。而且當表定義的列越多,每個列定義的數據類型(Data type)長度越長,這對性能的影響就可能越明顯。除此之外,DB2 還有一種被稱爲“Index-Only”的數據訪問方法,如果某個表上需要檢索的所有列都能在某個合適的索引(Index)上找到,DB2 就會使用“Index-Only”這種數據訪問方式。因爲這種訪問方式僅需要對索引進行檢索而無需對錶本身進行讀取,所以是一種較快高速的訪問方式。但是如果用戶輸入的 SQL 語句中使用了“SELECT *”,就意味着需要訪問表上的所有列。而通常情況下並不存在一個合適的索引是定義在這個表所有的列之上的(尤其是對於定義了許多列的表),這就使得 DB2 無法使用“Index-Only”這種較快的數據訪問方式,而改用其他數據訪問方式,這也有可能導致查詢性能的問題。
所以除非真的需要讀取表中的所有列,否則基於提高查詢性能的考慮,在寫 SQL 語句的時候應該儘量避免使用“SELECT *”這樣的情況。這是一條很簡單卻常常被用戶忽略的最佳實踐。
下面來看一個具體的示例。需要說明的是,本文示例中用到的表,除特別說明外,均爲 TPC-D 標準中定義的表,這樣有助於讀者更好的理解 SQL 語句本身。對於 TPC-D 標準的介紹,見文章最後的參考資源。
在這個例子中,我們比較 2 個不同的 SQL 語句在性能上的差別。2 個 SQL 的謂詞完全相同,並且這個謂詞有符合的索引可以使用。
SQL 1:select * from lineitem where l_orderkey = ? SQL 2:select l_suppkey, l_partkey from lineitem where l_orderkey = ? |
但是 SQL 1 使用了 select *,所以 DB2 在讀取索引之後,必須再去對錶中進行一次 Fetch 操作,讀取那些索引中不存在的列數據。而在實際業務需求中如果並不需要這些數據,這個 Fetch 操作就是多餘的而且會帶來性能問題。對比 SQL 2,它明確指出了在結果集中希望得到的列 l_suppkey, l_partkey,而這些列已經全部包含在索引中,所以數據庫採用了 Index-Only 的掃描方式,僅僅讀取了索引,不再需要對錶本身的 fetch 操作,從而使得性能得到了大幅提升。
圖 1. SQL 1 的訪問路徑圖
圖 2. SQL 2 的訪問路徑圖
所謂的本地謂詞(Local predicate)是與連接謂詞(Join predicate)相對應,它一般是指該謂詞當中只包含一個表上的一個列。
在上一節看到的例子當中,YEAR(Date) > 2004 和 Amount > 1000 都是兩個本地謂詞。然而在前一個謂詞 YEAR(Date) > 2004 中,它對 Date 這個列有一個函數 YEAR 的調用。在這種情況下,即使 Date 上存在一個索引,DB2 也無法使用這個索引來訪問數據。如果能夠在確保語義不變的前提下,適當改寫這個謂詞,避免在 Date 列上調用函數,那麼情況可能會有所不同。例如,這個謂詞可以改寫爲如下的樣子:
Date > ‘ 2004-12-31 ’ |
這樣的改寫首先確保了語義上的一致性,更重要的是,DB2 對於這樣的謂詞是可以通過索引來訪問數據,這樣查詢性能可能會比之前快很多。
這裏再給出一個類似的例子。對於 INTEGER(Sale)/100 = 900 這樣的謂詞,也可以將其改寫爲 Sale BETWEEN 90000 AND 90099 來提高查詢性能。
通過上面兩個例子,可以得出一個對應的最佳實踐的理論公式。如果在本地謂詞中出現如下的形式:
Function(Column_A)= ‘ constant ’ |
那麼儘可能的將其改寫爲如下的形式會有助於查詢性能的提高:
Column_A=Inverse_Function( ‘ constant ’ ) |
這裏 Column_A 是表上的某個列,constant 是常量,而 Function 與 Inverse_Function 是兩個互逆的函數。
這是在寫 SQL 語句時的另一個最佳實踐:儘量避免在本地謂詞中對於表的某個列使用複雜的表達式(函數調用或者數學運算等等)。
下面來看一個具體的示例:
SQL 3:select l_quantity, l_comment from lineitem where l_orderkey + 100 = 200 SQL 4:select l_quantity, l_comment from lineitem where l_orderkey = 100 |
圖 3. SQL 3 的訪問路徑圖
圖 4. SQL 4 的訪問路徑圖
這裏兩個 SQL 語句在語義上是完全相等的,只有謂詞在寫法上存在一些差異。SQL 3 的謂詞包含了一個 計算表達式 l_orderkey + 100 = 200,而 SQL 4 的謂詞是與之等價的簡單形式 l_orderkey = 100。但是它們的查詢訪問路徑可能會截然不同。對於 SQL 4,DB2 利用已有的索引,採用了較爲高效的索引訪問方式(Index-scan);而 SQL 3 的謂詞存在計算表達式,DB2 必須先計算出 l_orderkey + 100 的值再進行匹配,這使得直接利用索引的索引訪問方式無法採用。這兩種不同的訪問路徑所帶來的性能也是大不一樣的,這一點對比圖中兩者的 Total Cost 就可以看出,謂詞中含有計算表達式 l_orderkey + 100 = 200 的 SQL 3 的 Total Cost 較高,性能不好。避免用複雜表達式構建連接謂詞
所謂連接謂詞(Join predicate),一般是指該謂詞引用到了不同表上的多個列。比如在如下的 SQL 語句中:
Select T1.C1 From T1, T2 Where T1.C1=T2.C2 And T1.C2=10 |
T1.C1=T2.C2 就是一個典型的連接謂詞,這種寫法也是常見的連接謂詞的形式。對於這種常見的連接謂詞,DB2 可以考慮採用幾種不同的表連接方式(Join Method),常見的連接方法有嵌套循環連接(Nested-Loop-Join, NLJ),歸併排序連接(Merge-Scan-Join, MSJoin),哈希連接(Hash-Join)等。DB2 優化器會根據實際情況選擇從中選取一個性能最佳的來將 T1 和 T2 連接起來。
但是有的 SQL 語句可能會是如下這個樣子:
Select T1.C1 From T1, T2 Where T1.C1 * T1.C2 = T2.C2 |
在連接謂詞 T1.C1 * T1.C2 = T2.C2 中,“=”左邊不是一個列名,而是一個表達式,它涉及 T1 表上不同列之間的計算。對於這樣一個用複雜表達式構建的連接謂詞,DB2 只能用 Nested-Loop-Join 這種最基本的方式來建立 T1 和 T2 之間的連接,而不考慮用其他的連接方式,從而也就無法選擇最優的連接方式。所以這種在連接謂詞中使用複雜表達式的寫法不是一個好的習慣,在寫 SQL 語句時應該注意避免。
看下面這個示例:
SQL 5: SELECT l_comment, o_comment FROM lineitem, order WHERE l_orderkey = o_orderkey + 100 SQL 6: SELECT l_comment, o_comment FROM lineitem, order WHERE l_orderkey = o_orderkey |
在這個例子中,SQL 5 中的連接謂詞中包含了一個計算表達式 l_orderkey = o_orderkey + 100,而 SQL 6 中的連接謂詞是簡單的等式 l_orderkey = o_orderkey。這樣不同的連接謂詞對 DB2 選擇連接方法時有重要的影響。在 SQL 5 中,連接謂詞中包含計算表達式,DB2 只能選用最基本的 Nested-Loop-Join 連接方法(參見圖 5)。對比 SQL 6,假設連接謂詞是 l_orderkey = o_orderkey 這樣簡潔的形式,DB2 就會採用 Merge-Scan-Join 的連接方式(見圖 6)。注意這裏 SQL5 與 SQL6 在語義上是不等價的,在這裏用這樣的示例是爲了說明連接謂詞的寫法會導致連接方式的改變。如果想在滿足業務邏輯需求的情況下,同時保證連接謂詞的簡潔,也可以考慮增加一個新的列(例如 SQL 5 中,定義新的列 o_orderkey2,其值等於 o_orderkey + 100),直接構造連接謂詞(l_orderkey = o_orderkey2),從而最大程度的提高 SQL 語句的性能。
圖 5. SQL 5 的訪問路徑圖
圖 6. SQL 6 的訪問路徑圖
在用連接謂詞連接不同的表的時候,還有一點需要注意。即使對於 T1.C1=T2.C1 這樣典型的連接謂詞,也應該確保 T1.C1 和 T2.C1 具有同樣的數據類型。
在某些情況下,連接謂詞中兩個列的數據類型定義的不一致會導致 DB2 放棄使用某些表連接方式。比如 Hash-Join 這種表連接方式對連接謂詞就有更多的限制條件,條件之一就是連接謂詞中的兩個列的數據類型必須完全一致,否則 Hash-Join 不能使用。例如,如果連接謂詞中的 T1.C1 是 FLOAT 類型,而 T2.C1 是 REAL 類型,那麼 DB2 不會使用 Hash-Join 來連接 T1 和 T2。此外,如果 T1.C1 的數據類型是 CHAR,GRAPHIC,DECIMAL 或者 DECFLOAT,那麼 T2.C1 除了需要是相同的數據類型外,它所定義的數據類型的長度也需要和 T1.C1 一致,比如都被定義爲 CHAR(5),否則也不能使用 Hash-Join 來連接。
更多的表連接方式意味着 DB2 可以有更多的選擇來將表連接在一起,並從中選出最優的方案。如果連接謂詞中的數據類型不一致,而使得 DB2 不得不放棄某些特定的連接方式,這將有可能導致 SQL 在執行時性能不夠好。所以在不同的表之間建立連接關係時,應該避免連接謂詞中的數據類型不一致。
看下面這個示例:
SQL 7: SELECT l_comment, o_comment FROM lineitem, order WHERE l_orderkey = o_orderkey |
對於 SQL 1,DB2 優化器採用了歸併排序 (MSJoin) 的連接方法對兩個數據表進行了連接操作 ( 如圖 7 所示 ),注意根據 TPC-D 標準的定義,這裏連接謂詞 l_orderkey = o_orderkey 中的 2 個列的數據類型完全一致都爲 integer 類型。如果改動其中一個列的數據類型爲 double 類型,此時 DB2 就只能採用嵌套循環連接方法進行連接操作(如圖 8 所示),而對比之後就會發現,使用嵌套循環連接的 Total Cost 較高,這意味着性能較差。
圖 7. SQL 7 的訪問路徑圖
圖 8. SQL 7 的訪問路徑圖(修改 o_orderkey 數據類型之後)
典型的連接謂詞通常是形如 T1.C1=T2.C1 這樣的形式,注意到這裏是用“=”這個操作符將左右兩邊的列連接起來。理論上,也可以使用其他的操作符來構造連接謂詞,比如“<”或者“>”這樣的比較運算符。但是實際上基於性能的考慮,在連接謂詞中應該只使用“=”,儘量避免使用其他的比較運算符。
對於如下的 SQL 語句:
Select T1.C2 From T1, T2 Where T1.C1 < T2.C1 |
在連接謂詞 T1.C1 < T2.C1 中,使用了“<”這個比較運算符。 對於這樣的 SQL 語句,DB2 只能採用 Nested-Loop-Join 這種最基本的方式來建立 T1 和 T2 之間的連接,而不考慮用其他的連接方式。如前面提到的,更多的表連接方式意味着 DB2 可以有更多的選擇來將表連接在一起,並從中選出最優的方案。如果連接謂詞中的連接符不是“=”,使得 DB2 不得不放棄某些特定的連接方式,從而也就無法選擇最優的連接方式,這將有可能導致 SQL 在執行時性能不夠好。
此外,對於這樣沒有使用“=”的連接謂詞,DB2 在計算這個謂詞的篩選率(selectivity)的時候,有可能計算的不夠準確,而如果同樣的連接謂詞改爲用“=”連接,篩選率的計算就會準確很多。熟悉 DB2 的數據庫管理員和數據庫程序開發人員都會知道,篩選率的準確性對於 DB2 優化器非常重要。只有基於準確的篩選率,DB2 優化器才能從各種可能的訪問路徑中確定最優路徑。而篩選率不準確,就有可能帶來潛在的查詢性能問題。
基於上述兩點可以看出,“=”在構建連接謂詞時很重要。在不同的表之間建立連接關係時,應該儘可能的使用“=”來構建連接謂詞。
需要指出的是,在某些實際的應用場景當中,出於業務邏輯上的要求,出現 T1.C1 < T2.C1 這樣的連接謂詞可能是不可避免的。在這種情況下,基於性能優化的考慮,應該在 T1 和 T2 上都建立適當的索引,使得 T1.C1 < T2.C1 這個謂詞能夠使用索引。其中的原因在於,DB2 只能使用 Nested-Loop-Join 來建立 T1 和 T2 之間的連接,此時應該確保有合適使用的索引能夠讓 Nested-Loop-Join 採用 Index-Scan 這種數據訪問方法,從而儘可能提高性能。但是對於上面提到的第二個篩選率問題,即使添加索引也不能很好的解決這個問題。
看下面的示例,
SQL 8: SELECT l_comment, o_comment FROM lineitem, order WHERE l_oderkey >o_orderkey SQL 9: SELECT l_comment, o_comment FROM lineitem, order WHERE l_oderkey =o_orderkey |
在 SQL 8 中連接謂詞是通過大於號連接的,DB2 只能採用嵌套循環連接 (Nested-Loop-Join) 這種最基本的方式來建立兩個表之間的連接(如圖 9 所示)。在 SQL9 中連接謂詞中採用“=”連接,此時 DB2 優化器選用了歸併排序(MSJoin)的連接方式,它的 Total cost 比 SQL 8 的要低很多,具有較好的性能(如圖 10 所示)。注意這裏 SQL 8 與 SQL 9 在語義上是不等價的,在這裏用這樣的示例是爲了說明連接謂詞中不使用等號的寫法會導致訪問路徑完全不同,從而影響查詢性能。
圖 9. SQL 8 的訪問路徑圖
圖 10. SQL 9 的訪問路徑圖
表之間的主鍵外鍵反映了表之間數據的依賴關係。如果一個 SQL 語句涉及兩個表之間的連接,而這兩個表存在主外鍵關係,那麼通常情況下,該 SQL 語句中都應該有基於該主外鍵關係的連接謂詞。在寫 SQL 語句的時候,也應該注意這一點,即根據主外鍵關係確保 SQL 中存在對應的連接謂詞,否則的話,返回的查詢結果中可能會包括大量無實際意義的記錄,而返回這些記錄又會給數據庫執行帶來額外的開銷,造成性能問題。
來看一個簡單的例子,假設在表 T1 與 T2 之間存在主外鍵的關係,其中 T1.C1 是主鍵,T2.C1 是外鍵。如果有如下的 SQL 語句:
SELECT T1.C2, T2.C2 FROM T1, T2 WHERE T1.C2 = 5 AND T2.C2 = ‘ IBM ’ AND T1.C3 = T2.C3 |
注意在這個 SQL 當中,T1 與 T2 之間有一個連接謂詞 T1.C3 = T2.C3,但是卻缺少 T1.C1 = T2.C1 這樣的連接謂詞。而通過 T1 與 T2 之間的主外鍵關係,可以合理推導出在通常情況下,用戶想看到的結果中應該包括 T1.C1 = T2.C1 這樣的邏輯關係。因此可以將上面的 SQL 改寫爲:
SELECT T1.C2, T2.C2 FROM T1, T2 WHERE T1.C2 = 5 AND T2.C2 = ‘ IBM ’ AND T1.C3 = T2.C3 AND T1.C1 = T2.C1 |
通過添加這樣的連接謂詞,使得數據庫可以有更多的選擇來建立 T1 與 T2 直接的連接關係,並且避免了返回大量無意義的記錄,從而使得整體性能得以提高。但是需要注意的是,這樣的寫法改變了原先 SQL 的語義,從而改變了查詢結果,所以在使用的時候需要用戶來確認 T1.C1 = T2.C1 這樣的邏輯條件對於其業務應用來說是正確的。
看下面的示例:
SQL10: SELECT l_comment, o_comment FROM lineitem, order SQL11: SELECT l_comment, o_comment FROM lineitem, order WHRE l_orderkey = o_orderkey |
其中 SQL10 沒有包含 lineitem 和 order 表之間的主外鍵關係 l_orderkey = o_orderkey,從業務邏輯分析的角度出發來看,這樣的寫法很可能是由於人爲的疏忽漏掉了這個連接謂詞。從圖 11 中也可以看出,SQL 10 返回了一個很大的結果集(圖中 Cardinality 所示),可以合理推斷,其中包含了大量 l_orderkey ≠ o_orderkey 的無效數據,這些數據是業務邏輯並不想要的,而 DB2 爲了取得這些無效數據卻要花費很高的代價。對比 SQL 11,具有 l_orderkey = o_orderkey 這樣的主外鍵連接謂詞,更符合邏輯。同時從圖 12 也可以看出,它返回的結果集較小,所花費的成本(Total Cost)也小了很多,查詢性能優化很多。
圖 11. SQL 10 的訪問路徑圖
圖 12. SQL 11 的訪問路徑圖
通常情況下,SQL 語句中的 GROUP BY 子句會導致數據庫不得不通過一個排序(SORT)操作來實現對數據的分組,而排序被認爲是一個比較耗費 CPU 和內存的操作。實際上某些情況下,如果寫法得當,當中的排序操作是可以避免的。具體來說,在寫 GROUP BY 子句的時候,應該考慮到數據庫中已經存在的索引的情況。如果 GROUP BY 子句中所有的列恰好包括在某個索引的鍵(Key column)的範圍之內而且是處於開始的位置,那麼在寫 GROUP BY 子句的時候,就應該按照該索引上鍵的先後順序來寫 GROUP BY 子句。
比如說有如下的 SQL 語句:
SELECT C2, C3, C1, AVG(C4) FROM T1 GROUP BY C2, C3, C1 |
一般情況下,GROUP BY C2, C3, C1這樣的寫法都會導致數據庫的一個排序操作。但假定表 T1 上已經存在一個索引 IX1(C1, C2, C3, C4), 這裏注意到 GROUP BY 子句中引用到的列(C2,C3,C1)正好是索引 IX1 中的前三個鍵,那麼就可以通過改變 GROUP BY 子句中列的順序的辦法來避免這個排序操作。
可以把 SQL 語句改寫爲如下所示:
SELECT C1, C2, C3, AVG(C4) FROM T1 GROUP BY C1, C2, C3 |
通過這樣改變 GROUP BY 子句中列的順序使其與索引 IX1 中的鍵順序一致,數據庫就可以利用 IX1 來訪問其已經排序的鍵值並直接返回進行下一步操作,從而避免額外的排序操作,從而帶來查詢性能上的提高。
需要指出的是,通過這樣改寫 GROUP BY 子句來避免排序,可能會導致最終返回結果的順序不一致。在實際的業務邏輯當中,需要用戶來確認是否其關注返回結果的順序性。
下面來看一個具體的示例:
SQL 12:SELECT AVG(o_shippriority) FROM order GROUP BY o_custkey , o_orderkey, o_orderdate SQL 13:SELECT AVG(o_shippriority) FROM order GROUP BY o_orderkey, o_orderdate, o_custkey |
這裏 2 個 SQL 唯一的差別就在於 GROUP BY 子句中列的順序不同。根據 TPC-D 標準定義,order 表上存在一個索引 PXO@OKODCKSPOP (O_ORDERKEY,O_ORDERDATE,O_CUSTKEY,O_SHIPPRIORITY,O_ORDERPRIORITY)。
由於 SQL 12 中的 GROUP BY 子句的列順序與索引 PXO@OKODCKSPOP 的鍵順序不一致,DB2 無法直接利用這個索引,所以 DB2 需要基於這 3 個列做一次排序(Sort),然後進行分組合並,排序的結果還需要通過臨時文件(Wkfile)來保存,如圖 13 所示。如果調整 GROUP BY 子句中的列順序如 SQL 13 所示,使其與索引 PXO@OKODCKSPOP 的鍵順序一致,DB2 通過這個索引返回的結果就已經是有序的,這樣就省去了排序操作(如圖 14 所示)。對比兩者的訪問路徑圖可以看出來,SQL 13 所花費的成本(Total Cost)會少很多,性能上有較大的提高。
圖 13. SQL 12 的訪問路徑圖
圖 14. SQL 13 的訪問路徑圖
在 DB2 的 SQL 語法中,FETCH FIRST n ROWS ONLY 表示只取回結果集當中的前 n 條記錄。這在實際的業務邏輯中會經常用到,比如查找考試成績在前三名的學生,或者是薪水最高的五位公司員工。而 OPTIMZE FOR n ROWS 這個子句可能並不被一般用戶所熟悉,它的作用是告訴 DB2 的優化器採用儘可能快的方式來返回結果集中的前 n 條記錄,但是注意最終結果集中的所有記錄都會被返回,這是它與 FETCH FIRST n ROWS ONLY 的不同。
通常情況下,取得結果集中的全部記錄(比如 1000000 條)與取出其中的前 n 條記錄(比如第 1 條記錄)相比,最優化的方法是不一樣的。比如對於後者而言,通過索引來訪問可能是最快的,而這種訪問對於前者卻未必是最佳的訪問方式。也就是說,如果想要只取回結果集當中的前 n 條記錄,應該使得 DB2 優化器知道這一點,從而選取最優的訪問方式。
所以如果 SQL 語句中帶有 FETCH FIRST n ROWS ONLY 這個子句,那麼應該同時加上 OPTIMZE FOR n ROWS 子句來配合使用。比如對於如下的 SQL 語句:
SELECT e.name FROM employee e, department d WHERE e.workdept = d.deptno FETCH FIRST 10 ROWS ONLY |
可以加上 OPTIMZE FOR n ROWS 子句變爲如下的形式:
SELECT e.name FROM employee e, department d WHERE e.workdept = d.deptno FETCH FIRST 10 ROWS ONLY OPTIMIZE FOR 10 ROWS |
這樣一來,DB2 優化器就會盡量採用最優化的方式來儘快返回前 10 條結果,比如避免採用一個臨時表來存儲中間結果,從而達到查詢性能上的提升。
本文介紹了在編寫 SQL 語句時幾種可能影響 DB2 查詢性能的常見問題,並給出相應的編寫高效 SQL 語句的最佳實踐,它們對於數據庫程序開發人員在 DB2 上進行 SQL 查詢性能調優時非常重要。如果你的 SQL 語句寫的高效簡潔,你就可以有效的避免在 DB2 查詢時可能出現的性能問題,從而減少總的成本並提高客戶業務的投資回報。