1. 程式人生 > >MySQL 的 join 功能弱爆了?

MySQL 的 join 功能弱爆了?

大家好,我是歷小冰,今天我們來學習和吐槽一下 MySQL 的 Join 功能。 關於MySQL 的 join,大家一定了解過很多它的“軼事趣聞”,比如兩表 join 要小表驅動大表,阿里開發者規範禁止三張表以上的 join 操作,MySQL 的 join 功能弱爆了等等。這些規範或者言論亦真亦假,時對時錯,需要大家自己對 join 有深入的瞭解後才能清楚地理解。 下面,我們就來全面的瞭解一下 MySQL 的 join 操作。 ### 正文 在日常資料庫查詢時,我們經常要對多表進行連表操作來一次性獲得多個表合併後的資料,這是就要使用到資料庫的 join 語法。join 是在資料領域中十分常見的將兩個資料集進行合併的操作,如果大家瞭解的多的話,會發現 MySQL,Oracle,PostgreSQL 和 Spark 都支援該操作。本篇文章的主角是 MySQL,下文沒有特別說明的話,就是以 MySQL 的 join 為主語。而 **Oracle ,PostgreSQL 和 Spark 則可以算做將其吊打的大boss,其對 join 的演算法優化和實現方式都要優於 MySQL。** MySQL 的 join 有諸多規則,可能稍有不慎,可能一個不好的 join 語句不僅會導致對某一張表的全表查詢,還有**可能會影響資料庫的快取,導致大部分熱點資料都被替換出去,拖累整個資料庫效能。** 所以,業界針對 MySQL 的 join 總結了很多規範或者原則,比如說小表驅動大表和禁止三張表以上的 join 操作。下面我們會依次介紹 MySQL join 的演算法,和 Oracle 和 Spark 的 join 實現對比,並在其中穿插解答為什麼會形成上述的規範或者原則。 **對於 join 操作的實現,大概有 Nested Loop Join (迴圈巢狀連線),Hash Join(雜湊連線) 和 Sort Merge Join(排序歸併連線) 三種較為常見的演算法**,它們各有優缺點和適用條件,接下來我們會依次來介紹。 ### MySQL 中的 Nested Loop Join 實現 Nested Loop Join 是掃描驅動表,每讀出一條記錄,就根據 join 的關聯欄位上的索引去被驅動表中查詢對應資料。它適用於被連線的資料子集較小的場景,它也是 MySQL join 的唯一演算法實現,關於它的細節我們接下來會詳細講解。 MySQL 中有兩個 Nested Loop Join 演算法的變種,分別是 Index Nested-Loop Join 和 Block Nested-Loop Join。 #### Index Nested-Loop Join 演算法 下面,我們先來初始化一下相關的表結構和資料 ``` CREATE TABLE `t1` ( `id` int(11) NOT NULL, `a` int(11) DEFAULT NULL, `b` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `a` (`a`) ) ENGINE=InnoDB; delimiter ;; # 定義儲存過程來初始化t1 create procedure init_data() begin declare i int; set i=1; while(i<=10000)do insert into t1 values(i, i, i); set i=i+1; end while; end;; delimiter ; # 呼叫儲存過來來初始化t1 call init_data(); # 建立並初始化t2 create table t2 like t1; insert into t2 (select * from t1 where id<=500) ``` 有上述命令可知,這兩個表都有一個主鍵索引 id 和一個索引 a,欄位 b 上無索引。儲存過程 init_data 往表 t1 裡插入了 10000 行資料,在表 t2 裡插入的是 500 行資料。 為了避免 MySQL 優化器會自行選擇表作為驅動表,影響分析 SQL 語句的執行過程,我們直接使用 straight_join 來讓 MySQL 使用固定的連線表順序進行查詢,如下語句中,t1是驅動表,t2是被驅動表。 ``` select * from t2 straight_join t1 on (t2.a=t1.a); ``` 使用我們之前[文章](https://mp.weixin.qq.com/s/88sGSpVYfGBREH-vZkl_jg)介紹的 explain 命令檢視一下該語句的執行計劃。 ![](https://img2020.cnblogs.com/blog/1816118/202011/1816118-20201111222442913-552486112.png) 從上圖可以看到,t1 表上的 a 欄位是由索引的,join 過程中使用了該索引,因此該 SQL 語句的執行流程如下: - 從 t2 表中讀取一行資料 L1; - 使用L1 的 a 欄位,去 t1 表中作為條件進行查詢; - 取出 t1 中滿足條件的行, 跟 L1組成相應的行,成為結果集的一部分; - 重複執行,直到掃描完 t2 表。 這個流程我們就稱之為 Index Nested-Loop Join,簡稱 NLJ,它對應的流程圖如下所示。 ![](https://img2020.cnblogs.com/blog/1816118/202011/1816118-20201111222456933-1954918218.png) 需要注意的是,在第二步中,根據 a 欄位去表t1中查詢時,使用了索引,所以每次掃描只會掃描一行(從explain結果得出,根據不同的案例場景而變化)。 假設驅動表的行數是N,被驅動表的行數是 M。因為在這個 join 語句執行過程中,驅動表是走全表掃描,而被驅動表則使用了索引,並且驅動表中的每一行資料都要去被驅動表中進行索引查詢,所以整個 join 過程的近似複雜度是 N*2*log2M。顯然,N 對掃描行數的影響更大,因此這種情況下應該讓小表來做驅動表。 當然,這一切的前提是 join 的關聯欄位是 a,並且 t1 表的 a 欄位上有索引。 如果沒有索引時,再用上圖的執行流程時,每次到 t1 去匹配的時候,就要做一次全表掃描。這也導致整個過程的時間複雜度程式設計了 N * M,這是不可接受的。所以,當沒有索引時,MySQL 使用 Block Nested-Loop Join 演算法。 #### Block Nested-Loop Join Block Nested-Loop Join的演算法,簡稱 BNL,它是 MySQL 在被驅動表上無可用索引時使用的 join 演算法,其具體流程如下所示: - 把表 t2 的資料讀取當前執行緒的 join_buffer 中,在本篇文章的示例 SQL 沒有在 t2 上做任何條件過濾,所以就是講 t2 整張表 放入記憶體中; - 掃描表 t1,每取出一行資料,就跟 join_buffer 中的資料進行對比,滿足 join 條件的,則放入結果集。 比如下面這條 SQL ``` select * from t2 straight_join t1 on (t2.b=t1.b); ``` 這條語句的 explain 結果如下所示。可以看出 ![](https://img2020.cnblogs.com/blog/1816118/202011/1816118-20201111222510346-816644757.png) 可以看出,這次 join 過程對 t1 和 t2 都做了一次全表掃描,並且將表 t2 中的 500 條資料全部放入記憶體 join_buffer 中,並且對於表 t1 中的每一行資料,都要去 join_buffer 中遍歷一遍,都要做 500 次對比,所以一共要進行 500 * 10000 次記憶體對比操作,具體流程如下圖所示。 ![](https://img2020.cnblogs.com/blog/1816118/202011/1816118-20201111222518846-1241630582.png) 主要注意的是,**第一步中,並不是將表 t2 中的所有資料都放入 join_buffer,而是根據具體的 SQL 語句,而放入不同行的資料和不同的欄位**。比如下面這條 join 語句則只會將表 t2 中符合 b >= 100 的資料的 b 欄位存入 join_buffer。 ``` select t2.b,t1.b from t2 straight_join t1 on (t2.b=t1.b) where t2.b >= 100; ``` join_buffer 並不是無限大的,由 join_buffer_size 控制,預設值為 256K。當要存入的資料過大時,就只有分段儲存了,整個執行過程就變成了: - 掃描表 t2,將符合條件的資料行存入 join_buffer,因為其大小有限,存到100行時滿了,則執行第二步; - 掃描表 t1,每取出一行資料,就跟 join_buffer 中的資料進行對比,滿足 join 條件的,則放入結果集; - 清空 join_buffer; - 再次執行第一步,直到全部資料被掃描完,由於 t2 表中有 500行資料,所以一共重複了 5次 這個流程體現了該演算法名稱中 Block 的由來,分塊去執行 join 操作。因為表 t2 的資料被分成了 5 次存入 join_buffer,導致表 t1 要被全表掃描 5次。 | | 全部存入 | 分5次存入 | | -------- | ----------- | ------------------------------------- | | 記憶體操作 | 10000 * 500 | 10000 * (100 + 100 + 100 + 100 + 100) | | 掃描行數 | 10000 + 500 | 10000 * 5 + 500 | 如上所示,和表資料可以全部存入 join_buffer 相比,記憶體判斷的次數沒有變化,都是兩張錶行數的乘積,也就是 10000 * 500,但是被驅動表會被多次掃描,每多存入一次,被驅動表就要掃描一遍,影響了最終的執行效率。 基於上述兩種演算法,我們可以得出下面的結論,這也是網上大多數對 MySQL join 語句的規範。 - **被驅動表上有索引,也就是可以使用Index Nested-Loop Join 演算法時,可以使用 join 操作。** - **無論是Index Nested-Loop Join 演算法或者 Block Nested-Loop Join 都要使用小表做驅動表。** 因為上述兩個 join 演算法的時間複雜度**至少**也和涉及表的行數成一階關係,並且要花費大量的記憶體空間,所以阿里開發者規範所說的嚴格禁止三張表以上的 join 操作也是可以理解的了。 但是上述這兩個演算法只是 join 的演算法之一,還有**更加高效的 join 演算法,比如 Hash Join 和 Sorted Merged join。可惜這兩個演算法 MySQL 的主流版本中目前都不提供,而 Oracle ,PostgreSQL 和 Spark 則都支援,這也是網上吐槽 MySQL 弱爆了的原因**(MySQL 8.0 版本支援了 Hash join,但是8.0目前還不是主流版本)。 其實阿里開發者規範也是在從 Oracle 遷移到 MySQL 時,因為 MySQL 的 join 操作效能太差而定下的禁止三張表以上的 join 操作規定的 。 ### Hash Join 演算法 Hash Join 是掃描驅動表,利用 join 的關聯欄位在記憶體中建立散列表,然後掃描被驅動表,每讀出一行資料,並從散列表中找到與之對應資料。它是大資料集連線操時的常用方式,適用於驅動表的資料量較小,可以放入記憶體的場景,它對於**沒有索引的大表**和並行查詢的場景下能夠提供最好的效能。可惜它只適用於等值連線的場景,比如 on a.id = where b.a_id。 還是上述兩張表 join 的語句,其執行過程如下 ![](https://img2020.cnblogs.com/blog/1816118/202011/1816118-20201111222532512-1826904314.png) - 將驅動表 t2 中符合條件的資料取出,對其每行的 join 欄位值進行 hash 操作,然後存入記憶體中的散列表中; - 遍歷被驅動表 t1,每取出一行符合條件的資料,也對其 join 欄位值進行 hash 操作,拿結果到記憶體的散列表中查詢匹配,如果找到,則成為結果集的一部分。 可以看出,**該演算法和 Block Nested-Loop Join 有類似之處,只不過是將無序的 Join Buffer 改為了散列表 hash table,從而讓資料匹配不再需要將 join buffer 中的資料全部遍歷一遍,而是直接通過 hash,以接近 O(1) 的時間複雜度獲得匹配的行**,這極大地提高了兩張表的 join 速度。 不過由於 hash 的特性,該演算法只能適用於等值連線的場景,其他的連線場景均無法使用該演算法。 ### Sorted Merge Join 演算法 Sort Merge Join 則是先根據 join 的關聯欄位將兩張表排序(如果已經排序好了,比如欄位上有索引則不需要再排序),然後在對兩張表進行一次歸併操作。如果兩表已經被排過序,在執行排序合併連線時不需要再排序了,這時Merge Join的效能會優於Hash Join。Merge Join可適於於非等值Join(>,<,>=,<=,但是不包含!=,也即<>)。 需要注意的是,如果連線的欄位已經有索引,也就說已經排好序的話,可以直接進行歸併操作,但是如果連線的欄位沒有索引的話,則它的執行過程如下圖所示。 ![](https://img2020.cnblogs.com/blog/1816118/202011/1816118-20201111222543061-1063459291.png) - 遍歷表 t2,將符合條件的資料讀取出來,按照連線欄位 a 的值進行排序; - 遍歷表 t1,將符合條件的資料讀取出來,也按照連線欄位 a 的值進行排序; - 將兩個排序好的資料進行歸併操作,得出結果集。 Sorted Merge Join 演算法的主要時間消耗在於對兩個表的排序操作,所以如果兩個表已經按照連線欄位排序過了,該演算法甚至比 Hash Join 演算法還要快。在一邊情況下,該演算法是比 Nested Loop Join 演算法要快的。 下面,我們來總結一下上述三種演算法的區別和優缺點。 | | Nested Loop Join | Hash Join | Sorted Merge Join | | ------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | | 連線條件 | 適用於任何條件 | 只適用於等值連線(=) | 等值或非等值連線(>,<,=,>=,<=),‘<>’除外 | | 主要消耗資源 | CPU、磁碟I/O | 記憶體、臨時空間 | 記憶體、臨時空間 | | 特點 | 當有高選擇性索引或進行限制性搜尋時效率比較高,能夠快速返回第一次的搜尋結果 | 當缺乏索引或者索引條件模糊時,Hash Join 比 Nested Loop 有效。通常比 Merge Join 快。在資料倉庫環境下,如果表的紀錄數多,效率高 | 當缺乏索引或者索引條件模糊時,Sort Merge Join 比 Nested Loop 有效。當連線欄位有索引或者提前排好序時,比 hash join 快,並且支援更多的連線條件 | | 缺點 | 無索引或者表記錄多時效率低 | 建立雜湊表需要大量記憶體,第一次的結果返回較慢 | 所有的表都需要排序。它為最優化的吞吐量而設計,並且在結果沒有全部找到前不返回資料 | | 需要索引 | 是(沒有索引效率太差) | 否 | 否 | #### 對於 Join 操作的理解 講完了 Join 相關的演算法,我們這裡也聊一聊對於 join 操作的業務理解。 在業務不復雜的情況下,大多數join並不是無可替代。比如訂單記錄裡一般只有訂單使用者的 user_id,返回資訊時需要取得使用者姓名,可能的實現方案有如下幾種: 1. 一次資料庫操作,使用 join 操作,訂單表和使用者表進行 join,連同使用者名稱一起返回; 2. 兩次資料庫操作,分兩次查詢,第一次獲得訂單資訊和 user_id,第二次根據 user_id 取姓名,使用程式碼程式進行資訊合併; 3. 使用冗餘使用者名稱稱或者從 ES 等非關係資料庫中讀取。 上述方案都能解決資料聚合的問題,而且基於程式程式碼來處理,比資料庫 join 更容易除錯和優化,比如取使用者姓名不從資料庫中取,而是先從快取中查詢。 當然, join 操作也不是一無是處,所以技術都有其使用場景,上邊這些方案或者規則都是網際網路開發團隊總結出來的,適用於高併發、輕寫重讀、分散式、業務邏輯簡單的情況,這些場景一般對資料的一致性要求都不高,甚至允許髒讀。 但是,在金融銀行或者財務等企業應用場景,join 操作則是不可或缺的,這些應用一般都是低併發、頻繁複雜資料寫入、CPU密集而非IO密集,主要業務邏輯通過資料庫處理甚至包含大量儲存過程、對一致性與完整性要求很高的系統。 [個人部落格,歡迎來玩](http://remcarpediem.net/) ![](https://img2020.cnblogs.com/blog/1816118/202011/1816118-20201111222554538-14047738