轉自: https://www.ywnds.com/?p=14399
一、聯接過程介紹
為了后面一些測試案例,我們事先創建了兩張表,表數據如下:
1
2
3
4
|
CREATE TABLE t1 (m1 int, n1 char(1));
CREATE TABLE t2 (m2 int, n2 char(1));
INSERT INTO t1 VALUES(1, 'a'), (2, 'b'), (3, 'c');
INSERT INTO t2 VALUES(2, 'b'), (3, 'c'), (4, 'd'), (5, 'e'), (6, 'f');
|
聯接操作的本質就是把各個聯接表中的記錄都取出來依次匹配的組合加入結果集並返回給用戶。如果沒有任何限制條件的話,多表聯接起來產生的笛卡爾積可能是非常巨大的。比方說3個100行記錄的表聯接起來產生的笛卡爾積就有100×100×100=1000000行數據!所以在聯接的時候過濾掉特定記錄組合是有必要的,在聯接查詢中的過濾條件可以分成兩種,我們以一個JOIN查詢為例:
1
|
SELECT * FROM t1, t2 WHERE t1.m1 > 1 AND t1.m1 = t2.m2 AND t2.n2 < 'd';
|
- 涉及單表的條件
WHERE條件也可以稱為搜索條件,比如t1.m1 > 1是只針對t1表的過濾條件,t2.n2 < ‘d’是只針對t2表的過濾條件。
- 涉及兩表的條件
比如t1.m1 = t2.m2、t1.n1 > t2.n2等,這些條件中涉及到了兩個表,我們稍后會仔細分析這種過濾條件是如何使用的。
在這個查詢中我們指明了這三個過濾條件:
1. t1.m1 > 1
2. t1.m1 = t2.m2
3. t2.n2 < ‘d’
那么這個聯接查詢的大致執行過程如下:
首先確定第一個需要查詢的表,這個表稱之為驅動表。怎樣在單表中執行查詢語句,只需要選取代價最小的那種訪問方法去執行單表查詢語句就好了(就是說從const、ref、ref_or_null、range、index、all這些執行方法中選取代價最小的去執行查詢)。此處假設使用t1作為驅動表,那么就需要到t1表中找滿足t1.m1 > 1的記錄,假設這里並沒有給t1字段添加索引,所以此處查詢t1表的訪問方法就設定為all吧,也就是采用全表掃描的方式執行單表查詢。關於如何提升聯接查詢的性能我們之后再說,現在先把基本概念捋清楚哈。所以查詢過程就如下圖所示:
針對上一步驟中從驅動表產生的結果集中的每一條記錄,分別需要到t2表中查找匹配的記錄,所謂匹配的記錄,指的是符合過濾條件的記錄。因為是根據t1表中的記錄去找t2表中的記錄,所以t2表也可以被稱之為被驅動表。比如上一步驟從驅動表中得到了2條記錄,所以需要查詢2次t2表。此時涉及兩個表的列的過濾條件t1.m1 = t2.m2就派上用場了:
- 當t1.m1 = 2時,過濾條件t1.m1 = t2.m2就相當於t2.m2 = 2,所以此時t2表相當於有了t1.m1 = 2、t2.n2 < ‘d’這兩個過濾條件,然后到t2表中執行單表查詢。
- 當t1.m1 = 3時,過濾條件t1.m1 = t2.m2就相當於t2.m2 = 3,所以此時t2表相當於有了t1.m1 = 3、t2.n2 < ‘d’這兩個過濾條件,然后到t2表中執行單表查詢。
所以整個聯接查詢的執行過程就如下圖所示:
也就是說整個聯接查詢最后的結果只有兩條符合過濾條件的記錄:
1
2
3
4
5
6
|
+------+------+------+------+
| m1 | n1 | m2 | n2 |
+------+------+------+------+
| 2 | b | 2 | b |
| 3 | c | 3 | c |
+------+------+------+------+
|
從上邊兩個步驟可以看出來,我們上邊說的這個兩表聯接查詢共需要查詢1次t1表,2次t2表。當然這是在特定的過濾條件下的結果,如果我們把t1.m1 > 1這個條件去掉,那么從t1表中查出的記錄就有3條,就需要查詢3次t3表了。也就是說在兩表聯接查詢中,驅動表只需要訪問一次,被驅動表可能被訪問多次,這種方式在MySQL中有一個專有名詞,叫Nested-Loops Join(嵌套循環聯接)。我們在真正使用MySQL的時候表動不動就是幾百上千萬數據,如果都按照Nested-Loops Join算法,一次Join查詢的代價也太大了。所以下面就來看看MySQL支持的Join算法都有哪些?
二、聯接算法介紹
聯接算法是MySQL數據庫用於處理聯接的物理策略。目前MySQL數據庫僅支持Nested-Loops Join算法。而MySQL的分支版本MariaDB除了支持Nested-Loops Join算法外,還支持Classic Hash Join算法。當聯接的表上有索引時,Nested-Loops Join是非常高效的算法。根據B+樹的特性,其聯接的時間復雜度為O(N),若沒有索引,則可視為最壞的情況,時間復雜度為O(N²)。MySQL數據庫根據不同的使用場合,支持兩種Nested-Loops Join算法,一種是Simple Nested-Loops Join(NLJ)算法,另一種是Block Nested-Loops Join(BNL)算法。
在講述MySQL的Join類型與算法前,看看兩張表的Join的過程:
上圖的Fetch階段是指當被驅動表關聯的列是輔助索引時,但是需要訪問表中的數據,那么這時就需要再訪問主鍵索引才能得到數據的過程,不論表的存儲引擎是InnoDB存儲引擎還是MyISAM,這都是無法避免的,只是MyISAM的回表速度要快點,因為其輔助索引存放的就是指向記錄的指針,而InnoDB存儲引擎是索引組織表,需要再次通過索引查找才能定位數據。
Fetch階段也不是必須存在的,如果是聚集索引聯接,那么直接就能得到數據,無需回表,也就沒有Fetch這個階段。另外,上述給出了兩張表之間的Join過程,多張表的Join就是繼續上述這個過程。
接着計算兩張表Join的成本,這里有下列幾種概念:
驅動表的掃描次數,記為O。通常驅動表的掃描次數都是1,即Join時掃描一次驅動表的數據即可
被驅動表的掃描次數,記為I。根據不同Join算法,被驅動表的掃描次數不同
讀取表的記錄數,記為R。根據不同Join算法,讀取記錄的數量可能不同
Join的比較次數,記為M。根據不同Join算法,比較次數不同
回表的讀取記錄的數,記為F。若Join的是輔助索引,可能需要回表取得最終的數據
評判一個Join算法是否優劣,就是查看上述這些操作的開銷是否比較小。當然,這還要考慮I/O的訪問方式,順序還是隨機,總之Join的調優也是門藝術,並非想象的那么簡單。
- Simple Nested-Loops Join(SNLJ,簡單嵌套循環聯接)
Simple Nested-Loops Join算法相當簡單、直接。即驅動表中的每一條記錄與被驅動表中的記錄進行比較判斷。對於兩表聯接來說,驅動表只會被訪問一遍,但被驅動表卻要被訪問到好多遍,具體訪問幾遍取決於對驅動表執行單表查詢后的結果集中的記錄條數。對於內聯接來說,選取哪個表為驅動表都沒關系,而外聯接的驅動表是固定的,也就是說左(外)聯接的驅動表就是左邊的那個表,右(外)聯接的驅動表就是右邊的那個表(這個只是一般情況,也有左聯接驅動表選擇右邊的表)。
用偽代碼表示一下這個過程就是這樣:
1
2
3
4
|
For each row r in R do -- 掃描R表(驅動表)
For each row s in S do -- 掃描S表(被驅動表)
If r and s satisfy the join condition -- 如果r和s滿足join條件
Then output the tuple <r, s> -- 返回結果集
|
下圖能更好地顯示整個SNLJ的過程:
其中R表為驅動表(Outer Table),S表為被驅動表(Inner Table)。這是一個最簡單的算法,這個算法的開銷其實非常大。假設在兩張表R和S上進行聯接的列都不含有索引,驅動表的記錄數為RN,被驅動表的記錄數位SN。根據上一節對於Join算法的評判標准來看,SNLJ的開銷如下表所示:
開銷統計 | SNLJ |
驅動表掃描次數(O) | 1 |
被驅動表掃描次數(I) | RN |
讀取記錄數(R) | RN + SN*RN |
Join比較次數(M) | SN*RN |
回表讀取記錄次數(F) | 0 |
可以看到讀取記錄數的成本和比較次數的成本都是SN*RN,也就是笛卡兒積。假設驅動表和被驅動表都是1萬條記錄,那么其讀取的記錄數量和Join的比較次數都需要上億。實際上數據庫並不會使用到SNLJ算法。
- Index Nested-Loops Join(INLJ,基於索引的嵌套循環聯接)
SNLJ算法雖然簡單明了,但是也是相當的粗暴,需要多次訪問被驅動表(每一次都是全表掃描)。因此,在Join的優化時候,通常都會建議在被驅動表建立索引,以此降低Nested-Loop Join算法的開銷,減少被驅動表掃描次數,MySQL數據庫中使用較多的就是這種算法,以下稱為INLJ。來看這種算法的偽代碼:
1
2
3
4
|
For each row r in R do -- 掃描R表
lookup s in S index -- 查詢S表的索引(固定3~4次IO,B+樹高度)
If find s == r -- 如果r匹配了索引s
Then output the tuple <r, s> -- 返回結果集
|
由於被驅動表上有索引,所以比較的時候不再需要一條條記錄進行比較,而可以通過索引來減少比較,從而加速查詢。整個過程如下圖所示:
可以看到驅動表中的每條記錄通過被驅動表的索引進行訪問,就是讀取驅動表一行數據,然后去被驅動表索引進行二分查找匹配;而一般B+樹的高度為3~4層,也就是說匹配一次的io消耗也就3~4次,因此索引查詢的成本是比較固定的,故優化器都傾向於使用記錄數少的表作為驅動表(這里是否又會存在潛在的問題呢?)。故INLJ的算法成本如下表所示:
開銷統計 | SNLJ | INLJ |
驅動表掃描次數(O) | 1 | 1 |
被驅動表掃描次數(I) | R | 0 |
讀取記錄數(R) | RN + SN*RN | RN + Smatch |
Join比較次數(M) | SN*RN | RN * IndexHeight |
回表讀取記錄次數(F) | 0 | Smatch (if possible) |
上表Smatch表示通過索引找到匹配的記錄數量。同時可以發現,通過索引可以大幅降低被驅動表的Join的比較次數,每次比較1條驅動表的記錄,其實就是一次indexlookup(索引查找),而每次index lookup的成本就是樹的高度,即IndexHeight。
INLJ的算法並不復雜,也算簡單易懂。但是效率是否能達到用戶的預期呢?其實如果是通過表的主鍵索引進行Join,即使是大數據量的情況下,INLJ的效率亦是相當不錯的。因為索引查找的開銷非常小,並且訪問模式也是順序的(假設大多數聚集索引的訪問都是比較順序的)。
大部分人詬病MySQL的INLJ慢,主要是因為在進行Join的時候可能用到的索引並不是主鍵的聚集索引,而是輔助索引,這時INLJ的過程又需要多一步Fetch的過程,而且這個過程開銷會相當的大:
由於訪問的是輔助索引,如果查詢需要訪問聚集索引上的列,那么必要需要進行回表取數據,看似每條記錄只是多了一次回表操作,但這才是INLJ算法最大的弊端。首先,輔助索引的index lookup是比較隨機I/O訪問操作。其次,根據index lookup再進行回表又是一個隨機的I/O操作。所以說,INLJ最大的弊端是其可能需要大量的離散操作,這在SSD出現之前是最大的瓶頸。而即使SSD的出現大幅提升了隨機的訪問性能,但是對比順序I/O,其還是慢了很多,依然不在一個數量級上。
另外,在INNER JOIN中,兩張聯接表的順序是可以變換的,即R INNER JOIN S ON Condition P等效於S INNER JOIN R ON Condition P。根據前面描述的Simple Nested-Loops Join算法,優化器在一般情況下總是選擇將聯接列含有索引的表作為被驅動表。如果兩張表R和S在聯接列上都有索引,並且索引的高度相同,那么優化器會選擇記錄數少的表作為驅動表,這是因為被驅動表的掃描次數總是索引的高度,與記錄的數量無關。所以,聯接列只要有一個字段有索引即可,但最好是數據集多的表有索引;但是,但有WHERE條件的時候又另當別論了。
然后我們給上面的 t1.m1 和 t2.m2 分別添加主鍵,看一下下面這個內聯接的執行計划:
1
2
3
4
5
6
7
8
|
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.m1 = t2.m2;
+----+-------------+-------+------------+--------+---------------+---------+---------+-----------------+------+----------+-------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+--------+---------------+---------+---------+-----------------+------+----------+-------+
| 1 | SIMPLE | t1 | NULL | ALL | PRIMARY | NULL | NULL | NULL | 3 | 100.00 | NULL |
| 1 | SIMPLE | t2 | NULL | eq_ref | PRIMARY | PRIMARY | 4 | employees.t1.m1 | 1 | 100.00 | NULL |
+----+-------------+-------+------------+--------+---------------+---------+---------+-----------------+------+----------+-------+
2 rows in set, 1 warning (0.00 sec)
|
可以看到執行計划是將 t1 表作為驅動表,將 t2 表作為被驅動表,因為對 t2.m2 列的條件是等值查找,比如 t2.m2=2、t2.m2=3 等,所以 MySQL 把在聯接查詢中對被驅動表使用主鍵值或者唯一二級索引列的值進行等值查找的查詢執行方式稱之為 eq_ref。
Tips:如果被驅動表使用了非唯一二級索引列的值進行等值查詢,則查詢方式為 ref。另外,如果被驅動表使用了主鍵或者唯一二級索引列的值進行等值查找,但主鍵或唯一二級索引如果有多個列的話,則查詢類型也會變成 ref。
有時候聯接查詢的查詢列表和過濾條件中可能只涉及被驅動表的部分列,而這些列都是某個索引的一部分,這種情況下即使不能使用eq_ref、ref、ref_or_null或者range這些訪問方法執行對被驅動表的查詢的話,也可以使用索引掃描,也就是index的訪問方法來查詢被驅動表。所以我們建議在真實工作中最好不要使用*作為查詢列表,最好把真實用到的列作為查詢列表。
這里為什么將 t1 作為驅動表?因為表 t1 中的記錄少於表 t2,同時驅動表和被驅動表 Join 字段又都有索引,在滿足 Index Nested-Loops Join 算法的情況,選擇小表做為驅動表,這樣需要匹配的次數就少了,所以 SQL 優化器選擇表 t1 作為驅動表。
這也告訴我們在驅動表和被驅動表 Join 字段都有索引的情況下,總是應該使用小表作為驅動表;如果 Join 的字段都沒有索引,那么也應該使用小表作為驅動表;如果說 Join 的字段有一個有索引,另一個沒有索引,有索引的字段是一張小表,那么優化器就需要進行成本評估了,誰成本低就選誰做驅動表。
那么什么叫做“小表”呢?
我們前面的例子是沒有加條件的,若我們執行的 SQL 帶有 WHERE 條件時呢?看看不一樣的執行計划。如果條件為表 t1 的主鍵,執行計划如下:
1
2
3
4
5
6
7
8
|
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.m1 = t2.m2 WHERE t1.m1 = 2;
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
| 1 | SIMPLE | t1 | NULL | const | PRIMARY | PRIMARY | 4 | const | 1 | 100.00 | NULL |
| 1 | SIMPLE | t2 | NULL | const | PRIMARY | PRIMARY | 4 | const | 1 | 100.00 | NULL |
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
2 rows in set, 1 warning (0.00 sec)
|
可以看到執行計划算是極優,同時 t1 表還是驅動表,因為經過 WHERE 條件過濾后的數據只有一條(我們知道在單表中使用主鍵值或者唯一二級索引列的值進行等值查找的方式稱之為 const,所以我們可以看到 t1 的 type 為 const;如果這里條件為 t1.m1 > 1,那么自然 type 就為 range),同時 t2.m2 也是主鍵,自然只有一條數據,type 也為 const。
如果 WHERE 條件是一個沒有索引的字段呢?執行計划如下:
1
2
3
4
5
6
7
8
|
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.m1 = t2.m2 WHERE t1.n1='a';
+----+-------------+-------+------------+--------+---------------+---------+---------+-----------------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+--------+---------------+---------+---------+-----------------+------+----------+-------------+
| 1 | SIMPLE | t1 | NULL | ALL | PRIMARY | NULL | NULL | NULL | 3 | 33.33 | Using where |
| 1 | SIMPLE | t2 | NULL | eq_ref | PRIMARY | PRIMARY | 4 | employees.t1.m1 | 1 | 100.00 | NULL |
+----+-------------+-------+------------+--------+---------------+---------+---------+-----------------+------+----------+-------------+
2 rows in set, 1 warning (0.00 sec)
|
從執行計划上看跟不加 WHERE 條件幾乎差不多,但是可以看到 filtered 為 33% 了,而不是 100%,說明需要返回的數據量變少了。另外 Extra 字段中標識使用了 WHERE 條件過濾。
如果 WHERE 條件是一個有索引的字段呢(比如給 t2.n2 添加一個非唯一二級索引)?那么此時的執行計划就如下:
1
2
3
4
5
6
7
8
|
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.m1 = t2.m2 WHERE t2.n2='a';
+----+-------------+-------+------------+--------+----------------+---------+---------+-----------------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+--------+----------------+---------+---------+-----------------+------+----------+-------------+
| 1 | SIMPLE | t2 | NULL | ref | PRIMARY,idx_n2 | idx_n2 | 2 | const | 1 | 100.00 | Using index |
| 1 | SIMPLE | t1 | NULL | eq_ref | PRIMARY | PRIMARY | 4 | employees.t2.m2 | 1 | 100.00 | NULL |
+----+-------------+-------+------------+--------+----------------+---------+---------+-----------------+------+----------+-------------+
2 rows in set, 1 warning (0.00 sec)
|
可以看到 t2 表成為了驅動表,經過二級索引過濾后數據只有1條,所以這里使用到 ref 的訪問方法。簡單來說就是 Join 查詢時,被 WHERE 條件過濾掉的數據越多越好,可以大幅度減少搜索或掃描及對比的次數。
如果我們把 t2.n2 換為范圍查詢呢?看執行計划如下:
1
2
3
4
5
6
7
8
|
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.m1 = t2.m2 WHERE t2.n2>'a';
+----+-------------+-------+------------+--------+----------------+---------+---------+-----------------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+--------+----------------+---------+---------+-----------------+------+----------+-------------+
| 1 | SIMPLE | t1 | NULL | ALL | PRIMARY | NULL | NULL | NULL | 3 | 100.00 | NULL |
| 1 | SIMPLE | t2 | NULL | eq_ref | PRIMARY,idx_n2 | PRIMARY | 4 | employees.t1.m1 | 1 | 100.00 | Using where |
+----+-------------+-------+------------+--------+----------------+---------+---------+-----------------+------+----------+-------------+
2 rows in set, 1 warning (0.00 sec)
|
可以看到雖然 WHERE 條件有索引,但由於 t2.n2>’a’ 過濾后的數據還是比 t1 表多,所以優化器就選擇了 t1 表作為驅動表。
而此時 t2 表的查詢條件類似如下:
1
|
SELECT * FROM t2 WHERE t2.m2 = 1 AND t2.n2 > 'a';
|
由於 t2.m2 是主鍵,t2.n2 有二級索引,優化器經過成本比較之后得出 t2.n2 過濾后的數據占全表比例太大,回表的成本比直接訪問主鍵成本要高,所以就直接使用了主鍵。如果說 t2.n2 過濾后的數據占全表數據比例較小,是有可能會選擇 idx_n2 索引。
最后,我們使用 t1.n1 與 t2.n2 作為條件,看一下執行計划如下:
1
2
3
4
5
6
7
8
|
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.n1 = t2.n2;
+----+-------------+-------+------------+------+---------------+--------+---------+-----------------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+--------+---------+-----------------+------+----------+-------------+
| 1 | SIMPLE | t1 | NULL | ALL | NULL | NULL | NULL | NULL | 3 | 100.00 | Using where |
| 1 | SIMPLE | t2 | NULL | ref | idx_n2 | idx_n2 | 1 | employees.t1.n1 | 1 | 100.00 | Using index |
+----+-------------+-------+------------+------+---------------+--------+---------+-----------------+------+----------+-------------+
2 rows in set, 1 warning (0.00 sec)
|
一切按照我們預想的結果在工作,就是由於 t2.n2 不是主鍵或唯一索引,type 類型變成了 ref。
所以,對於“小表”的定義更准確地說,在決定哪個表做驅動表的時候,應該是兩個表按照各自的條件過濾,過濾完成之后,計算參與 Join 的各個字段的總數據量,數據量小的那個表,就是“小表”,應該作為驅動表。但如果在有條件過濾或沒有條件過濾時,在小表有索引,大表沒有索引的情況下,那么優化器就需要進行成本評估了,誰成本低就選誰做驅動表。
外聯接消除
我們前邊說過,內連接的驅動表和被驅動表的位置可以相互轉換,而左(外)連接和右(外)連接的驅動表和被驅動表是固定的,因為有些不滿足聯接條件的記錄會通過驅動表行的方式再次添加到結果中。這就導致內連接可能通過優化表的連接順序來降低整體的查詢成本,而外連接卻無法優化表的連接順序。
外連接和內連接的本質區別就是:對於外連接的驅動表的記錄來說,如果無法在被驅動表中找到匹配 ON 子句中的過濾條件的記錄,那么該記錄仍然會被加入到結果集中,對應的被驅動表記錄的各個字段使用 NULL 值填充;而內連接的驅動表的記錄如果無法在被驅動表中找到匹配 ON 子句中的過濾條件的記錄,那么該記錄會被舍棄。查詢效果就是這樣:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
mysql> SELECT * FROM t1 INNER JOIN t2 ON t1.m1 = t2.m2;
+------+------+------+------+
| m1 | n1 | m2 | n2 |
+------+------+------+------+
| 2 | b | 2 | b |
| 3 | c | 3 | c |
+------+------+------+------+
2 rows in set (0.00 sec)
mysql> SELECT * FROM t1 LEFT JOIN t2 ON t1.m1 = t2.m2;
+------+------+------+------+
| m1 | n1 | m2 | n2 |
+------+------+------+------+
| 2 | b | 2 | b |
| 3 | c | 3 | c |
| 1 | a | NULL | NULL |
+------+------+------+------+
3 rows in set (0.00 sec)
|
對於上邊例子中的(左)外連接來說,由於驅動表 t1 中 m1=1, n1=’a’ 的記錄無法在被驅動表 t2 中找到符合 ON 子句條件 t1.m1 = t2.m2 的記錄,所以就直接把這條記錄加入到結果集,對應的 t2 表的 m2 和 n2 列的值都設置為 NULL。
我們知道凡是不符合 WHERE 子句中條件的記錄都不會參與連接。只要我們在搜索條件中指定關於被驅動表相關列的值不為 NULL,那么外連接中在被驅動表中找不到符合 ON 子句條件的驅動表記錄也就被排除出最后的結果集了,也就是說:在這種情況下:外連接和內連接也就沒有什么區別了!比方說這個查詢:
1
2
3
4
5
6
7
8
|
mysql> SELECT * FROM t1 LEFT JOIN t2 ON t1.m1 = t2.m2 WHERE t2.n2 IS NOT NULL;
+------+------+------+------+
| m1 | n1 | m2 | n2 |
+------+------+------+------+
| 2 | b | 2 | b |
| 3 | c | 3 | c |
+------+------+------+------+
2 rows in set (0.01 sec)
|
由於指定了被驅動表 t2 的 n2 列不允許為 NULL,所以上邊的 t1 和 t2 表的左(外)連接查詢和內連接查詢是一樣一樣的。
我們把這種在外連接查詢中,指定的 WHERE 子句中包含被驅動表中的列不為 NULL 值的條件稱之為空值拒絕(英文名:reject-NULL)。在被驅動表的 WHERE 子句符合空值拒絕的條件后,外連接和內連接可以相互轉換。這種轉換帶來的好處就是查詢優化器可以通過評估表的不同連接順序的成本,選出成本最低的那種連接順序來執行查詢。
- Block Nested-Loops Join(BNL,基於塊的嵌套循環聯接)
掃描一個表的過程其實是先把這個表從磁盤上加載到內存中,然后從內存中比較匹配條件是否滿足。但內存里可能並不能完全存放的下表中所有的記錄,所以在掃描表前邊記錄的時候后邊的記錄可能還在磁盤上,等掃描到后邊記錄的時候可能內存不足,所以需要把前邊的記錄從內存中釋放掉。我們前邊又說過,采用 Simple Nested-Loop Join 算法的兩表聯接過程中,被驅動表可是要被訪問好多次的,如果這個被驅動表中的數據特別多而且不能使用索引進行訪問,那就相當於要從磁盤上讀好幾次這個表,這個 I/O 代價就非常大了,所以我們得想辦法:盡量減少訪問被驅動表的次數。
當被驅動表中的數據非常多時,每次訪問被驅動表,被驅動表的記錄會被加載到內存中,在內存中的每一條記錄只會和驅動表結果集的一條記錄做匹配,之后就會被從內存中清除掉。然后再從驅動表結果集中拿出另一條記錄,再一次把被驅動表的記錄加載到內存中一遍,周而復始,驅動表結果集中有多少條記錄,就得把被驅動表從磁盤上加載到內存中多少次。所以我們可不可以在把被驅動表的記錄加載到內存的時候,一次性和多條驅動表中的記錄做匹配,這樣就可以大大減少重復從磁盤上加載被驅動表的代價了。這也就是 Block Nested-Loop Join 算法的思想。
也就是說在有索引的情況下,MySQL 會嘗試去使用 Index Nested-Loop Join 算法,在有些情況下,可能 Join 的列就是沒有索引,那么這時 MySQL 的選擇絕對不會是最先介紹的 Simple Nested-Loop Join 算法,因為那個算法太粗暴,不忍直視。數據量大些的復雜 SQL 估計幾年都可能跑不出結果。而 Block Nested-Loop Join 算法較 Simple Nested-Loop Join 的改進就在於可以減少被驅動表的掃描次數,甚至可以和 Hash Join 算法一樣,僅需掃描被驅動表一次。其使用 Join Buffer(聯接緩沖) 來減少內部循環讀取表的次數。
1
2
3
4
5
|
For each tuple r in R do -- 掃描驅動表R
store used columns as p from R in Join Buffer -- 將部分或者全部R的記錄保存到Join Buffer中,記為p
For each tuple s in S do -- 掃描被驅動表S
If p and s satisfy the join condition -- p與s滿足join條件
Then output the tuple -- 返回為結果集
|
可以看到相比 Simple Nested-Loop Join 算法,Block Nested-Loop Join 算法僅多了一個所謂的 Join Buffer,為什么這樣就能減少被驅動表的掃描次數呢?下圖相比更好地解釋了 Block Nested-Loop Join 算法的運行過程:
可以看到 Join Buffer 用以緩存聯接需要的列,比如 SELECT * 就需要放置所有的列在 Join Buffer 中(所以再次提醒我們,最好不要把 * 作為查詢列表,只需要把我們需要的列放到查詢列表就好了,這樣還可以在 join buffer 中放置更多的記錄),以無序數組的方式組織。然后掃描被驅動表,每取一行數據就和 Join Buffer 中的記錄進行一一比較,滿足 Join 條件的,就作為結果集的一部分返回。
如果 join buffer 可以緩存驅動表所有列數據,那么聯接僅需掃描驅動表和被驅動表各一次即可,從而大幅提升 Join 的性能。與 Simple Nested-Loop Join 算法一樣的是,比較的次數兩者相同,只是減少了被驅動表掃描的次數;雖然比較次數沒有減少,但 Block Nested-Loop Join 所有的比較操作都是內存操作,速度上會快很多,性能也更好。
如果驅動表很大,Join Buffer 放不下怎么辦呢?Join Buffer 的大小是由參數 Join_buffer_size 設定的,默認值是 256K,會話級別的。如果放不下驅動表所有數據的話,策略也很簡單,就是分段存放,這也就是這個算法名字中 “Block” 的由來,表示分段去 Join。這個時候驅動表被分為多少段就意味着被驅動表需要掃描多少次,所以可以看出驅動表越小越好。
MySQL 數據庫使用 Join Buffer 的原則如下:
* 系統變量 Join_buffer_size 決定了 Join Buffer 的大小。
* Join Buffer 可被用於聯接是 ALL、index 和 range 的類型。
* 每次聯接使用一個 Join Buffer,因此多表的聯接可以使用多個 Join Buffer。
* Join Buffer 在聯接發生之前進行分配,在 SQL 語句執行完后進行釋放。
* Join Buffer 只存儲要進行查詢操作的相關列數據,而不是整行的記錄。
Join_buffer_size 變量
所以,Join Buffer 並不是那么好用的。首先變量 join_buffer_size 用來控制 Join Buffer 的大小,調大后可以避免多次的被驅動表掃描,從而提高性能。也就是說,當 MySQL 的 Join 有使用到 Block Nested-Loop Join,那么調大變量 join_buffer_size 才是有意義的。而前面的 Index Nested-Loop Join 僅使用索引進行 Join,那么調大這個變量則毫無意義。
變量 join_buffer_size 的默認值是 256K,顯然對於稍復雜的 SQL 是不夠用的。好在這個是會話級別的變量,可以在執行前進行擴展。建議在會話級別進行設置,而不是全局設置,因為很難給一個通用值去衡量。另外,這個內存是會話級別分配的,如果設置不好容易導致因無法分配內存而導致的宕機問題。
Join Buffer 緩存對象
另外,Join Buffer 緩存的對象是什么?這個問題相當關鍵和重要。然在 MySQL 的官方手冊中是這樣記錄的:Only columns of interest to the join are stored in the join buffer, not whole rows.
可以發現 Join Buffer 不是緩存驅動表的整行記錄,而是緩存 “columns of interest”,具體指所有參與查詢的列都會保存到 Join Buffer,而不是只有 Join 的列。比如下面的 SQL 語句,假設沒有索引,需要使用到 Join Buffer 進行鏈接:
1
2
3
4
5
6
|
SELECT a.col3
FROM a,
b
WHERE a.col1 = b.col2
AND a.col2 > ….
AND b.col2 = …
|
假設上述 SQL 語句的驅動表是 a,被驅動表是 b,那么存放在 Join Buffer 中的列是所有參與查詢的列,在這里就是(a.col1,a.col2,a.col3)。
通過上面的介紹,我們現在可以得到被驅動表的掃描次數為:
1
|
Scaninner_table = (RN * used_column_size) / join_buffer_size + 1
|
對於有經驗的 DBA 就可以預估需要分配的 Join Buffer 大小,然后盡量使得被驅動表的掃描次數盡可能的少,最優的情況是只掃描被驅動表一次。
Join Buffer 的分配
需要牢記的是,Join Buffer是在Join之前就進行分配,並且每次Join就需要分配一次Join Buffer,所以假設有N張表參與Join,每張表之間通過Block Nested-Loop Join,那么總共需要分配N-1個Join Buffer,這個內存容量是需要DBA進行考量的。
在MySQL 5.6(包括MariaDB 5.3)中,優化了Join Buffer在多張表之間聯接的內存使用效率。MySQL 5.6將Join Buffer分為Regular join buffer和Incremental join buffer。假設B1是表t1和t2聯接使用的Join Buffer,B2是t1和t2聯接產生的結果和表t3進行聯接使用的join buffer,那么:
* 如果B2是Regular join buffer,那么B2就會包含B1的Join Buffer中r1相關的列,以及表t2中相關的列。
* 如果B2是Incremental join buffer,那么B2包含表t2中的數據及一個指針,該指針指向B1中r1相對應的數據。
因此,對於第一次聯接的表,使用的都是Regular join buffer,之后再聯接,則使用Incremental join buffer。又因為Incremental join buffer只包含指向之前Join Buffer中數據的指針,所以Join Buffer的內存使用效率得到了大幅的提高。
此外,對於NULL類型的列,其實不需要存放在Join Buffer中,而對於VARCHAR類型的列,也是僅需最小的內存即可,而不是以CHAR類型在Join Buffer中保存。最后,在MySQL 5.5版本中,Join Buffer只能在INNER JOIN中使用,在OUTER JOIN中則不能使用,即Block Nested Loop算法不支持OUTER JOIN。從MySQL 5.6及MariaDB 5.3開始,Join Buffer的使用得到了進一步擴展,在OUTER JOIN中使join buffer得到支持。
Block Nested-Loop Join 開銷
Block Nested-Loop Join 極大的避免了被驅動表的掃描次數,如果 Join Buffer 可以緩存驅動表的數據,那么被驅動表的掃描僅需一次,這和 Hash Join 非常類似。但驅動表的數據在 Join Buffer 中是以無序數組的方式組織,所以 Block Nested-Loop Join 依然沒有解決的是 Join 比較的次數,其仍然通過 Join 判斷式進行比較。綜上所述,到目前為止各 Join 算法的成本比較如下所示:
開銷統計 | SNLJ | INLJ | BNL |
驅動表掃描次數(O) | 1 | 1 | 1 |
被驅動表掃描次數(I) | R | 0 | RN*used_column_size/join_buffer_size + 1 |
讀取記錄數(R) | RN + SN*RN | RN + Smatch | RN + S*I |
Join比較次數(M) | SN*RN | RN * IndexHeight | SN*RN |
回表讀取記錄次數(F) | 0 | Smatch (if possible) | 0 |
這個算法很好測試,我們可以隨便構建兩張沒有索引的字段進行聯接,然后查看一下執行計划。下面是我在 MySQL 5.7 版本上的執行計划。
1
2
3
4
5
6
7
8
|
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.m1 = t2.m2 WHERE t2.n2>'c';
+----+-------------+-------+------------+-------+----------------+--------+---------+------+------+----------+----------------------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+-------+----------------+--------+---------+------+------+----------+----------------------------------------------------+
| 1 | SIMPLE | t2 | NULL | range | PRIMARY,idx_n2 | idx_n2 | 2 | NULL | 3 | 100.00 | Using where; Using index |
| 1 | SIMPLE | t1 | NULL | ALL | PRIMARY | NULL | NULL | NULL | 3 | 33.33 | Using where; Using join buffer (Block Nested Loop) |
+----+-------------+-------+------------+-------+----------------+--------+---------+------+------+----------+----------------------------------------------------+
2 rows in set, 1 warning (0.00 sec)
|
可以看到,SQL 執行計划的 Extra 列中提示 Using join buffer (Block Nested Loop),很明顯使用了 BNL 算法。
另外,可以看出這條 SQL 先根據索引進行了條件過濾,然后拿過濾后的結果集作為驅動表,也是為了減少被驅動表掃描次數。如果 t2.n2 沒有索引呢?使用 BNL 算法來 join 的話,這個語句的執行流程是這樣的,假設表 t1 是驅動表,表 t2 是被驅動表:
1. 把表 t1 的所有字段取出來,存入 join_buffer 中。
2. 掃描表 t2,取出每一行數據跟 join_buffer 中的數據進行對比;如果不滿足 t1.m1=t2.m2,則跳過; 如果滿足 t1.m1=t2.m2,再判斷其他條件,也就是是否滿足 t2.n2>’c’ 的條件,如果是,就作為結果集的一部分返回,否則跳過。
對於表 t2 的每一行,判斷 join 是否滿足的時候,都需要遍歷 join_buffer 中的所有行。因此判斷等值條件的次數是 t1 表行數乘以 t2 表行數,數據量稍微大點時,這個判斷的次數都是上億次。如果不想在表 t2 的字段 n2 上創建索引,又想減少比較次數。那么,有沒有兩全其美的辦法呢?這時候,我們可以考慮使用臨時表。使用臨時表的大致思路是:
1. 把表 t2 中滿足條件的數據放在臨時表 tmp_t 中;
2. 為了讓 join 使用 BKA 算法,給臨時表 tmp_t 的字段 n2 加上索引;
3. 讓表 t1 和 tmp_t 做 join 操作。
Block Nested-Loop Join 影響
在使用 Block Nested-Loop Join 算法時,可能會對被驅動表做多次掃描。如果這個被驅動表是一個大的冷數據表,除了會導致 IO 壓力大以外,還會對 buffer poll 產生嚴重的影響。
如果了解 InnoDB 的 LRU 算法就會知道,由於 InnoDB 對 Bufffer Pool 的 LRU 算法做了優化,即:第一次從磁盤讀入內存的數據頁,會先放在 old 區域。如果 1 秒之后這個數據頁不再被訪問了,就不會被移動到 LRU 鏈表頭部,這樣對 Buffer Pool 的命中率影響就不大。
但是,如果一個使用 BNL 算法的 join 語句,多次掃描一個冷表,而且這個語句執行時間超過 1 秒,就會在再次掃描冷表的時候,把冷表的數據頁移到 LRU 鏈表頭部。這種情況對應的,是冷表的數據量小於整個 Buffer Pool 的 3/8,能夠完全放入 old 區域的情況。如果這個冷表很大,就會出現另外一種情況:業務正常訪問的數據頁,沒有機會進入 young 區域。
由於優化機制的存在,一個正常訪問的數據頁,要進入 young 區域,需要隔 1 秒后再次被訪問到。但是,由於我們的 join 語句在循環讀磁盤和淘汰內存頁,進入 old 區域的數據頁,很可能在 1 秒之內就被淘汰了。這樣,就會導致這個 MySQL 實例的 Buffer Pool 在這段時間內,young 區域的數據頁沒有被合理地淘汰。
也就是說,這兩種情況都會影響 Buffer Pool 的正常運作。 大表 join 操作雖然對 IO 有影響,但是在語句執行結束后,對 IO 的影響也就結束了。但是,對 Buffer Pool 的影響就是持續性的,需要依靠后續的查詢請求慢慢恢復內存命中率。
為了減少這種影響,你可以考慮增大 join_buffer_size 的值,減少對被驅動表的掃描次數。
也就是說,BNL 算法對系統的影響主要包括三個方面: 可能會多次掃描被驅動表,占用磁盤 IO 資源; 判斷 join 條件需要執行 M*N 次對比(M、N 分別是兩張表的行數),如果是大表就會占用非常多的 CPU 資源; 可能會導致 Buffer Pool 的熱數據被淘汰,影響內存命中率。
Tips:思考這么一個問題,假設被驅動表全在內存中,這個時候 SNLJ 和 BNL 算法還有性能差別嗎?當然是有的,由於 SNLJ 這個算法天然會對被驅動表的數據做多次訪問,所以更容易將這些數據頁放到 Buffer Pool 的頭部,從而污染 Buffer Pool。另外,即使被驅動表數據都在內存中,但每次查找“下一個記錄的操作”,都是類似指針操作。而 BNL 算法中的 join_buffer 是數組,遍歷的成本更低,從被驅動表讀取一條數據去 join_buffer 中遍歷。
- Batched Key Access Join(BKA,批量鍵訪問聯接)
Index Nested-Loop Join 雖好,但是通過輔助索引進行聯接后需要回表,這里需要大量的隨機 I/O 操作。若能優化隨機 I/O,那么就能極大的提升 Join 的性能。為此,MySQL 5.6(MariaDB 5.3)開始支持 Batched Key Access Join 算法(簡稱 BKA),該算法通過常見的空間換時間,隨機 I/O 轉順序 I/O,以此來極大的提升 Join 的性能。
在說明 Batched Key Access Join 前,首先介紹下 MySQL 5.6 的新特性 mrr——multi range read。因為這個特性也是 BKA 的重要支柱。MRR 優化的目的就是為了減少磁盤的隨機訪問,InnoDB 由於索引組織表的特性,如果你的查詢是使用輔助索引,並且有用到表中非索引列(投影非索引字段,及條件有非索引字段),因此需要回表讀取數據做后續處理,過於隨機的回表會伴隨着大量的隨機 I/O。這個過程如下圖所示:
而 MRR 的優化在於,並不是每次通過輔助索引讀取到數據就回表去取記錄,范圍掃描(range access)中 MySQL 將掃描到的數據存入由 read_rnd_buffer_size 變量定義的內存大小中,默認 256K。然后對其按照 Primary Key(RowID)排序,然后使用排序好的數據進行順序回表,我們知道 InnoDB 中葉子節點數據是按照 PRIMARY KEY(ROWID) 進行順序排列的,所以我們可以認為,如果按照主鍵的遞增順序查詢的話,對磁盤的讀比較接近順序讀,緩存命中率高,同時數據庫還有預讀特性,都能夠極大提升讀性能。這對於 IO-bound 類型的 SQL 查詢語句帶來性能極大的提升。
MRR 能夠提升性能的核心在於,這條查詢語句在索引上做的是一個范圍查詢(也就是說,這是一個多值查詢),可以得到足夠多的主鍵id。這樣通過排序以后,再去主鍵索引查數據,才能體現出“順序性”的優勢。所以 MRR 優化可用於 range,ref,eq_ref 類型的查詢,工作方式如下圖:
要開啟 MRR 還有一個比較重的參數是在變量 optimizer_switch 中的 MRR 和 mrr_cost_based 選項。MRR 選項默認為 on,mrr_cost_based 選項默認為 off。mrr_cost_based 選項表示通過基於成本的算法來確定是否需要開啟 MRR 特性。然而,在 MySQL 當前版本中,基於成本的算法過於保守,導致大部分情況下優化器都不會選擇 MRR 特性。為了確保優化器使用 MRR 特性,請執行下面的 SQL 語句:
1
|
set optimizer_switch='mrr=on,mrr_cost_based=off';
|
但如果強制開啟 MRR,那在某些 SQL 語句下,性能可能會變差;因為 MRR 需要排序,假如排序的時間超過直接掃描的時間,那性能就會降低。optimizer_switch 可以是全局的,也可以是會話級的。
當然,除了調整參數外,數據庫也提供了語句級別的開啟或關閉 MRR,使用方法如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
mysql> explain select /*+ MRR(employees)*/ * from employees where birth_date >= '1996-01-01'\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: employees
partitions: NULL
type: range
possible_keys: idx_birth_date
key: idx_birth_date
key_len: 3
ref: NULL
rows: 1
filtered: 100.00
Extra: Using index condition; Using MRR
1 row in set, 1 warning (0.00 sec)
|
理解了 MRR 性能提升的原理,我們就能理解 MySQL 在 5.6 版本后開始引入的 Batched Key Acess 算法了。這個 BKA 算法,其實就是對 INLJ 算法的優化。
我們知道 INLJ 算法執行的邏輯是:從驅動表一行行地取出 join 條件值,再到被驅動表去做 join。也就是說,對於被驅動表來說,每次都是匹配一個值。這時,MRR 的優勢就用不上了。那怎么才能一次性地多傳些值給被驅動表呢?方法就是,從驅動表里一次性地多拿些行出來,一起傳給被驅動表。既然如此,我們就把驅動表的數據取出來一部分,先放到一個臨時內存。這個臨時內存不是別人,就是 join_buffer。
我們知道 join_buffer 在 BNL 算法里的作用,是暫存驅動表的數據。但是在 NLJ 算法里並沒有用。那么,我們剛好就可以復用 join_buffer 到 BKA 算法中。NLJ 算法優化后的 BKA 算法的流程,整個過程如下所示:
對於多表 join 語句,當 MySQL 使用索引訪問被驅動表的時候,使用一個 join buffer 來存儲驅動表需要的相關列。BKA 構建好 key 后,批量傳給引擎層做索引查找。key 是通過 MRR 接口提交給引擎的,這樣 MRR 使得查詢更有效率。
如果驅動表掃描的是主鍵,那么表中的記錄訪問都是比較有序的,但是如果聯接的列是非主鍵索引,那么對於表中記錄的訪問可能就是非常離散的。因此對於非主鍵索引的聯接,Batched Key Access Join 算法將能極大提高 SQL 的執行效率。BKA 算法支持內聯接,外聯接和半聯接操作,包括嵌套外聯接。
Batched Key Access Join 算法的工作步驟如下:
1. 將驅動表中相關的列放入 Join Buffer 中。
2. 批量的將 Key 發送到 MRR 接口。
3. MRR 通過收到的 Key,根據其對應的 ROWID 進行排序,然后再進行數據的讀取操作。
4. 返回結果集給客戶端。
BKA 算法的本質上來說還是 Simple Nested-Loops Join 算法,其發生的條件為被驅動表上有索引,並且該索引為非主鍵,並且聯接需要訪問被驅動表主鍵上的索引。這時 BKA 算法會調用 MRR 接口,批量的進行索引鍵的匹配和主鍵索引上獲取數據的操作,以此來提高聯接的執行效率,因為讀取數據是以順序磁盤 IO 而不是隨機磁盤 IO 進行的。
在 MySQL 5.6 中默認關閉 BKA(MySQL 5.7 默認打開),必須將 optimizer_switch 系統變量的 batched_key_access 標志設置為 on。BKA 使用 MRR,因此 MRR 標志也必須打開。目前,MRR 的成本估算過於悲觀。因此,mrr_cost_based 也必須關閉才能使用 BKA。以下設置啟用 BKA:
1
|
SET optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on';
|
因為 BKA 算法的本質是通過 MRR 接口將非主鍵索引對於記錄的訪問,轉化為根據 ROWID 排序的較為有序的記錄獲取,所以要想通過 BKA 算法來提高性能,不但需要確保聯接的列參與 match 的操作(聯接的列可以是唯一索引或者普通索引,但不能是主鍵),還要有對非主鍵列的 search 操作。例如下列 SQL 語句:
1
2
3
4
5
6
7
8
|
mysql> explain select a.gender, b.dept_no from employees a, dept_emp b where a.birth_date=b.from_date;
+----+-------------+-------+------------+------+----------------+----------------+---------+-----------------------+--------+----------+----------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+----------------+----------------+---------+-----------------------+--------+----------+----------------------------------------+
| 1 | SIMPLE | b | NULL | ALL | NULL | NULL | NULL | NULL | 331570 | 100.00 | NULL |
| 1 | SIMPLE | a | NULL | ref | idx_birth_date | idx_birth_date | 3 | employees.b.from_date | 62 | 100.00 | Using join buffer (Batched Key Access) |
+----+-------------+-------+------------+------+----------------+----------------+---------+-----------------------+--------+----------+----------------------------------------+
2 rows in set, 1 warning (0.00 sec)
|
列 a.gender 是表 employees 的數據,但不是通過搜索 idx_birth_date 索引就能得到數據,還需要回表訪問主鍵來獲取數據。因此這時可以使用 BKA 算法。但是如果聯接不涉及針對主鍵進一步獲取數據,被驅動表只參與聯接判斷,那么就不會啟用 BKA 算法,因為沒有必要去調用 MRR 接口。比如 search 的主鍵(a.emp_no),那么肯定就不需要 BKA 算法了,直接覆蓋索引就可以返回數據了(二級索引有主鍵值)。
1
2
3
4
5
6
7
8
|
mysql> explain select a.emp_no, b.dept_no from employees a, dept_emp b where a.birth_date=b.from_date;
+----+-------------+-------+------------+------+----------------+----------------+---------+-----------------------+--------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+----------------+----------------+---------+-----------------------+--------+----------+-------------+
| 1 | SIMPLE | b | NULL | ALL | NULL | NULL | NULL | NULL | 331570 | 100.00 | NULL |
| 1 | SIMPLE | a | NULL | ref | idx_birth_date | idx_birth_date | 3 | employees.b.from_date | 62 | 100.00 | Using index |
+----+-------------+-------+------------+------+----------------+----------------+---------+-----------------------+--------+----------+-------------+
2 rows in set, 1 warning (0.00 sec)
|
在 EXPLAIN 輸出中,當 Extra 值包含 Using join buffer(Batched Key Access) 且類型值為 ref 或 eq_ref 時,表示使用 BKA。
- Classic Hash Join(CHJ)
MySQL 數據庫雖然提供了 BKA 來優化傳統的 JOIN 算法,的確在一定程度上可以提升 JOIN 的速度。但不可否認的是,仍然有許多用戶對於 Hash Join 算法有着強烈的需求。Hash Join 不需要任何的索引,通過掃描表就能快速地進行 JOIN 查詢,通過利用磁盤的帶寬帶最大程度的解決大數據量下的 JOIN 問題。
MariaDB 支持 Classic Hash Join 算法,該算法不同於 Oracle 的 Grace Hash Join,但是也是通過 Hash 來進行聯接,不需要索引,可充分利用磁盤的帶寬。
其實 MariaDB 的 Classic Hash Join 和 Block Nested Loop Join 算法非常類似(Classic Hash Join也稱為Block Nested Loop Hash Join),但並不是直接通過進行 JOIN 的鍵值進行比較,而是根據 Join Buffer 中的對象創建哈希表,被驅動表通過哈希算法進行查找,從而在 Block Nested Loop Join 算法的基礎上,又進一步減少了被驅動表的比較次數,從而提升 JOIN 的查詢性能。過程如下圖所示:
Classic Hash Join 算法先將驅動表中數據放入 Join Buffer 中,然后根據鍵值產生一張散列表,這是第一個階段,稱為 build 階段。隨后讀取被驅動表中的一條記錄,對其應用散列函數,將其和散列表中的數據進行比較,這是第二個階段,稱為 probe 階段。
如果將 Hash 查找應用於 Simple Nested-Loops Join 中,則執行計划的 Extra 列會顯示 BNLH。如果將 Hash 查找應用於 Batched Key Access Join 中,則執行計划的 Extra 列會顯示 BKAH。
同樣地,如果 Join Buffer 能夠緩存所有驅動表的查詢列,那么驅動表和被驅動表的掃描次數都將只有 1 次,並且比較的次數也只是被驅動表記錄數(假設哈希算法沖突為 0)。反之,需要掃描多次被驅動表。為了使 Classic Hash Join 更有效果,應該更好地規划 Join Buffer 的大小。
要使用 Classic Hash Join 算法,需要將 join_cache_level 設置為大於等於 4 的值,並顯示地打開優化器的選項,設置過程如下:
1
2
|
set join_cache_join=4;
set optimizer_switch='join_cache_hashed=on';
|
最后,各 JOIN 算法成本之間的比較如下表所示:
開銷統計 | SNLJ | INLJ | BNL | CHJ |
驅動表掃描次數(O) | 1 | 1 | 1 | 1 |
被驅動表掃描次數(I) | R | 0 | RN*used_column_size/join_buffer_size + 1 | RN*used_column_size/join_buffer_size + 1 |
讀取記錄數(R) | RN + SN*RN | RN + Smatch | RN + SN*I | RN + SN*I |
Join比較次數(M) | SN*RN | RN * IndexHeight | SN*RN | SN/I |
回表讀取記錄次數(F) | 0 | Smatch (if possible) | 0 | 0 |
Hash Join 算法雖好,但是僅能用於等值聯接,非等值聯接的 JOIN 查詢,其就顯得無能為力了。另外,創建哈希表也是費時的工作,但是一旦建立完成后,其就能大幅提升 JOIN 的速度。所以通常情況下,大表之間的 JOIN,Hash Join 算法會比較有優勢。小表通過索引查詢,利用 BKA Join 就已經能很好的完成查詢。目前 MySQL 8.0 已經出了,但目前還沒有看到 Hash Join 的身影,不知未來會不會加入。
三、總結
經過上面的學習,我們能發現聯接查詢成本占大頭的就是“驅動表記錄數 乘以 單次訪問被驅動表的成本”,所以我們的優化重點其實就是下面這兩個部分:
- 盡量減少驅動表的記錄數
- 對被驅動表的訪問成本盡可能降低
這兩點對於我們實際書寫聯接查詢語句時十分有用,我們需要盡量在被驅動表的聯接列上建立索引(主鍵或唯一索引最優,其次是非唯一二級索引),這樣就可以使用 eq_ref 或 ref 訪問方法來降低訪問被驅動表的成本了。
<摘錄>
InnoDB存儲引擎 – 姜
MySQL Join算法與調優白皮書 – 姜
https://dev.mysql.com/doc/refman/5.7/en/bnl-bka-optimization.html