書接上文《 SQL夯實基礎(八):聯接運算符算法歸類》。html
這裏先解釋下EXPLAIN 結果中,第一行出現的表就是驅動表(Important!)。mysql
對驅動表能夠直接排序,對非驅動表(的字段排序)須要對循環查詢的合併結果(臨時表)進行排序(Important!)算法
爲了後面一些測試案例,咱們事先建立了兩張表,表數據以下:sql
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查詢爲例:數據庫
SELECT * FROM t1, t2 WHERE t1.m1 > 1 AND t1.m1 = t2.m2 AND t2.n2 < 'd';
1 涉及單表的條件數組
WHERE條件也能夠稱爲搜索條件,好比t1.m1 > 1是隻針對t1表的過濾條件,t2.n2 < ‘d’是隻針對t2表的過濾條件。緩存
2 涉及兩表的條件mysql優化
好比t1.m1 = t2.m二、t1.n1 > t2.n2等,這些條件中涉及到了兩個表,咱們稍後會仔細分析這種過濾條件是如何使用的。函數
在這個查詢中咱們指明瞭這三個過濾條件:oop
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 = 二、t2.n2 < ‘d’這兩個過濾條件,而後到t2表中執行單表查詢。
當t1.m1 = 3時,過濾條件t1.m1 = t2.m2就至關於t2.m2 = 3,因此此時t2表至關於有了t1.m1 = 三、t2.n2 < ‘d’這兩個過濾條件,而後到t2表中執行單表查詢。
因此整個聯接查詢的執行過程就以下圖所示:
也就是說整個聯接查詢最後的結果只有兩條符合過濾條件的記錄:
+------+------+------+------+ | m1 | n1 | m2 | n2 | +------+------+------+------+ | 2 | b | 2 | b | | 3 | c | 3 | c | +------+------+------+------+
從上邊兩個步驟能夠看出來,咱們上邊說的這個兩表聯接查詢共須要查詢1次t1表,2次t2表。固然這是在特定的過濾條件下的結果,若是咱們把t1.m1 > 1這個條件去掉,那麼從t1表中查出的記錄就有3條,就須要查詢3次t2表了。也就是說在兩表聯接查詢中,驅動表只須要訪問一次,被驅動表可能被訪問屢次,這種方式在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算法至關簡單、直接。即外表(驅動表)中的每一條記錄與內表(被驅動表)中的記錄進行比較判斷。對於兩表聯接來講,驅動表只會被訪問一遍,但被驅動表卻要被訪問到好多遍,具體訪問幾遍取決於對驅動表執行單表查詢後的結果集中的記錄條數。對於內聯接來講,選取哪一個表爲驅動表都不要緊,而外聯接的驅動表是固定的,也就是說左(外)聯接的驅動表就是左邊的那個表,右(外)聯接的驅動表就是右邊的那個表。(這個只是通常狀況,也有左聯接驅動表選擇右邊的表,mysql優化器有時候會進行優化)
用僞代碼表示一下這個過程就是這樣:
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的開銷以下表所示:
能夠看到讀取記錄數的成本和比較次數的成本都是SN*RN,也就是笛卡兒積。假設外表內表都是1萬條記錄,那麼其讀取的記錄數量和Join的比較次數都須要上億。實際上數據庫並不會使用到SNLJ算法。
SNLJ算法雖然簡單明瞭,可是也是至關的粗暴,須要屢次訪問內表(每一次都是全表掃描)。所以,在Join的優化時候,一般都會建議在內表創建索引,以此下降Nested-Loop Join算法的開銷,減小內表掃描次數,MySQL數據庫中使用較多的就是這種算法,如下稱爲INLJ。來看這種算法的僞代碼:
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的算法成本以下表所示:
上表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 分別添加主鍵,看一下下面這個內聯接的執行計劃:
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.m1 = t2.m2;
能夠看到執行計劃是將 t1 表做爲驅動表,將 t2 表做爲被驅動表,由於對 t2.m2 列的條件是等值查找,好比 t2.m2=二、t2.m2=3 等,因此MySQL把在聯接查詢中對被驅動表使用主鍵值或者惟一二級索引列(二級索引也叫非聚簇索引,惟一就是unique屬性)的值進行等值查找的查詢執行方式稱之爲eq_ref。
Tips:若是被驅動表使用了非惟一二級索引列的值進行等值查詢,則查詢方式爲 ref。另外,若是被驅動表使用了主鍵或者惟一二級索引列的值進行等值查找,但主鍵或惟一二級索引若是有多個列的話,則查詢類型也會變成 ref。
有時候聯接查詢的查詢列表和過濾條件中可能只涉及被驅動表的部分列,而這些列都是某個索引的一部分,這種狀況下即便不能使用eq_ref、ref、ref_or_null或者range這些訪問方法執行對被驅動表的查詢的話,也可使用索引掃描,也就是index的訪問方法來查詢被驅動表。因此咱們建議在真實工做中最好不要使用*做爲查詢列表,最好把真實用到的列做爲查詢列表。
這裏爲何將 t1 做爲驅動表?由於表 t1 中的記錄少於表 t2,這樣聯接須要匹配的次數就少了,因此SQL優化器選擇表 t1 做爲驅動表。
若咱們執行的SQL帶有WHERE條件時呢?看看不同的執行計劃。若是條件爲表 t1 的主鍵,執行計劃以下:
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.m1 = t2.m2 WHERE t1.m1 = 2;
能夠看到執行計劃算是極優,同時 t1 表仍是驅動表,由於通過WHERE條件過濾後的數據只有一條(咱們知道在單表中使用主鍵值或者惟一二級索引列的值進行等值查找的方式稱之爲const,因此咱們能夠看到 t1 的type爲const;若是這裏條件爲 t1.m1 > 1,那麼天然 type 就爲 range),同時 t2.m2 也是主鍵,天然只有一條數據,type也爲const。
若是WHERE條件是一個沒有索引的字段呢?執行計劃以下:
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.m1 = t2.m2 WHERE t1.n1='a';
從執行計劃上看跟不加WHERE條件幾乎差很少,可是能夠看到filtered爲33%了,而不是100%,說明須要返回的數據量變少了。另外Extra字段中標識使用了WHERE條件過濾。
若是WHERE條件是一個有索引的字段呢(好比給 t2.n2 添加一個非惟一二級索引)?這裏就不得不提MySQL一個很是重要的特性了,pushed-down conditions(條件下推)優化。就是把索引條件下推到存儲引擎層進行數據的過濾並返回過濾後的數據。那麼此時的執行計劃就以下:
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.m1 = t2.m2 WHERE t2.n2='a';
能夠看到 t2 表成爲了驅動表(通過二級索引過濾後數據只有1條,因此這裏使用到ref的訪問方法)。
若是咱們把 t2.n2 換爲範圍查詢呢?看執行計劃以下:
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.m1 = t2.m2 WHERE t2.n2>'a';
能夠看到雖然WHERE條件有索引,但因爲t2.n2>’a’ 過濾後的數據仍是比 t1 表多,因此優化器就選擇了 t1 表做爲驅動表。而此時 t2 表的查詢條件相似以下:
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 做爲條件,看一下執行計劃以下:
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.n1 = t2.n2;
一切按照咱們預想的結果在工做,就是因爲 t2.n2 不是主鍵或惟一索引,type類型變成了 ref。
Tips:雖然在INNER JOIN中可使用pushed-down conditions的優化方式,可是不能直接在OUTER JOIN中使用該方式,由於有些不知足聯接條件的記錄會經過外部錶行的方式再次添加到結果中,所以須要有條件地使用pushed-down conditions的優化。在優化器內部對於聯接查詢會設置一個標誌來表示是否啓用pushed-down conditions的過濾。
掃描一個表的過程實際上是先把這個表從磁盤上加載到內存中,而後從內存中比較匹配條件是否知足。但內存裏可能並不能徹底存放的下表中全部的記錄,因此在掃描表前邊記錄的時候後邊的記錄可能還在磁盤上,等掃描到後邊記錄的時候可能內存不足,因此須要把前邊的記錄從內存中釋放掉。咱們前邊又說過,採用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(聯接緩衝)來減小內部循環讀取表的次數。
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-LoopJoin算法僅多了一個所謂的Join Buffer,爲何這樣就能減小內表的掃描次數呢?下圖相比更好地解釋了Block Nested-Loop Join算法的運行過程:
能夠看到Join Buffer用以緩存聯接須要的列(因此再次提醒咱們,最好不要把*做爲查詢列表,只須要把咱們關心的列放到查詢列表就行了,這樣還能夠在join buffer中放置更多的記錄呢),而後以Join Buffer批量的形式和內表中的數據進行聯接比較。就上圖來看,記錄r1,r2 … rT的聯接僅需掃內表一次,若是join buffer能夠緩存全部的外表列,那麼聯接僅需掃描內外表各一次,從而大幅提高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並非那麼好用的。首先變量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緩存的對象是什麼,這個問題至關關鍵和重要。然在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進行連接:
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)。
經過上面的介紹,咱們如今能夠獲得內表的掃描次數爲:
Scaninner_table = (RN * used_column_size) / join_buffer_size + 1
對於有經驗的DBA就能夠預估須要分配的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極大的避免了內表的掃描次數,若是Join Buffer能夠緩存外表的數據,那麼內表的掃描僅需一次,這和Hash Join很是相似。可是Block Nested-Loop Join依然沒有解決的是Join比較的次數,其仍然經過Join判斷式進行比較。綜上所述,到目前爲止各Join算法的成本比較以下所示:
這個算法很好測試,咱們能夠隨便構建兩張沒有索引的字段進行聯接,而後查看一下執行計劃。下面是我在`MySQL 5.7版本上的執行計劃。(這裏把表的主鍵刪除掉)
EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.m1 = t2.m2 WHERE t2.n2>'c';
能夠看到,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(BNL) 算法時,可能會對被驅動表作屢次掃描。若是這個被驅動表是一個大的冷數據表,除了會致使 IO 壓力大之外,還會對 buffer poll 產生嚴重的影響。
若是瞭解 InnoDB 的 LRU 算法就會知道,因爲 InnoDB 對 Bufffer Pool 的 LRU 算法作了優化,即:第一次從磁盤讀入內存的數據頁,會先放在 old 區域。若是 1 秒以後這個數據頁再也不被訪問了,就不會被移動到 LRU 鏈表頭部,這樣對 Buffer Pool 的命中率影響就不大。
可是,若是一個使用 BNLe 算法的 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 中遍歷。
這部分我測試的效果有問題,雖然我也安裝了mysql employee數據庫
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語句:
set optimizer_switch='mrr=on,mrr_cost_based=off';
但若是強制開啓MRR,那在某些SQL語句下,性能可能會變差;由於MRR須要排序,假如排序的時間超過直接掃描的時間,那性能就會下降。optimizer_switch能夠是全局的,也能夠是會話級的。
固然,除了調整參數外,數據庫也提供了語句級別的開啓或關閉MRR,使用方法以下:
mysql> explain select /*+ MRR(employees)*/ * from employees where birth_date >= '1996-01-01'\G
理解了 MRR 性能提高的原理,咱們就能理解 MySQL 在 5.6 版本後開始引入的 Batched Key Acess(BKA) 算法了。這個 BKA 算法,其實就是對 INLJ 算法的優化。
咱們知道 INLJ 算法執行的邏輯是:從驅動表一行行地取出 join 條件值,再到被驅動表去作 join。也就是說,對於被驅動表來講,每次都是匹配一個值。這時,MRR 的優點就用不上了。那怎麼才能一次性地多傳些值給被驅動表呢?方法就是,從驅動表裏一次性地多拿些行出來,一塊兒傳給被驅動表。既然如此,咱們就把驅動表的數據取出來一部分,先放到一個臨時內存。這個臨時內存不是別人,就是 join_buffer。
咱們知道 join_buffer 在 BNL 算法裏的做用,是暫存驅動表的數據。可是在 NLJ 算法裏並無用。那麼,咱們恰好就能夠複用 join_buffer 到 BKA 算法中。NLJ 算法優化後的 BKA 算法的流程,整個過程以下所示:
對於多表join語句,當MySQL使用索引訪問第二個join表的時候,使用一個join buffer來收集第一個操做對象生成的相關列值。BKA構建好key後,批量傳給引擎層作索引查找。key是經過MRR接口提交給引擎的,這樣,MRR使得查詢更有效率。
若是外部表掃描的是主鍵,那麼表中的記錄訪問都是比較有序的,可是若是聯接的列是非主鍵索引,那麼對於表中記錄的訪問可能就是很是離散的。所以對於非主鍵索引的聯接,Batched Key Access Join算法將能極大提升SQL的執行效率。BKA算法支持內聯接,外聯接和半聯接操做,包括嵌套外聯接。
Batched Key Access Join算法的工做步驟以下:
1. 將外部表中相關的列放入Join Buffer中。
2. 批量的將Key(索引鍵值)發送到Multi-Range Read(MRR)接口。
3. Multi-Range Read(MRR)經過收到的Key,根據其對應的ROWID進行排序,而後再進行數據的讀取操做。
4. 返回結果集給客戶端。
Batched Key Access Join算法的本質上來講仍是Simple Nested-Loops Join算法,其發生的條件爲內部表上有索引,而且該索引爲非主鍵,而且聯接須要訪問內部表主鍵上的索引。這時Batched Key Access Join算法會調用Multi-Range Read(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:
SET optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on';
由於BKA算法的本質是經過MRR接口將非主鍵索引對於記錄的訪問,轉化爲根據ROWID排序的較爲有序的記錄獲取,因此要想經過BKA算法來提升性能,不但須要確保聯接的列參與match的操做(聯接的列能夠是惟一索引或者普通索引,但不能是主鍵),還要有對非主鍵列的search操做。例以下列SQL語句:
mysql> explain select a.gender, b.dept_no from employees a, dept_emp b where a.birth_date=b.from_date
列a.gender是表employees的數據,但不是經過搜索idx_birth_date索引就能獲得數據,還須要回表訪問主鍵來獲取數據。所以這時可使用BKA算法。可是若是聯接不涉及針對主鍵進一步獲取數據,內部表只參與聯接判斷,那麼就不會啓用BKA算法,由於沒有必要去調用MRR接口。好比search的主鍵(a.emp_no),那麼確定就不須要BKA算法了,直接覆蓋索引就能夠返回數據了(二級索引有主鍵值)。
mysql> explain select a.emp_no, b.dept_no from employees a, dept_emp b where a.birth_date=b.from_date;
在EXPLAIN輸出中,當Extra值包含Using join buffer(Batched Key Access)且類型值爲ref或eq_ref時,表示使用BKA。
MySQL數據庫雖然提供了BKA Join來優化傳統的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的值,並顯示地打開優化器的選項,設置過程以下:
set join_cache_join=4;
set optimizer_switch='join_cache_hashed=on';
最後,各JOIN算法成本之間的比較以下表所示:
Hash Join算法雖好,可是僅能用於等值聯接,非等值聯接的JOIN查詢,其就顯得無能爲力了。另外,建立哈希表也是費時的工做,可是一旦創建完成後,其就能大幅提高JOIN的速度。因此一般狀況下,大表之間的JOIN,Hash Join算法會比較有優點。小表經過索引查詢,利用BKA Join就已經能很好的完成查詢。目前MySQL 8.0已經出了,但目前尚未看到Hash Join的身影,不知將來會不會加入。
通過上面的學習,咱們能發現聯接查詢成本佔大頭的就是「驅動表記錄數乘以單次訪問被驅動表的成本」,因此咱們的優化重點其實就是下面這兩個部分:
1 儘可能減小驅動表的記錄數
2 對被驅動表的訪問成本儘量下降
這兩點對於咱們實際書寫聯接查詢語句時十分有用,咱們須要儘可能在被驅動表的聯接列上創建索引(主鍵或惟一索引最優,其次是非惟一二級索引),這樣就可使用 eq_ref 或 ref 訪問方法來下降訪問被驅動表的成本了。
<摘錄>
InnoDB存儲引擎 – 姜
MySQL Join算法與調優白皮書 – 姜
https://dev.mysql.com/doc/refman/5.7/en/bnl-bka-optimization.html