關系型數據庫進階(三)連接運算及查詢實例
上篇文字,我們知道如何獲取數據了,那現在就把它們聯接起來!
我要展現的是3個個常用聯接運算符:合並聯接(Merge join),哈希聯接(Hash Join)和嵌套循環聯接(Nested Loop Join)。但是在此之前,我需要引入新詞匯了:內關系和外關系(inner relation and outer relation)。
一個關系可以是:
一個表
一個索引
上一個運算的中間結果(比如上一個聯接運算的結果)。
當你聯接兩個關系時,聯接算法對兩個關系的處理是不同的。在本文剩余部分,我將假定:
外關系是左側數據集
內關系是右側數據集
比如, A JOIN B 是 A 和 B 的聯接,這裏 A 是外關系,B 是內關系。
多數情況下,A JOIN B 的成本跟 B JOIN A 的成本是不同的。
在這一部分,我還將假定外關系有 N 個元素,內關系有 M 個元素。要記住,真實的優化器通過統計知道 N 和 M 的值。
註:N 和 M 是關系的基數。【註:基數】
嵌套循環聯接
嵌套循環聯接是最簡單的。
道理如下:
1 針對外關系的每一行
2 查看內關系裏的所有行來尋找匹配的行
下面是偽代碼:
nested_loop_join(array outer,array inner) foreach row a in outer foreach row b in innerif(match_join_condition(a,b)) write_result_in_output(a,b) end if end for end for
由於這是個雙叠代,時間復雜度是 O(N*M)。
在磁盤 I/O 方面, 針對 N 行外關系的每一行,內部循環需要從內關系讀取 M 行。這個算法需要從磁盤讀取 N+ N*M 行。但是,如果內關系足夠小,你可以把它讀入內存,那麽就只剩下 M + N 次讀取。這樣修改之後,內關系必須是最小的,因為它有更大機會裝入內存。
在CPU成本方面沒有什麽區別,但是在磁盤 I/O 方面,最好最好的,是每個關系只讀取一次。
當然,內關系可以由索引代替,對磁盤 I/O 更有利。
由於這個算法非常簡單,下面這個版本在內關系太大無法裝入內存時,對磁盤 I/O 更加有利。道理如下:
1 為了避免逐行讀取兩個關系,
2 你可以成簇讀取,把(兩個關系裏讀到的)兩簇數據行保存在內存裏,
3 比較兩簇數據,保留匹配的,
4 然後從磁盤加載新的數據簇來繼續比較
5 直到加載了所有數據。
可能的算法如下:
// improved version to reduce the disk I/O. nested_loop_join_v2(file outer, file inner) for each bunch ba in outer // ba is now in memory for each bunch bb in inner // bb is now in memory for each row a in ba for each row b in bb if (match_join_condition(a,b)) write_result_in_output(a,b) end if end for end for end for end for
使用這個版本,時間復雜度沒有變化,但是磁盤訪問降低了:
用前一個版本,算法需要 N + N*M 次訪問(每次訪問讀取一行)。
用新版本,磁盤訪問變為外關系的數據簇數量 + 外關系的數據簇數量 * 內關系的數據簇數量。
增加數據簇的尺寸,可以降低磁盤訪問。
哈希聯接
哈希聯接更復雜,不過在很多場合比嵌套循環聯接成本低。
哈希聯接的道理是:
1) 讀取內關系的所有元素
2) 在內存裏建一個哈希表
3) 逐條讀取外關系的所有元素
4) (用哈希表的哈希函數)計算每個元素的哈希值,來查找內關系裏相關的哈希桶內
5) 是否與外關系的元素匹配。
在時間復雜度方面我需要做些假設來簡化問題:
1 內關系被劃分成 X 個哈希桶
2 哈希函數幾乎均勻地分布每個關系內數據的哈希值,就是說哈希桶大小一致。
3 外關系的元素與哈希桶內的所有元素的匹配,成本是哈希桶內元素的數量。
時間復雜度是 (M/X) * N + 創建哈希表的成本(M) + 哈希函數的成本 * N 。
如果哈希函數創建了足夠小規模的哈希桶,那麽復雜度就是 O(M+N)。
還有個哈希聯接的版本,對內存有利但是對磁盤 I/O 不夠有利。 這回是這樣的:
1) 計算內關系和外關系雙方的哈希表
2) 保存哈希表到磁盤
3) 然後逐個哈希桶比較(其中一個讀入內存,另一個逐行讀取)。
合並聯接
合並聯接是唯一產生排序的聯接算法。
註:這個簡化的合並聯接不區分內表或外表;兩個表扮演同樣的角色。但是真實的實現方式是不同的,比如當處理重復值時。
1.(可選)排序聯接運算:兩個輸入源都按照聯接關鍵字排序。
2.合並聯接運算:排序後的輸入源合並到一起。
排序
我們已經談到過合並排序,在這裏合並排序是個很好的算法(但是並非最好的,如果內存足夠用的話,還是哈希聯接更好)。
然而有時數據集已經排序了,比如:
1 如果表內部就是有序的,比如聯接條件裏一個索引組織表【index-organized table】
2 如果關系是聯接條件裏的一個索引
3 如果聯接應用在一個查詢中已經排序的中間結果
合並聯接
這部分與我們研究過的合並排序中的合並運算非常相似。不過這一次呢,我們不是從兩個關系裏挑選所有元素,而是只挑選相同的元素。道理如下:
1) 在兩個關系中,比較當前元素(當前=頭一次出現的第一個)
2) 如果相同,就把兩個元素都放入結果,再比較兩個關系裏的下一個元素
3) 如果不同,就去帶有最小元素的關系裏找下一個元素(因為下一個元素可能會匹配)
4) 重復 1、2、3步驟直到其中一個關系的最後一個元素。
因為兩個關系都是已排序的,你不需要『回頭去找』,所以這個方法是有效的。
該算法是個簡化版,因為它沒有處理兩個序列中相同數據出現多次的情況(即多重匹配)。真實版本『僅僅』針對本例就更加復雜,所以我才選擇簡化版。
如果兩個關系都已經排序,時間復雜度是 O(N+M)
如果兩個關系需要排序,時間復雜度是對兩個關系排序的成本:O(N*Log(N) + M*Log(M))
對於計算機極客,我給出下面這個可能的算法來處理多重匹配(註:對於這個算法我不保證100%正確):
mergeJoin(relation a, relation b) relation output integer a_key:=0; integer b_key:=0; while (a[a_key]!=null and b[b_key]!=null) if (a[a_key] < b[b_key]) a_key++; else if (a[a_key] > b[b_key]) b_key++; else //Join predicate satisfied write_result_in_output(a[a_key],b[b_key]) //We need to be careful when we increase the pointers if (a[a_key+1] != b[b_key]) b_key++; end if if (b[b_key+1] != a[a_key]) a_key++; end if if (b[b_key+1] == a[a_key] && b[b_key] == a[a_key+1]) b_key++; a_key++; end if end if end while
哪個算法最好?
如果有最好的,就沒必要弄那麽多種類型了。這個問題很難,因為很多因素都要考慮,比如:
1 空閑內存:沒有足夠的內存的話就跟強大的哈希聯接拜拜吧(至少是完全內存中哈希聯接)。
2 兩個數據集的大小。比如,如果一個大表聯接一個很小的表,那麽嵌套循環聯接就比哈希聯接快,因為後者有創建哈希的高昂成本;如果兩個表都非常大,那麽嵌套循環聯接CPU成本就很高昂。
3 是否有索引:有兩個 B+樹索引的話,聰明的選擇似乎是合並聯接。
4 結果是否需要排序:即使你用到的是未排序的數據集,你也可能想用成本較高的合並聯接(帶排序的),因為最終得到排序的結果後,你可以把它和另一個合並聯接串起來(或者也許因為查詢用 ORDER BY/GROUP BY/DISTINCT 等操作符隱式或顯式地要求一個排序結果)。
5 關系是否已經排序:這時候合並聯接是最好的候選項。
6 聯接的類型:是等值聯接(比如 tableA.col1 = tableB.col2 )? 還是內聯接?外聯接?笛卡爾乘積?或者自聯接?有些聯接在特定環境下是無法工作的。
7 數據的分布:如果聯接條件的數據是傾斜的(比如根據姓氏來聯接人,但是很多人同姓),用哈希聯接將是個災難,原因是哈希函數將產生分布極不均勻的哈希桶。
8 如果你希望聯接操作使用多線程或多進程。
想要更詳細的信息,可以閱讀相關數據庫的文檔。
簡化的例子
我們已經研究了 3 種類型的聯接操作。
現在,比如說我們要聯接 5 個表,來獲得一個人的全部信息。一個人可以有:
多個手機號(MOBILES)
多個郵箱(MAILS)
多個地址(ADRESSES)
多個銀行賬號(BANK_ACCOUNTS)
換句話說,我們需要用下面的查詢快速得到答案:
MySQL SELECT * from PERSON, MOBILES, MAILS,ADRESSES, BANK_ACCOUNTS WHERE PERSON.PERSON_ID = MOBILES.PERSON_ID AND PERSON.PERSON_ID = MAILS.PERSON_ID AND PERSON.PERSON_ID = ADRESSES.PERSON_ID AND PERSON.PERSON_ID = BANK_ACCOUNTS.PERSON_ID
作為一個查詢優化器,我必須找到處理數據最好的方法。但有 2 個問題:
每個聯接使用那種類型?
我有 3 種可選(哈希、合並、嵌套),同時可能用到 0, 1 或 2 個索引(不必說還有多種類型的索引)。
按什麽順序執行聯接?
比如,下圖顯示了針對 4 個表僅僅 3 次聯接,可能采用的執行計劃:
那麽下面就是我可能采取的方法:
1) 采取粗暴的方式
用數據庫統計,計算每種可能的執行計劃的成本,保留最佳方案。但是,會有很多可能性。對於一個給定順序的聯接操作,每個聯接有三種可能性:哈希、合並、嵌套,那麽總共就有 3^4 種可能性。確定聯接的順序是個二叉樹的排列問題,會有(2*4)!/(4+1)! 種可能的順序。對本例這個相當簡化了的問題,我最後會得到
3^4*(2*4)!/(4+1)! 種可能。
拋開專業術語,那相當於 27,216 種可能性。如果給合並聯接加上使用 0,1 或 2 個 B+樹索引,可能性就變成了 210,000種。我是不是告訴過你這個查詢其實非常簡單嗎?
2) 我大叫一聲辭了這份工作
很有誘惑力,但是這樣一來,你不會的到查詢結果,而我需要錢來付賬單。
3) 我只嘗試幾種執行計劃,挑一個成本最低的。
由於不是超人,我不能算出所有計劃的成本。相反,我可以武斷地從全部可能的計劃中選擇一個子集,計算它們的成本,把最佳的計劃給你。
4) 我用聰明的規則來降低可能性的數量
有兩種規則:
我可以用『邏輯』規則,它能去除無用的可能性,但是無法過濾大量的可能性。比如:
『嵌套聯接的內關系必須是最小的數據集』。
我接受現實,不去找最佳方案,用更激進的規則來大大降低可能性的數量。比如:『如果一個關系很小,使用嵌套循環聯接,絕不使用合並或哈希聯接。』
在這個簡單的例子中,我最後得到很多可能性。但現實世界的查詢還會有其他關系運算符,像 OUTER JOIN, CROSS JOIN, GROUP BY, ORDER BY, PROJECTION, UNION, INTERSECT, DISTINCT … 這意味著更多的可能性。
那麽,數據庫是如何處理的呢?
動態規劃,貪婪算法和啟發式算法
關系型數據庫會嘗試我剛剛提到的多種方法,優化器真正的工作是在有限時間裏找到一個好的解決方案。
多數時候,優化器找到的不是最佳的方案,而是一個『不錯』的
對於小規模的查詢,采取粗暴的方式是有可能的。但是為了讓中等規模的查詢也能采取粗暴的方式,我們有辦法避免不必要的計算,這就是動態編程。
動態規劃
這幾個字背後的理念是,很多執行計劃是非常相似的。看看下圖這幾種計劃:
它們都有相同的子樹(A JOIN B),所以,不必在每個計劃中計算這個子樹的成本,計算一次,保存結果,當再遇到這個子樹時重用。用更正規的說法,我們面對的是個重疊問題。為了避免對部分結果的重復計算,我們使用記憶法。
應用這一技術,我們不再有 (2*N)!/(N+1)! 的復雜度,而是“只有” 3^N。在之前 4 個JOIN 的例子裏,這意味著將 336 次排序降為 81 次。如果是大一些的查詢,比如 8 個 JOIN (其實也不是很大啦),就是將 57,657,600 次降為 6551 次。【譯者註:這一小段漏掉了,感謝nsos指出來。另外感謝 Clark Li 指出Dynamic Programing 應該翻譯為動態規劃。 】
對於計算機極客,下面是我在先前給你的教程裏找到的一個算法。我不提供解釋,所以僅在你已經了解動態規劃或者精通算法的情況下閱讀(我提醒過你哦):
procedure findbestplan(S) if (bestplan[S].cost infinite) return bestplan[S] // else bestplan[S] has not been computed earlier, compute it now if (S contains only 1 relation) set bestplan[S].plan and bestplan[S].cost based on the best way of accessing S /* Using selections on S and indices on S */ else for each non-empty subset S1 of S such that S1 != S P1= findbestplan(S1) P2= findbestplan(S - S1) A = best algorithm for joining results of P1 and P2 cost = P1.cost + P2.cost + cost of A if cost < bestplan[S].cost bestplan[S].cost = cost bestplan[S].plan = 『execute P1.plan; execute P2.plan; join results of P1 and P2 using A』 return bestplan[S]
針對大規模查詢,你也可以用動態規劃方法,但是要附加額外的規則(或者稱為啟發式算法)來減少可能性。
如果我們僅分析一個特定類型的計劃(例如左深樹 left-deep tree,參考),我們得到 n*2^n 而不是 3^n。
如果我們加上邏輯規則來避免一些模式的計劃(像『如果一個表有針對指定謂詞的索引,就不要對表嘗試合並聯接,要對索引』),就會在不給最佳方案造成過多傷害的前提下,減少可能性的數量。
如果我們在流程裏增加規則(像『聯接運算先於其他所有的關系運算』),也能減少大量的可能性。
……
貪婪算法
但是,優化器面對一個非常大的查詢,或者為了盡快找到答案(然而查詢速度就快不起來了),會應用另一種算法,叫貪婪算法。
原理是按照一個規則(或啟發)以漸進的方式制定查詢計劃。在這個規則下,貪婪算法逐步尋找最佳算法,先處理一條JOIN,接著每一步按照同樣規則加一條新的JOIN。
我們來看個簡單的例子。比如一個針對5張表(A,B,C,D,E)4次JOIN 的查詢,為了簡化我們把嵌套JOIN作為可能的聯接方式,按照『使用最低成本的聯接』規則。
直接從 5 個表裏選一個開始(比如 A)
計算每一個與 A 的聯接(A 作為內關系或外關系)
發現 “A JOIN B” 成本最低
計算每一個與 “A JOIN B” 的結果聯接的成本(“A JOIN B” 作為內關系或外關系)
發現 “(A JOIN B) JOIN C” 成本最低
計算每一個與 “(A JOIN B) JOIN C” 的結果聯接的成本 ……
最後確定執行計劃 “( ( (A JOIN B) JOIN C) JOIN D ) JOIN E )”
因為我們是武斷地從表 A 開始,我們可以把同樣的算法用在 B,然後 C,然後 D, 然後 E。最後保留成本最低的執行計劃。
順便說一句,這個算法有個名字,叫『最近鄰居算法』。
拋開細節不談,只需一個良好的模型和一個 N*log(N) 復雜度的排序,問題就輕松解決了。這個算法的復雜度是 O(N*log(N)) ,對比一下完全動態規劃的 O(3^N)。如果你有個20個聯接的大型查詢,這意味著 26 vs 3,486,784,401 ,天壤之別!
這個算法的問題是,我們做的假設是:找到 2 個表的最佳聯接方法,保留這個聯接結果,再聯接下一個表,就能得到最低的成本。但是:
即使在 A, B, C 之間,A JOIN B 可得最低成本
(A JOIN C) JOIN B 也許比 (A JOIN B) JOIN C 更好。
為了改善這一狀況,你可以多次使用基於不同規則的貪婪算法,並保留最佳的執行計劃。
其他算法
[ 如果你已經受夠了算法話題,就直接跳到下一部分。這部分對文章余下的內容不重要。]【譯者註:我也很想把這段跳過去 -_-f 】
很多計算機科學研究者熱衷於尋找最佳的執行計劃,他們經常為特定問題或模式探尋更好的解決方案,比如:
如果查詢是星型聯接(一種多聯接查詢),某些數據庫使用一種特定的算法。
如果查詢是並行的,某些數據庫使用一種特定的算法。 ……
其他算法也在研究之中,就是為了替換在大型查詢中的動態規劃算法。貪婪算法屬於一個叫做啟發式算法的大家族,它根據一條規則(或啟發),保存上一步找到的方法,『附加』到當前步驟來進一步搜尋解決方法。有些算法根據特定規則,一步步的應用規則但不總是保留上一步找到的最佳方法。它們統稱啟發式算法。
比如,基因算法就是一種:
一個方法代表一種可能的完整查詢計劃
每一步保留了 P 個方法(即計劃),而不是一個。
0) P 個計劃隨機創建
1) 成本最低的計劃才會保留
2) 這些最佳計劃混合在一起產生 P 個新的計劃
3) 一些新的計劃被隨機改寫
4) 1,2,3步重復 T 次
5) 然後在最後一次循環,從 P 個計劃裏得到最佳計劃。
循環次數越多,計劃就越好。
這是魔術?不,這是自然法則:適者生存!
PostgreSQL實現了基因算法,但我並沒有發現它是不是默認使用這種算法的。
數據庫中還使用了其它啟發式算法,像『模擬退火算法(Simulated Annealing)』、『交互式改良算法(Iterative Improvement)』、『雙階段優化算法(Two-Phase Optimization)』…..不過,我不知道這些算法當前是否在企業級數據庫應用了,還是僅僅用在研究型數據庫。
如果想進一步了解,這篇研究文章介紹兩個更多可能的算法《數據庫查詢優化中聯接排序問題的算法綜述》,你可以去閱讀一下。
真實的優化器
[ 這段不重要,可以跳過 ]
然而,所有上述羅裏羅嗦的都非常理論化,我是個開發者而不是研究者,我喜歡具體的例子。
我們來看看SQLite 優化器是怎麽工作的。這是個輕量化數據庫,它使用一種簡單優化器,基於帶有附加規則的貪婪算法,來限制可能性的數量。
SQLite 在有 CROSS JOIN 操作符時從不給表重新排序
使用嵌套聯接
外聯接始終按順序評估
……
3.8.0之前的版本使用『最近鄰居』貪婪算法來搜尋最佳查詢計劃
等等……我們見過這個算法!真是巧哈!
從3.8.0版本(發布於2015年)開始,SQLite使用『N最近鄰居』貪婪算法來搜尋最佳查詢計劃
我們再看看另一個優化器是怎麽工作的。IBM DB2 跟所有企業級數據庫都類似,我討論它是因為在切換到大數據之前,它是我最後真正使用的數據庫。
看過官方文檔後,我們了解到 DB2 優化器可以讓你使用 7 種級別的優化:
對聯接使用貪婪算法
0 – 最小優化,使用索引掃描和嵌套循環聯接,避免一些查詢重寫
1 – 低級優化
2 – 完全優化
對聯接使用動態規劃算法
3 – 中等優化和粗略的近似法
5 – 完全優化,使用帶有啟發式的所有技術
7 – 完全優化,類似級別5,但不用啟發式
9 – 最大優化,完全不顧開銷,考慮所有可能的聯接順序,包括笛卡爾乘積
可以看到DB2 使用貪婪算法和動態規劃算法。當然,他們不會把自己的啟發算法分享出來的,因為查詢優化器是數據庫的看家本領。
DB2 的默認級別是 5,優化器使用下列特性: 【譯者註:以下出現的一些概念我沒有做考證,因為[ 這段不重要,可以跳過 ]】
使用所有可用的統計,包括線段樹(frequent-value)和分位數統計(quantile statistics)。
使用所有查詢重寫規則(含物化查詢表路由,materialized query table routing),除了在極少情況下適用的計算密集型規則。
使用動態規劃模擬聯接
有限使用組合內關系(composite inner relation)
對於涉及查找表的星型模式,有限使用笛卡爾乘積
考慮寬泛的訪問方式,含列表預取(list prefetch,註:我們將討論什麽是列表預取),index ANDing(註:一種對索引的特殊操作),和物化查詢表路由。
默認的,DB2 對聯接排列使用受啟發式限制的動態規劃算法。
其它情況 (GROUP BY, DISTINCT…) 由簡單規則處理。
查詢計劃緩存
由於創建查詢計劃是耗時的,大多數據庫把計劃保存在查詢計劃緩存,來避免重復計算。這個話題比較大,因為數據庫需要知道什麽時候更新過時的計劃。辦法是設置一個上限,如果一個表的統計變化超過了上限,關於該表的查詢計劃就從緩存中清除。
查詢執行器
在這個階段,我們有了一個優化的執行計劃,再編譯為可執行代碼。然後,如果有足夠資源(內存,CPU),查詢執行器就會執行它。計劃中的操作符 (JOIN, SORT BY …) 可以順序或並行執行,這取決於執行器。為了獲得和寫入數據,查詢執行器與數據管理器交互,本文下一部分來討論數據管理器。
關系型數據庫進階(三)連接運算及查詢實例