Mysql專題五:關於JOIN的優化和子查詢的優化

JOIN的含義就如英文單詞“join”一樣,連接兩張表,大致分爲內連接,外連接,右連接,左連接,自然連接。這裏描述先甩出一張用爛了的圖,然後插入測試數據。
在這裏插入圖片描述
在這裏插入圖片描述

一、JOIN語法

內連接
一下三種寫法都是內連接:
select * from t1 join t2 on t1.a = t2.a;
select * from t1 inner join t2 on t1.a = t2.a;
select * from t1 cross join t2 on t1.a = t2.a;

左連接:
select * from t1 left join t2 on t1.a = t2.a;

右連接:
select * from t1 right join t2 on t1.a = t2.a;

二、連接的原理

不管是內連接還是左右連接,都需要一個驅動表和一個被驅動表,對於內連接來說,選取哪個表爲驅動表都沒關係,而外連接的驅動表是固定的,也就是說左連接的驅動表就是左邊的那個表,右連接的驅動表就是右邊的那個表。
連接的大致原理是:

1)選取驅動表,使用與驅動表相關的過濾條件,選取代價最低的訪問形式來執行對驅動表的單表查詢。
2)對上一步驟中查詢驅動表得到的結果集中每一條記錄,都分別到被驅動表中查找匹配的記錄。
對應僞代碼就是:

for each row in t1 {   //此處表示遍歷滿足對t1單表查詢結果集中的每一條記錄
    for each row in t2 {   //此處表示對於某條t1表的記錄來說,遍歷滿足對t2單表查詢結果集中的每一條記錄
        // 判斷是否符合join條件
    }
}

(一) 嵌套循環連接(Nested-Loop Join)

上面的過程就像是一個嵌套的循環,所以這種驅動表只訪問一次,但被驅動表卻可能被多次訪問,訪問次數取決於對驅動表執行單表查詢後的結果集中的記錄條數的連接執行方式稱之爲嵌套循環連接(Nested-Loop Join),這是最簡單,也是最笨拙的一種連接查詢算法;

比如對於下面這個sql:
select * from t1 join t2 on t1.a = t2.a where t1.b in (1,2);

先會執行:
在這裏插入圖片描述
得到三條記錄。
然後分別執行;
select * from t2 where t2.a = 1;
select * from t2 where t2.a = 2;
select * from t2 where t2.a = 5;

所以實際上對於上面的步驟,實際上都是針對單表的查詢,所以都可以使用索引來幫助查詢。

(二)基於塊的嵌套循環連接(Block Nested-Loop Join)

掃描一個表的過程其實是先把這個表從磁盤上加載到內存中,然後從內存中比較匹配條件是否滿足。現實生活中的表可不像t1、t2這種只有幾條記錄,可能會有成千上萬的數據。內存裏可能並不能完全存放的下表中所有的記錄,所以在掃描表前邊記錄的時候後邊的記錄可能還在磁盤上,等掃描到後邊記錄的時候可能內存不足,所以需要把前邊的記錄從內存中釋放掉。我們前邊又說過,採用嵌套循環連接算法的兩表連接過程中,被驅動表可是要被訪問好多次的,如果這個被驅動表中的數據特別多而且不能使用索引進行訪問,那就相當於要從磁盤上讀好幾次這個表,這個I/O代價就非常大了,所以我們得想辦法:儘量減少訪問被驅動表的次數

當被驅動表中的數據非常多時,每次訪問被驅動表,被驅動表的記錄會被加載到內存中,在內存中的每一條記錄只會和驅動表結果集的一條記錄做匹配,之後就會被從內存中清除掉。然後再從驅動表結果集中拿出另一條記錄,再一次把被驅動表的記錄加載到內存中一遍,周而復始,驅動表結果集中有多少條記錄,就得把被驅動表從磁盤上加載到內存中多少次。所以我們可不可以在把被驅動表的記錄加載到內存的時候,一次性和多條驅動表中的記錄做匹配,這樣就可以大大減少重複從磁盤上加載被驅動表的代價了。
join buffer
Mysql中有一個叫做join buffer的概念,join buffer就是執行連接查詢前申請的一塊固定大小的內存,先把若干條驅動表結果集中的記錄裝在這個join buffer中,然後開始掃描被驅動表,每一條被驅動表的記錄一次性和join buffer中的多條驅動表記錄做匹配,因爲匹配的過程都是在內存中完成的,所以這樣可以顯著減少被驅動表的I/O代價。
最好的情況是join buffer足夠大,能容納驅動表結果集中的所有記錄,這樣只需要訪問一次被驅動表就可以完成連接操作了。這種加入了join buffer的嵌套循環連接算法稱之爲基於塊的嵌套連接(Block Nested-Loop Join)算法。
這個join buffer的大小是可以通過啓動參數或者系統變量join_buffer_size進行配置,默認大小爲262144字節(也就是256KB),最小可以設置爲128字節。當然,對於優化被驅動表的查詢來說,最好是爲被驅動表加上效率高的索引,如果實在不能使用索引,並且自己的機器的內存也比較大可以嘗試調大join_buffer_size的值來對連接查詢進行優化。
另外需要注意的是,驅動表的記錄並不是所有列都會被放到join buffer中,只有查詢列表中的列和過濾條件中的列纔會被放到join buffer中,所以再次提醒我們,最好不要把*作爲查詢列表,只需要把我們關心的列放到查詢列表就好了,這樣可以在join buffer中放置更多的記錄。

三、外連接消除

內連接的驅動表和被驅動表的位置可以相互轉換,而左連接和右連接的驅動表和被驅動表是固定的。這就導致內連接可能通過優化表的連接順序來降低整體的查詢成本,而外連接卻無法優化表的連接順序。
外連接和內連接的本質區別就是:對於外連接的驅動表的記錄來說,如果無法在被驅動表中找到匹配ON子句中的過濾條件的記錄,那麼該記錄仍然會被加入到結果集中,對應的被驅動表記錄的各個字段使用NULL值填充;而內連接的驅動表的記錄如果無法在被驅動表中找到匹配ON子句中的過濾條件的記錄,那麼該記錄會被捨棄

案例:下圖中可以發現驅動表和被驅動表發生了變化,實際上加上了is not null之後被優化成了內連接,就可以利用查詢優化器選擇最優的連接順序了。
在這裏插入圖片描述

四、關於子查詢的優化

下面這些sql都含有子查詢:

select * from t1 where a in (select a from t2);
select * from (select * from t1) as t;

(一)按返回的結果集區分子查詢

  • 1、標量子查詢
    那些只返回一個單一值的子查詢稱之爲標量子查詢。比如:
    select * from t1 where a in (select max(a) from t2);
  • 2、行子查詢
    返回一條記錄的子查詢,不過這條記錄需要包含多個列。比如:
    select * from t1 where (a, b) = (select a, b from t2 limit 1);
  • 3、列子查詢
    返回一個列的數據的子查詢,包含多條記錄。比如:
    select * from t1 where a in (select a from t2);
  • 4、表子查詢
    子查詢的結果既包含很多條記錄,又包含很多個列。比如:
    select * from t1 where (a, b) in (select a,b from t2);

(二)按與外層查詢關係來區分子查詢

  • 1、相關子查詢
    如果子查詢的執行需要依賴於外層查詢的值,我們就可以把這個子查詢稱之爲相關子查詢。比如:
    select * from t1 where a in (select a from t2 where t1.a = t2.a);
  • 2、不相關子查詢
    如果子查詢可以單獨運行出結果,而不依賴於外層查詢的值,我們就可以把這個子查詢稱之爲不相關子查詢。前邊介紹的那些子查詢全部都可以看作不相關子查。

(三)子查詢在MySQL中是怎麼執行的

  • 1、對於不相關標量子查詢或者行子查詢
    比如:select * from t1 where a = (select a from t2 limit 1);
    它的執行步驟是:
    1)執行select a from t2 limit 1這個子查詢。
    2)然後在將上一步子查詢得到的結果當作外層查詢的參數再執行外層查詢select * from t1 where a = …;

  • 2、對於相關標量子查詢或者行子查詢
    比如:select * from t1 where b = (select b from t2 where t1.a = t2.a limit 1);
    它的執行步驟是:
    1)先從外層查詢中獲取一條記錄,本例中也就是先從t1表中獲取一條記錄。
    2)然後從上一步驟中獲取的那條記錄中找出子查詢中涉及到的值,本例中就是從t1表中獲取的那條記錄中找出t1.a列的值,然後執行子查詢。
    3)最後根據子查詢的查詢結果來檢測外層查詢WHERE子句的條件是否成立,如果成立,就把外層查詢的那條記錄加入到結果集,否則就丟棄。
    4)再次執行第一步,獲取第二條外層查詢中的記錄,依次類推。。。

  • 3、IN子查詢優化
    mysql對IN子查詢進行了優化。
    比如:select * from t1 where a in (select a from t2);
    對於不相關的IN子查詢來說,如果子查詢的結果集中的記錄條數很少,那麼把子查詢和外層查詢分別看成兩個單獨的單表查詢效率還是蠻高的,但是如果單獨執行子查詢後的結果集太多的話,就會導致這些問題:
    • 結果集太多,可能內存中都放不下
    • 對於外層查詢來說,如果子查詢的結果集太多,那就意味着IN子句中的參數特別多,這會導致:
    • 無法有效的使用索引,只能對外層查詢進行全表掃描。
    • 在對外層查詢執行全表掃描時,由於IN子句中的參數太多,這會導致檢測一條記錄是否符合和IN子句中的參數匹配花費的時間太長

在mysql中,不直接將不相關子查詢的結果集當作外層查詢的參數,而是將該結果集寫入一個臨時表裏。寫入臨時表的過程是這樣的:
1)該臨時表的列就是子查詢結果集中的列。
2)寫入臨時表的記錄會被去重。IN語句是判斷某個操作數在不在某個集合中,集合中的值重不重複對整個IN語句的結果並不影響,所以我們在將結果集寫入臨時表時對記錄進行去重可以讓臨時表變得更小。臨時表也是個表,只要爲表中記錄的所有列建立主鍵或者唯一索引就可以進行去重。
3)一般情況下子查詢結果集不會特別大,所以會爲它建立基於內存的使用Memory存儲引擎的臨時表,而且會爲該表建立哈希索引。IN語句的本質就是判斷某個操作數在不在某個集合裏,如果集合中的數據建立了哈希索引,那麼這個匹配的過程就是很快的。
4)如果子查詢的結果集非常大,超過了系統變量tmp_table_size或者max_heap_table_size,臨時表會轉而使用基於磁盤的存儲引擎來保存結果集中的記錄,索引類型也對應轉變爲B+樹索引。

這個將子查詢結果集中的記錄保存到臨時表的過程稱之爲物化(Materialize)。那個存儲子查詢結果集的臨時表稱之爲物化表。正因爲物化表中的記錄都建立了索引(基於內存的物化表有哈希索引,基於磁盤的有B+樹索引),通過索引執行IN語句判斷某個操作數在不在子查詢結果集中變得非常快,從而提升了子查詢語句的性能。

還是對於上面的那個sql:select * from t1 where a in (select a from t2);

當我們把子查詢進行物化之後,假設子查詢物化表的名稱爲materialized_table,該物化表存儲的子查詢結果集的列爲m_val,那麼這個查詢其實可以從下邊兩種角度來看待:

• 從表t1的角度來看待,整個查詢的意思其實是:對於t1表中的每條記錄來說,如果該記錄的a列的值在子查詢對應的物化表中,則該記錄會被加入最終的結果集。

• 從子查詢物化表的角度來看待,整個查詢的意思其實是:對於子查詢物化表的每個值來說,如果能在t1表中找到對應的a列的值與該值相等的記錄,那麼就把這些記錄加入到最終的結果集。

也就是說其實上邊的查詢就相當於表t1和子查詢物化表materialized_table進行內連接:
select * from t1 inner join materialized_table on t1.a = m_val;

轉化成內連接之後,查詢優化器就可以評估不同連接順序需要的成本是多少,選取成本最低的那種查詢方式執行查詢。
雖然將子查詢進行物化之後再執行查詢會有建立臨時表的成本,但是可以將子查詢轉換爲JOIN還是會更有效率一點的。那能不能不進行物化操作直接把子查詢轉換爲連接呢。
我們對比下面兩個sql:
select * from t1 where a in (select a from t2);
select t1.* from t1 inner join t2 on t1.a = t2.a;

這兩個sql的查詢結果其實很像,只是說對於第二個sql的結果集沒有去重,所以IN子查詢和兩表連接之間並不完全等價,但是將子查詢轉換爲連接又真的可以充分發揮優化器的作用,所以MySQL提出了一個新概念半連接(semi-join),將t1表和t2表進行半連接的意思就是:對於t1表的某條記錄來說,我們只關心在t2表中是否存在與之匹配的記錄是否存在,而不關心具體有多少條記錄與之匹配,最終的結果集中只保留t1表的記錄。semi-join只是在MySQL內部採用的一種執行子查詢的方式,MySQL並沒有提供面向用戶的semi-join語法 。

那麼怎麼實現semi-join呢?

(1)Table pullout (子查詢中的表上拉)
當子查詢的查詢列表處只有主鍵或者唯一索引列時,可以直接把子查詢中的表上拉到外層查詢的FROM子句中,並把子查詢中的搜索條件合併到外層查詢的搜索條件中。
比如:select * from t1 where a in (select a from t2 where t2.b = 1); – a是主鍵

我們可以直接把t2表上拉到外層查詢的FROM子句中,並且把子查詢中的搜索條件合併到外層查詢的搜索條件中,上拉之後的查詢就是這樣的:
select * from t1 inner join t2 on t1.a = t2.a where t2.b = 1; – a是主鍵

(2)DuplicateWeedout execution strategy (重複值消除)
對於這個查詢來說:
select * from t1 where a in (select e from t2 where t2.b = 1); – e只是一個普通字段

轉換爲半連接查詢後,t1表中的某條記錄可能在t2表中有多條匹配的記錄,所以該條記錄可能多次被添加到最後的結果集中,爲了消除重複,我們可以建立一個臨時表,比方說這個臨時表長這樣:
CREATE TABLE tmp (
id PRIMARY KEY
);

這樣在執行連接查詢的過程中,每當某條t1表中的記錄要加入結果集時,就首先把這條記錄的主鍵值加入到這個臨時表裏,如果添加成功,說明之前這條t1表中的記錄並沒有加入最終的結果集,現在把該記錄添加到最終的結果集;如果添加失敗,說明這條之前這條t1表中的記錄已經加入過最終的結果集,這裏直接把它丟棄就好了,這種使用臨時表消除semi-join結果集中的重複值的方式稱之爲DuplicateWeedout

(3)FirstMatch execution strategy (首次匹配)
FirstMatch是一種最原始的半連接執行方式,就是我們最開始的思路,先取一條外層查詢的中的記錄,然後到子查詢的表中尋找符合匹配條件的記錄,如果能找到一條,則將該外層查詢的記錄放入最終的結果集並且停止查找更多匹配的記錄,如果找不到則把該外層查詢的記錄丟棄掉;然後再開始取下一條外層查詢中的記錄,重複上邊這個過程。

(4)LooseScan(鬆散索引掃描)
子查詢掃描了非唯一索引,因爲是非唯一索引,所以可能有相同的值,可以利用索引去重。
對於某些使用IN語句的相關子查詢,比方這個查詢:
select * from t1 where a in (select b from t2 where t1.b = t2.b);
它可以轉換爲半連接:
select * from t1 semi join t2 on t1.a = t2.a and t1.b = t2.b;

如一下幾種情況就不能轉換爲semi-join:
• 外層查詢的WHERE條件中有其他搜索條件與IN子查詢組成的布爾表達式使用OR連接起來
• 使用NOT IN而不是IN的情況
• 子查詢中包含GROUP BY、HAVING或者聚集函數的情況
• 子查詢中包含UNION的情況

那麼對於不能轉爲semi-join查詢的子查詢,有其他方式來進行優化:
• 對於不相關子查詢來說,可以嘗試把它們物化之後再參與查詢
比如對於使用了NOT IN下面這個sql:
select * from t1 where a not in (select a from t2 where t2.a = 1);

請注意這裏將子查詢物化之後不能轉爲和外層查詢的表的連接,因爲用的是not in只能是先掃描t1表,然後對t1表的某條記錄來說,判斷該記錄的a值在不在物化表中。
• 不管子查詢是相關的還是不相關的,都可以把IN子查詢嘗試專爲EXISTS子查詢
其實對於任意一個IN子查詢來說,都可以被轉爲EXISTS子查詢,通用的例子如下:
outer_expr IN (SELECT inner_expr FROM … WHERE subquery_where)
可以被轉換爲:
EXISTS (SELECT inner_expr FROM … WHERE subquery_where AND outer_expr=inner_expr)

這樣轉換的好處是,轉換前本來不能用到索引,但是轉換後可能就能用到索引了,比如:
select * from t1 where a in (select a from t2 where t2.e = t1.e);
這個sql裏面的子查詢時用不到索引的,轉換後變爲:
select * from t1 where exists (select 1 from t2 where t2.e = t1.e and t1.a = t2.a)

轉換之後t2表就能用到a字段的索引了。
所以,如果IN子查詢不滿足轉換爲semi-join的條件,又不能轉換爲物化表或者轉換爲物化表的成本太大,那麼它就會被轉換爲EXISTS查詢。

五、對於派生表的優化

select * from (select a, b from t1) as t;
上面這個sql,子查詢是放在from後面的,這個子查詢的結果相當於一個派生表,表的名稱是t,有a,b兩個字段。
對於派生表,有兩種執行方式:

(一)把派生表物化

我們可以將派生表的結果集寫到一個內部的臨時表中,然後就把這個物化表當作普通表一樣參與查詢。當然,在對派生表進行物化時,使用了一種稱爲延遲物化的策略,也就是在查詢中真正使用到派生表時纔回去嘗試物化派生表,而不是還沒開始執行查詢就把派生表物化掉。比如:
select * from (select * from t1 where a = 1) as derived1 inner join t2 on derived1.a = t2.a where t2.a =10;

如果採用物化派生表的方式來執行這個查詢的話,那麼執行時首先會到t1表中找出滿足t1.a = 10的記錄,如果找不到,說明參與連接的t1表記錄就是空的,所以整個查詢的結果集就是空的,所以也就沒有必要去物化查詢中的派生表了。

(二)將派生表和外層的表合併,也就是將查詢重寫爲沒有派生表的形式

比如下面這個sql:
select * from (select * from t1 where a = 1) as t;
和下面的sql是等價的:
select * from t1 where a = 1;

再看一些複雜一點的sql:
select * from (select * from t1 where a = 1) as t inner join t2 on t.a = t2.a where t2.b = 1;
我們可以將派生表與外層查詢的表合併,然後將派生表中的搜索條件放到外層查詢的搜索條件中,就像下面這樣:
select * from t1 inner join t2 on t1.a = t2.a where t1.a = 1 and t2.b = 1;

這樣通過將外層查詢和派生表合併的方式成功的消除了派生表,也就意味着我們沒必要再付出創建和訪問臨時表的成本了。可是並不是所有帶有派生表的查詢都能被成功的和外層查詢合併,當派生表中有這些語句就不可以和外層查詢合併:

聚集函數,比如MAX()、MIN()、SUM()啥的
DISTINCT
GROUP BY
HAVING
LIMIT
UNION 或者 UNION ALL
派生表對應的子查詢的SELECT子句中含有另一個子查詢

所以MySQL在執行帶有派生表的時候,優先嚐試把派生表和外層查詢合併掉,如果不行的話,再把派生表物化掉執行查詢。

以上均爲魯班學院學習資料,歡迎大家報班學習,真心推薦

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章