Categories
程式開發

數據庫內核雜談(六):表的JOIN(連接)


在上篇文章中,我們主要介紹了排序和聚合的實現,覆蓋了單個表的大部分操作。但數據庫強大的地方在於,它能夠把現實中的某一塊業務,映射地表達成一系列的表的集合,並且其查詢語句SQL支持多個表相關聯的查詢操作。這種連接多表的查詢使得數據庫的功能得到了一個質的飛躍。對應用開發者而言,相當於打開了一個新世界的大門。原本一個個獨立的表,因為連接而產生了無窮多種可能。本文我們就來細聊一下表與表之間的JOIN(連接)算子的實現。

為什麼需要JOIN?

在說具體實現前,咱們先細聊一下為什麼數據庫需要支持JOIN。這個問題,甚至可以再退一步到為什麼數據庫需要有多個表?其實答案很簡單,上文都提到了:因為現實中的事物就是複雜,多樣的。多個表之間的關係能方便地和現實世界的事物映射起來,並且這種映射更直觀,更能夠讓人接受。就像面向對象編程一樣,恐怕我們都不能想像如果面向對象編程只能創建一種類型的對象吧。

除了方便映射實物,業務邏輯同樣需要JOIN。舉個例子,假設現在有個簡單的電商系統有買家,賣家和訂單三張表,數據科學家想要查詢2018年,對於上海客戶的銷售額最高的前3位賣家信息。這麼一個簡單的查詢其實就用到了每個表裡的信息,需要從買家表裡得到賣家信息,從買家表裡得到地址是上海的買家ID,然後對訂單表以賣家ID做組隊聚合,最後對銷售總額進行排序並取前3。讀者可能想到,這些業務邏輯其實也可以放在應用層做,的確如此(並且,如果是使用某些NoSQL的用戶,因為還不支持JOIN,應用層實現是唯一的選擇)。放在應用層有下面這些短處,一是執行效率方面肯定不如在數據庫高效;二是數據一致性和正確性得不到保證。假設在多用戶的情形下,有多個用戶同時在更新數據和查詢數據,如何保證查詢數據的正確性。由於應用層沒有數據庫系統的全局觀,在保證對多用戶的支持上實現會更加複雜。業務驅動實現,數據庫系統就推出了SQL查詢語句讓實現業務的程序員通過構建不同的SQL語句就能得到相應的信息,而把複雜的執行邏輯,資源管理,多用戶協調等等都封裝進了數據庫內部。這樣不僅提高了整體執行效率,也大大簡化了業務邏輯的開發工作。這裡插個題外話,現在很多互聯網公司開始推出數據中台,我覺得和數據庫系統提供SQL查詢語句的封裝是類似的概念:由於業務更加複雜,但迭代需求更快,如果每次實現新的業務或報表都需要寫很複雜的查詢語句,那豈不是效率很低。如何能夠根據業務邏輯需求,把數據標準化,接口化,提供比SQL語句更高層次的API來方便上層開發。除了更進一步提高效率,還能提高安全性和對數據的控制性。未準就出現一套新的數據操作的標準,值得期待。

鋪墊做好了,進入正題環節。在查詢語句中經常可能涉及多個表的JOIN,比如我們示例中的訂單,賣家和買家。在實現過程中,我們可以選擇兩個表先JOIN變成一個暫存的中間表然後再和下一個表JOIN來獲得最終結果(當然也有一些高級的支持多表同時JOIN的算子操作,就不在這篇進行深入了)因此,只要提供兩個表JOIN的算子操作,就能依次實現多表JOIN,得到最終結果。今天討論的實現都針對兩個表。

其次,和討論排序以及聚合一樣,為了估計和比較時間複雜度,我們假設有兩個表,表A和表B;分別有M個block(頁),m個row以及N個Block和n個row的數據,並且假設M < N 和m < n。最後,因為絕大部分的JOIN都涉及equality join,也就是JOIN的where condition裡面是等式比如(WHERE A.id = B.id),今天討論的JOIN實現都是基於equality join。

NestedLoopJoin:看似暴力的兩層for循環

如果有兩個表,讓你在應用層做join,最容易想到的是什麼方法?你可能應口而出,可以用兩層for循環:第一層for循環表A的每一個row,然後嵌套內部另一層表B的for循環,循環體內做具體的join邏輯,如果滿足條件,就輸出一個joint的結果,代碼示例如下圖所示:

數據庫內核雜談(六):表的JOIN(連接) 1

NestedLoopJoin

這就是我們今天介紹的第一個JOIN算子實現,NestedLoopJoin;名副其實的兩層for循環。如果表A和表B都能放進內存裡,那總共的IO時間複雜度就是M + N:只需要讀取M + N個page。那如果內存有限,每次每個表只能讀取一個page,又該怎麼改進呢?改進的代碼如下圖所示:

數據庫內核雜談(六):表的JOIN(連接) 2

block-based NestedLoopJoin

如代碼所示,先讀取一個外層表的block,然後依次讀取內層表的block,比較結束後再讀取下一個。這種時間下的IO cost是多少?應該是 M + M * N。由此也可見,在運行NestedLoopJoin時,應該盡量把小的表放在外層循環中來減少IO次數。

這裡,給大家出一道思考題,現在假設系統允許分配B個page給這個join算子,應該怎麼分配讀取才能最優,最後IO時間複雜度又是多少呢?

有同學問,還能不能進一步優化,答案是可以的。時間複雜度的大頭主要在M * N, 有什麼辦法可以優化這一塊?要想辦法避免對錶B進行全表掃描。優化方法就是,如果表B在對應的join鍵上建立索引,那我們就能用Index Scan來取代全表掃描。這就是NestedLoopIndexJoin。假設每次Index Scan的時間是常數C,它的時間複雜度就是 M + m * C。相較於前面 block-based NestedLoopJoin又提高了不少。

總結一下,我們第一個介紹的Join實現就是NestedLoopJoin,看似暴力的2層for循環。同時,也引出了block-based和內層循環用IndexScan替代的優化。

SortMergeJoin:Merge 2 sorted List

做過LeetCode的同學肯定都知道這題,Merge 2 sorted list。只要對每個list維護一個指針,然後相互比較後移即可。其實這個思路同樣可以應用到表的JOIN上,只要對兩個表分別根據JOIN鍵做排序,然後在JOIN時,對每個表維護一個指針,當兩邊指針的鍵值相同,就可以輸出JOIN的row。指針不斷後移,直到一方終止為止。代碼如下圖所示:

數據庫內核雜談(六):表的JOIN(連接) 3

SortMergeJoin

從IO的時間複雜度來看,又是多少呢?在講排序那章的時候我們已經討論過,對M個page的表做排序,複雜度是2M * (log2M)。因此,總共的時間複雜度是M + N + 2M * (log2M) + 2N * (log2N)。因為沒有M*N這一項,因此當兩個表都比較大的情況下,性能是優於NestedLoopJoin的。

除了性能進步,SortMergeJoin還有什麼好的特性?和BTreeIndexScan一樣,SortMergeJoin後輸出的tuples是已經排過序的。因此,如果上層的SQL語句還有對應鍵排序的需求,就不再需要額外排序了。並且排序的需求不單單指ORDER BY語句,上一期提到過的,作組隊聚合(group aggregation)的時候,有序的tuple就不需要額外建立Hash表,可以直接用SortGroupByAgg來實現。另外,如果系統在JOIN前發現一方或者兩方都已經排序過,同樣在JOIN時就可以直接使用MERGE JOIN。很多情況下,排序是一個一勞永逸的過程,一旦排序後,上層的語句可能都能獲益。

總結一下,第二個JOIN算子實現是SortMergeJoin,分別對兩張表根據JOIN鍵排序然後合併。

HashJoin:最高效的Join

說完了利用排序來作JOIN,結合上篇文章中我們提過的,實現聚類可以用排序或者哈希表。排序和哈希表在數據庫算子裡有那麼些既生瑜何生亮的即視感。你是不是也預料到了,在表JOIN的時候,哈希表依然作為排序的對手出現。這正是我們要介紹的最後一種實現HashJoin,並且HashJoin再一次扮演了亮的角色。 HashJoin算法很直觀,對相對較小的表表A,根據join condition來建立哈希表。然後對錶B,只要依次讀入數據去和哈希表裡的值去匹配,如果匹配,那就生成一個新的tuple。代碼如下圖所示:

數據庫內核雜談(六):表的JOIN(連接) 4

HashJoin

下面來計算IO時間複雜度,如果假設較小的表A建立的哈希表能夠全部放進內存的話,IO時間複雜度只有 M+N。

按照這種方式,假設將較小的外部表M建立哈希表,如果能全部放進內存的話,然後對N表進行全表掃描並且對比hash。 IO時間複雜度只有 M + N。如果表A比較大,不能全部放進內存的話,我們又要藉助外部哈希表的算法來分而治之。首先,我們用一個哈希函數把表A和表B分別劃分到x個bucket,如果在劃分的過程中發現某個bucket依然很大,可以藉助遞歸思想,繼續劃分。我們稱之為partition phase。 Partition phase的IO複雜度是2 * (M + N),因為需要把所有的表A和表B讀取到內存在寫回文件系統。第二階段就是對於每個bucket,分別讀取表A和表B然後建立哈希表做JOIN,稱之為probing phase。這個階段會再次讀取所有表A和表B的數據,因此是 M + N。因此,HASH JOIN的時間複雜度,在即使需要依賴外部哈希表的情況下,依然是線性的 3*(M+N)。所以在絕大多數的情況下,性能都是最優的。

總結一下,最後一個JOIN實現是HashJoin,通過對相對較小的表建立哈希表,然後讀取另一個表來匹配生成join rows。

總結

總結時刻,本章我們詳細介紹了三種JOIN算子的實現,分析了它們的適用場景和時間複雜度。至此,數據庫內和雜談覆蓋了常見算子的實現。各位讀者可以回想一下寫過的SQL,是否能能夠使用我們討論過的算子組合成算子樹來實現這些查詢語句。

說了那麼多種JOIN,讀者可能會想,即然已經說了HashJoin普遍情況下性能最優,為什麼還需要實現其他JOIN呢?因為在特定的情況下,NestedLoopIndexScan可能會比HashJoin更快,或者在需要排序的情況下,SortMergeJoin也可能更有優勢。影響因素有具體的查詢語句,表A和表B的大小,join鍵值的分佈,以及是否對join鍵值有index等等。數據庫在執行語句的時候,需要通盤考慮這些影響因素來決定最後具體使用哪種JOIN算子。而做這個決定的就是咱們下一期要講的內容:數據庫的大腦 – 優化器。盡情期待。

作者介紹:

顧仲賢,現任Facebook Tech Lead,專注於數據庫,分佈式系統,數據密集型應用後端架構與開發。擁有多年分佈式數據庫內核開發經驗,發表數十篇數據庫頂級期刊併申請獲得多項專利,對搜索,即時通訊系統有深刻理解,愛設計愛架構,持續跟進互聯網前沿技術。

2008年畢業於上海交大軟件學院,2012年,獲得美國加州大學戴維斯計算機碩士,博士學位;2013-2014年任Pivotal數據庫核心研發團隊資深工程師,開發開源數據庫優化器Orca;2016年作為初創員工加入Datometry,任首席工程師,負責全球首家數據庫虛擬化平台開發;2017年至今就職於Facebook任Tech Lead,領導重構搜索相關後端服務及數據管道, 管理即時通訊軟件WhatsApp數據平台負責數據收集,整理,並提供後續應用。

相關閱讀:

數據庫內核雜談(一):一小時實現一個基本功能的數據庫

數據庫內核雜談(二):存儲“演化論”

數據庫內核雜談(三):索引優化

數據庫內核雜談(四):執行模式

數據庫內核雜談(五):如何實現排序和聚合