1. 程式人生 > >關系型數據庫進階(三)連接運算及查詢實例

關系型數據庫進階(三)連接運算及查詢實例

array 怎麽 細節 處理 file 哈希聯接 intro 查看 分析

上篇文字,我們知道如何獲取數據了,那現在就把它們聯接起來!

  我要展現的是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 inner
if(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 …) 可以順序或並行執行,這取決於執行器。為了獲得和寫入數據,查詢執行器與數據管理器交互,本文下一部分來討論數據管理器。

關系型數據庫進階(三)連接運算及查詢實例