MySQL之LEFT JOIN問題匯總


使用ON和WHRERE對表數據過濾

背景

left join在我們使用mysql查詢的過程中可謂非常常見,比如博客里一篇文章有多少條評論、商城里一個貨物有多少評論、一條評論有多少個贊等等。但是由於對join、on、where等關鍵字的不熟悉,有時候會導致查詢結果與預期不符,所以今天我就來總結一下,一起避坑。

這里我先給出一個場景,並拋出兩個問題,如果你都能答對那這篇文章就不用看了。

假設有一個班級管理應用,有一個表classes,存了所有的班級;有一個表students,存了所有的學生,具體數據如下(在線SQL:https://www.liaoxuefeng.com/wiki/1177760294764384/1179611432985088):

SELECT * FROM classes;
id name
1 一班
2 二班
3 三班
4 四班

SELECT * FROM students;
id class_id name gender
1 1 小明 M
2 1 小紅 F
3 1 小軍 M
4 1 小米 F
5 2 小白 F
6 2 小兵 M
7 2 小林 M
8 3 小新 F
9 3 小王 M
10 3 小麗 F

那么現在有兩個需求:
1、找出每個班級的名稱及其對應的女同學數量
2、找出一班的同學總數

對於需求1,大多數人不假思索就能想出如下兩種sql寫法,請問哪種是對的?

SELECT c.name, count(s.name) as num
FROM classes c left join students s
on s.class_id = c.id
and s.gender = 'F'
group by c.name
或者
SELECT c.name, count(s.name) as num
FROM classes c left join students s
on s.class_id = c.id
where s.gender = 'F'
group by c.name

對於需求2,大多數人也可以不假思索的想出如下兩種sql寫法,請問哪種是對的?

SELECT c.name, count(s.name) as num
FROM classes c left join students s
on s.class_id = c.id
where c.name = '一班'
group by c.name
或者
SELECT c.name, count(s.name) as num
FROM classes c left join students s
on s.class_id = c.id
and c.name = '一班'
group by c.name
請不要繼續往下翻 !!先給出你自己的答案,正確答案就在下面。
.
.
.
.
.
.
.
.
答案是兩個需求都是第一條語句是正確的,要搞清楚這個問題,就得明白mysql對於left join的執行原理,下節進行展開。

原理

mysql 對於left join的采用類似嵌套循環的方式來進行從處理,以下面的語句為例:

SELECT * FROM LT LEFT JOIN RT ON P1(LT,RT)) WHERE P2(LT,RT)
其中P1是on過濾條件,缺失則認為是TRUE,P2是where過濾條件,缺失也認為是TRUE

該語句的執行邏輯可以描述為:
FOR each row lt in LT {// 遍歷左表的每一行
BOOL b = FALSE;
FOR each row rt in RT such that P1(lt, rt) {// 遍歷右表每一行,找到滿足join條件的行
IF P2(lt, rt) {//滿足 where 過濾條件
t:=lt||rt;//合並行,輸出該行
}
b=TRUE;// lt在RT中有對應的行
}
IF (!b) { // 遍歷完RT,發現lt在RT中沒有有對應的行,則嘗試用null補一行
IF P2(lt,NULL) {// 補上null后滿足 where 過濾條件
t:=lt||NULL; // 輸出lt和null補上的行
}
}
}
當然,實際情況中MySQL會使用buffer的方式進行優化,減少行比較次數,不過這不影響關鍵的執行流程,不在本文討論范圍之內。

從這個偽代碼中,我們可以看出兩點:
1、右表限制用ON
如果想對右表進行限制,則一定要在on條件中進行,若在where中進行則可能導致數據缺失,導致左表在右表中無匹配行的行在最終結果中不出現,違背了我們對left join的理解。因為對左表無右表匹配行的行而言,遍歷右表后b=FALSE,所以會嘗試用NULL補齊右表,但是此時我們的P2對右表行進行了限制,NULL若不滿足P2(NULL一般都不會滿足限制條件,除非IS NULL這種),則不會加入最終的結果中,導致結果缺失。
2、左表限制用WHERE
如果沒有where條件,無論on條件對左表進行怎樣的限制,左表的每一行都至少會有一行的合成結果,對左表行而言,若右表若沒有對應的行,則右表遍歷結束后b=FALSE,會用一行NULL來生成數據,而這個數據是多余的。所以對左表進行過濾必須用where。

分析總結

下面展開兩個需求的錯誤語句的執行結果和錯誤原因:

需求1
name num
一班 2
二班 1
三班 2
需求1由於在where條件中對右表限制,導致數據缺失(四班應該有個為0的結果)

需求2
name num
一班 4
二班 0
三班 0
四班 0
需求2由於在on條件中對左表限制,導致數據多余(其他班的結果也出來了,還是錯的)

通過上面的問題現象和分析,可以得出了結論:
在left join語句中,左表過濾必須放where條件中,右表過濾必須放on條件中

SQL 看似簡單,其實也有很多細節原理在里面,一個小小的混淆就會造成結果與預期不符,所以平時要注意這些細節原理,避免關鍵時候出錯。

小表與大表關聯(join)的性能分析

誤區

			經常看到一些Hive優化的建議中說當小表與大表做關聯時,把小表寫在前面,這樣可以使Hive的關聯速度更快,提到的原因都是說因為小表可以先放到內存中,然后大表的每條記錄再去內存中檢測,最終完成關聯查詢。這樣的原因看似合理,但是仔細推敲,又站不住腳跟。
			
   多小的表算小表?如果所謂的小表在內存中放不下怎么辦?我用2個只有幾條記錄的表做關聯查詢,這應該算是小表了,在查看reduce的執行日志時依然是有寫磁盤的操作的。實際上reduce在接收全部map的輸出后一定會有一個排序所有鍵值對並合並寫入磁盤文件的操作。寫入磁盤(spill)有可能是多次的,因此有可能會生成多個臨時文件,但是最終都要合並成一個文件,即最終每一個reduce都只處理一個文件。 
   我做了一個實驗,用1條記錄的表和3億多條記錄的表做join,無論小表是放在join的前面還是join的后面,執行的時間幾乎都是相同的。再去看reduce的執行日志,1條記錄的表在join前或者join后兩次查詢的reduce日志幾乎也是一摸一樣的。如果按照上面的說法把join左側的表放內存等待join右側的表到內存中去檢測,那么當3億多條記錄的表放在join左側時,內存肯定是無法容下這么多記錄的,勢必要進行寫磁盤的操作,那它的執行時間應該會比小表在join前時長很多才對,但事實並不是這樣,也就說明了上面說到的原因並不合理。 

原理

		 事實上“把小表放在前面做關聯可以提高效率”這種說法是錯誤的。
		 
		 正確的說法應該是“把重復關聯鍵少的表放在join前面做關聯可以提高join的效率。” 
		 
   分析一下Hive對於兩表關聯在底層是如何實現的。因為不論多復雜的Hive查詢,最終都要轉化成mapreduce的JOB去執行,因此Hive對於關聯的實現應該和mapreduce對於關聯的實現類似。而mapreduce對於關聯的實現,簡單來說,是把關聯鍵和標記是在join左邊還是右邊的標識位作為組合鍵(key),把一條記錄以及標記是在join左邊還是右邊的標識位組合起來作為值(value)。在reduce的shuffle階段,按照組合鍵的關聯鍵進行主排序,當關聯鍵相同時,再按照標識位進行輔助排序。而在分區段時,只用關聯鍵中的關聯鍵進行分區段,這樣關聯鍵相同的記錄就會放在同一個value list中,同時保證了join左邊的表的記錄在value list的前面,而join右邊的表的記錄在value list的后面。 

例子

例如A join B ON (A.id = b.id) ,假設A表和B表都有1條id = 3的記錄,那么A表這條記錄的組合鍵是(3,0),B表這條記錄的組合鍵是(3,1)。排序時可以保證A表的記錄在B表的記錄的前面。而在reduce做處理時,把id=3的放在同一個value list中,形成 key = 3,value list = [A表id=3的記錄,B表id=3的記錄]

   接下來我們再來看當兩個表做關聯時reduce做了什么。Reduce會一起處理id相同的所有記錄。我們把value list用數組來表示。 
		 
   1)   Reduce先讀取第一條記錄v[0],如果發現v[0]是B表的記錄,那說明沒有A表的記錄,最終不會關聯輸出,因此不用再繼續處理這個id了,讀取v[0]用了1次讀取操作。 
		 
   2)   如果發現v[0]到v[length-1]全部是A表的記錄,那說明沒有B表的記錄,同樣最終不會關聯輸出,但是這里注意,已經對value做了length次的讀取操作。 
		 
   3)   例如A表id=3有1條記錄,B表id=3有10條記錄。首先讀取v[0]發現是A表的記錄,用了1次讀取操作。然后再讀取v[1]發現是B表的操作,這時v[0]和v[1]可以直接關聯輸出了,累計用了2次操作。這時候reduce已經知道從v[1]開始后面都是B 表的記錄了,因此可以直接用v[0]依次和v[2],v[3]……v[10]做關聯操作並輸出,累計用了11次操作。
		 
   4)   換過來,假設A表id=3有10條記錄,B表id=3有1條記錄。首先讀取v[0]發現是A表的記錄,用了1次讀取操作。然后再讀取v[1]發現依然是A表的記錄,累計用了2次讀取操作。以此類推,讀取v[9]時發現還是A表的記錄,累計用了10次讀取操作。然后讀取最后1條記錄v[10]發現是B表的記錄,可以將v[0]和v[10]進行關聯輸出,累計用了11次操作。接下來可以直接把v[1]~v[9]分別與v[10]進行關聯輸出,累計用了20次操作。 
		 
   5)   再復雜一點,假設A表id=3有2條記錄,B表id=3有5條記錄。首先讀取v[0]發現是A表的記錄,用了1次讀取操作。然后再讀取v[1]發現依然是A表的記錄,累計用了2次讀取操作。然后讀取v[2]發現是B表的記錄,此時v[0]和v[2]可以直接關聯輸出,累計用了3次操作。接下來v[0]可以依次和v[3]~v[6]進行關聯輸出,累計用了7次操作。接下來v[1]再依次和v[2]~v[6]進行關聯輸出,累計用了12次操作。 
		 
   6)   把5的例子調過來,假設A表id=3有5條記錄,B表id=3有2條記錄。先讀取v[0]發現是A表的記錄,用了1次讀取操作。然后再讀取v[1]發現依然是A表的記錄,累計用了2次讀取操作。以此類推,讀取到v[4]發現依然是A表的記錄,累計用了5次讀取操作。接下來讀取v[5],發現是B表的記錄,此時v[0]和v[5]可以直接關聯輸出,累計用了6次操作。然后v[0]和v[6]進行關聯輸出,累計用了7次操作。然后v[1]分別與v[5]、v[6]關聯輸出,累計用了9次操作。V[2] 分別與v[5]、v[6]關聯輸出,累計用了11次操作。以此類推,最后v[4] 分別與v[5]、v[6]關聯輸出,累計用了15次操作。 
		 
   7)   額外提一下,當reduce檢測A表的記錄時,還要記錄A表同一個key的記錄的條數,當發現同一個key的記錄個數超過hive.skewjoin.key的值(默認為1000000)時,會在reduce的日志中打印出該key,並標記為傾斜的關聯鍵。 

結論

   寫在關聯左側的表每有1條重復的關聯鍵時底層就會多1次運算處理。
		 最終得出的結論是:把重復關聯鍵少的表放在join前面做關聯可以提高join的效率
   假設A表有一千萬個id,平均每個id有3條重復值,那么把A表放在前面做關聯就會多做三千萬次的運算處理,這時候誰寫在前誰寫在后就看出性能的差別來了。

參考:
1、https://segmentfault.com/a/1190000020458807
2、https://blog.csdn.net/qq_26442553/article/details/80865014


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM