在相同語義表達下,何時用Join查詢,何時用子查詢?

導讀

還記得我在《Join查詢深度優化 - 鮮爲人知的新方法》一文中的《導讀》裏的一條案例SQL嗎?程序員

這是一條Join查詢,用來統計訪問一個用戶的人羣性別分佈。在這裏,我再從新貼一下:web

SELECT u.sex, COUNT(*) FROM user u LEFT JOIN t_user_view tuv ON u.user_id = tuv.user_id WHERE tuv.viewed_user_id = 10008 GROUP BY u.sex
複製代碼

做爲程序員,咱們常常發現不少業務邏輯用SQL表達,既可使用Join,也能夠是子查詢實現。好比,上面這條SQL,若是咱們用子查詢來實現,那麼,能夠這麼寫:面試

SELECT u.sex, COUNT(*) FROM user u WHERE u.user_id IN (SELECT user_id FROM t_user_view WHERE viewed_user_id = 10008) GROUP BY u.sex
複製代碼

既然一個業務邏輯既能夠用Join表達,又能夠用子查詢表達,那麼,到底Join和子查詢差異在哪兒呢,哪一個查詢性能更好呢,咱們到底何時使用Join,何時使用子查詢呢?sql

今天,我就來分析一會兒查詢的原理,逐漸幫你解開上面一連串的問題。markdown

LooseScan

咱們先來看下上面這條SQL使用到的表結構及數據。工具

user和t_user_view兩張表的結構以下:post

  • user性能

    CREATE TABLE `user` (
      `id` int(11) NOT NULL,
      `user_id` int(8) DEFAULT NULL COMMENT '用戶id',
      `user_name` varchar(29) DEFAULT NULL COMMENT '用戶名',
      `user_introduction` varchar(498) DEFAULT NULL COMMENT '用戶介紹',
      `sex` tinyint(1) DEFAULT NULL COMMENT '性別',
      `age` int(3) DEFAULT NULL COMMENT '年齡',
      `birthday` date DEFAULT NULL COMMENT '生日',
      PRIMARY KEY (`id`),
      UNIQUE KEY `index_user_id` (`user_id`),
      KEY `index_un_age_sex` (`user_name`,`age`,`sex`),
      KEY `index_age_sex` (`age`,`sex`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    複製代碼
  • t_user_view優化

    CREATE TABLE `t_user_view` (
      `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增id',
      `user_id` bigint(20) DEFAULT NULL COMMENT '用戶id',
      `viewed_user_id` bigint(20) DEFAULT NULL COMMENT '被查看用戶id',
      `view_count` bigint(20) DEFAULT NULL COMMENT '查看次數',
      `create_time` datetime(3) DEFAULT CURRENT_TIMESTAMP(3),
      `update_time` datetime(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
      PRIMARY KEY (`id`),
      KEY `index_viewed_user_user` (`viewed_user_id`,`user_id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8;
    複製代碼

其中,兩張表的記錄以下:ui

  • user

image.png

  • t_user_view

image.png

那麼,如今有了表結構和數據,結合我在《爲何MySQL可以支撐千萬數據規模的快速查詢?》中講解的索引查找過程,咱們看下MySQL是如何執行《導讀》中的子查詢的?

image.png 咱們關注圖中紅線部分:

  1. 根據條件viewed_user_id=10008,查找表t_user_view中的索引樹index_viewed_user_user,定位到知足條件的葉子節點,即圖中最左邊樹中的綠色節點。遍歷節點內知足條件的記錄,獲得結果,即圖中從左向右第二部分。

  2. 順序掃描上一步的查詢結果記錄,圖中第二部分向下的箭頭。

    2.1 根據知足條件的記錄10008,10001,到表user中的索引樹index_user_id查找user_id=10001的記錄,定位到葉子節點,即圖中第三部分索引樹右下角橘黃色節點。獲得節點內知足user_id=10001的記錄10001。同理,能夠獲得知足user_id=10002的記錄10002。分別對應圖中最右邊的1000110002

    2.2 根據知足條件的記錄10008,100013,到表user中的索引樹index_user_id查找user_id=10003的記錄,定位到葉子節點,即圖中第三部分索引樹下方中間的橘黃色節點。獲得節點內知足user_id=10003的記錄10003。同理,能夠獲得知足user_id=10005的記錄10005和知足user_id=10009的記錄10009。分別對應圖中最右邊的100031000510009

  3. 結合《告訴面試官,我能優化groupBy,並且知道得很深!》中groupBy原理,對上一步知足條件的記錄執行groupBy和count查詢。

其中,第2步掃描的過程,MySQL把它叫作LooseScan: 鬆散掃描。爲何叫鬆散掃描呢?假設上圖第二部分,咱們獲得的記錄有重複值,MySQL是如何掃描的呢?咱們來看一下:

image.png

一樣關注圖中紅線部分:

  1. 順序掃描知足條件的記錄,圖中向下的箭頭。

    1.1 根據知足條件的記錄10008,10001,到表user中的索引樹index_user_id查找user_id=10001的記錄,定位到葉子節點,即圖中索引樹右下角橘黃色節點。

    1.2 根據知足條件的記錄10008,10003中的第一條,到表user中的索引樹index_user_id查找user_id=10003的記錄,定位到葉子節點,即圖中索引樹下方中間的橘黃色節點。ps:相同記錄,只取第一條掃描

結合上面兩種掃描的過程,咱們就能夠明白爲何MySQL把這種掃描過程叫作鬆散掃描了。

LooseScan:在掃描索引記錄過程當中,若是出現相同的記錄,只掃描第一條記錄。

講到這裏,你可能以爲子查詢執行過程也沒啥特別的呀!就是走索引 -> 掃描 -> 走索引!說到這個過程,你有沒有想過,若是第2步順序掃描的記錄不少,好比1w條,那麼,MySQL這麼執行子查詢是否是很是很是慢?

這裏順便就引出了執行LooseScan的一個觸發條件:子查詢語句中內層查詢必須可以命中索引。否則會很慢!

所以,在應對掃描記錄很是多的狀況,MySQL又想出了其餘策略來優化子查詢。

FirstMatch

好比:下面這個案例:

假設如今風控團隊但願找出平臺上在某個時間點訪問單個用戶主頁總次數大於10000的異經常使用戶。那麼,我就會用下面這條SQL找出這樣的用戶:

SELECT * FROM user WHERE user_id IN (SELECT user_id FROM t_user_view WHERE view_count >= 10000)
複製代碼

此時,我再建一個索引以下:

ALTER TABLE `t_user_view` ADD INDEX `index_vc_user` (`view_count`, `user_id`);
複製代碼

因爲上面的SQL中的內層查詢使用的範圍查詢,因此,MySQL認爲範圍查詢的結果數量是不可預知的,因此,即上面SQL的內層查詢條件view_count>=10000,誰也不知道t_user_view表中有多少大於等於10000的記錄,因此,就引出了另外一個執行LooseScan的觸發條件:

子查詢語句中內層查詢必須是等值查詢

既然不能使用LooseScan策略,因而,MySQL嘗試了下面這種查詢策略來執行案例SQL。

image-20210614232345194.png

關注圖中紅線部分:

  1. 根據內層語句查詢條件view_count>=10000,查找t_user_view表索引樹index_vc_user,定位到葉子節點,即圖中左邊樹中綠色的節點。同時,找到了該節點內知足條件的第一條記錄10000,10002

  2. 掃描user表:

    2.1 根據表記錄1,10001,...,1998-01-02,從節點內知足條件的第一條記錄向後掃描。掃描到最後一條知足條件的記錄,發現全部知足條件記錄中的user_id都不等於表記錄中的10001,即圖中記錄1,10001,...,1998-01-02指出的打叉的紅色箭頭。

    2.2 根據表記錄2,10002,...,2008-02-03,從節點內知足條件的第一條記錄向後掃描。掃描到第一條知足條件的記錄,發現該記錄10000,10002中的user_id等於表記錄中的10002,即圖中記錄2,10002,...,2008-02-03指出的綠色虛線箭頭。將user表記錄1,10002,...,2008-02-03放入最終結果集,即圖中灰色方框2,10002,...,2008-02-03表示最終結果集中的記錄之一。

    2.3 根據表記錄3,10009,...,2002-06-07,從節點內知足條件的第一條記錄向後掃描。掃描到第二條知足條件的記錄,發現該記錄15000,10009中的user_id等於表記錄中的10009,即圖中記錄3,10009,...,2002-06-07指出的綠色虛線箭頭。將user表記錄3,10009,...,2002-06-07放入最終結果集,即圖中灰色方框3,10009,...,2002-06-07表示最終結果集中的記錄之一。

MySQL將上面這種掃描子查詢語句外層表,而後,逐條查找語句內層索引或內層表的過程,叫作FirstMatch

從上面的案例能夠看出,MySQL在不知道內層子句索引記錄是否很大的狀況下,選擇了掃描外層表的方式嘗試執行整條語句,可是,很明顯在不知道內層索引的狀況下,單純掃描外層表不必定是性能最好的方式,因此,MySQL又想出了下面這種策略嘗試掃描內層表索引。

MaterializeScan

仍是以《FirstMatch》中的案例SQL爲例,咱們來看看這個執行策略:

image-20210615122102255.png

關注圖中紅線部分:

  1. 根據內層查詢條件view_count>=10000,查找索引樹index_vc_user,定位到知足條件的節點,即圖中左邊樹的綠色節點。同時,找到節點內知足條件的第一條記錄10000,10002

  2. 新建臨時表tmp_table,從記錄10000,10002開始遍歷後面的記錄:

    2.1 將記錄10000,10002中的值10002插入tmp_table

    2.2 將記錄15000,10009中的值10009插入tmp_table

    2.3 將記錄20000,10005中的值10005插入tmp_table

    2.4 將記錄20000,10005中的值10005插入tmp_table,因爲tmp_table加了user_id的惟一索引,因此,MySQL檢查10005已經存在於tmp_table,因此,該插入失敗

    2.5 將記錄30000,10005中的值10005插入tmp_table,同理,因爲tmp_table加了user_id的惟一索引,因此,MySQL檢查10005已經存在於tmp_table,因此,該插入失敗

  3. 掃描tmp_table

    3.1 根據表中的記錄10002,查找外層表user中的索引樹index_user_id,定位到10002所在葉子節點,即圖中最右邊樹中橘色節點。遍歷該節點內記錄,找到10002這條記錄。

    3.2 同理,根據表中的記錄10009,查找外層表user中的索引樹index_user_id,定位到10009所在葉子節點,即圖中最右邊樹中橘色節點。遍歷該節點內記錄,找到10009這條記錄。

    3.3 同理,根據表中的記錄10005,查找外層表user中的索引樹index_user_id,定位到10005所在葉子節點,即圖中最右邊樹中橘色節點。遍歷該節點內記錄,找到10005這條記錄。

  4. user表查找第3步中找到的3條記錄100021000910005對應的用戶信息。

上面的過程當中,新建立的tmp_table,因爲其包含了一個惟一索引,保證了其插入記錄的惟一性,對索引index_vc_user起到了去重的做用,而後,經過掃描tmp_table,逐條記錄去查找index_user_id索引。

MySQL把新建臨時表去重,而後,掃描臨時表(或臨時表索引),以後用臨時表記錄逐條匹配外層表記錄,這樣一種方式叫作MaterializeScan,其中,新建的tmp_table叫作物化表。

仔細看上述過程當中的第3步,因爲tmp_table中的每一條記錄都須要從索引樹index_user_id的根節點搜索,這個搜索路徑是否是有點重複,因此,MySQL發現其實有不去重複走這個搜索路徑的方法,因而,就產生了新的策略來優化《FirstMatch》中的案例SQL。

MaterializeLookup

咱們來看一下這個策略:

image-20210615205156970.png

關注圖中紅線部分:

  1. 根據內層查詢條件view_count>=10000,查找索引樹index_vc_user,定位到知足條件的節點,即圖中左邊樹的綠色節點。同時,找到節點內知足條件的第一條記錄10000,10002

  2. 新建臨時表tmp_table,從記錄10000,10002開始遍歷後面的記錄:

    2.1 將記錄10000,10002中的值10002插入tmp_table

    2.2 將記錄15000,10009中的值10009插入tmp_table

    2.3 將記錄20000,10005中的值10005插入tmp_table

    2.4 將記錄20000,10005中的值10005插入tmp_table,因爲tmp_table加了user_id的惟一索引,因此,MySQL檢查10005已經存在於tmp_table,因此,該插入失敗

    2.5 將記錄30000,10005中的值10005插入tmp_table,同理,因爲tmp_table加了user_id的惟一索引,因此,MySQL檢查10005已經存在於tmp_table,因此,該插入失敗

  3. 掃描user表:

    3.1 根據表記錄1,10001,...,1998-01-02,從tmp_table中的第一條記錄向後掃描。掃描到最後一條記錄,發現全部記錄中的user_id都不等於user表記錄中的10001,即圖中記錄1,10001,...,1998-01-02指出的打叉的紅色箭頭。

    3.2 根據表記錄2,10002,...,2008-02-03,從tmp_table中的第一條記錄向後掃描。掃描到第一條記錄,發現該記錄中的user_id等於user表記錄中的10002,即圖中記錄2,10002,...,2008-02-03指出的綠色虛線箭頭。將user表記錄1,10002,...,2008-02-03放入最終結果集,即圖中灰色方框2,10002,...,2008-02-03表示最終結果集中的記錄之一。

    3.3 根據表記錄3,10009,...,2002-06-07,從tmp_table中的第一條記錄向後掃描。掃描到第2條記錄,發現該記錄中的user_id等於user表記錄中的10009,即圖中記錄3,10009,...,2002-06-07指出的綠色虛線箭頭。將user表記錄3,10009,...,2002-06-07放入最終結果集,即圖中灰色方框3,10009,...,2002-06-07表示最終結果集中的記錄之一。同理,能夠找到記錄5,10005,...,2008-02-06放入最終結果集。

    3.4 根據表記錄8,10008,...,2002-06-07,從tmp_table中的第一條記錄向後掃描。掃描到最後一條記錄,發現全部記錄中的user_id都不等於user表記錄中的10009,即圖中記錄8,10008,...,2002-06-07指出的打叉的紅色箭頭。

  4. 至此,從user表中找出了案例中查詢語句的記錄1,10002,...,2008-02-033,10009,...,2002-06-075,10005,...,2008-02-06

在上面這個過程當中,咱們發現MySQL直接用user表的記錄去匹配tmp_table中的記錄,沒有走索引查找,所以,查詢效率相比上面MaterializeScan的方式快一些。

MySQL把這種新建臨時表,經過掃描子查詢語句外層表,逐條記錄匹配臨時表記錄的方式叫作MaterializeLookup

上面講了4種子查詢執行策略,你會發現它們都有共同點:不管是語句外層表仍是內層表,只要有索引,就能夠藉助索引提高查詢的效率。那麼,若是內外表都沒有索引,MySQL又是怎麼執行子查詢的呢?這裏又引出了一種新策略,我仍是以《FirstMatch》中的案例SQL爲例,咱們來看一下。

DuplicatesWeedout

image-20210616003247554.png

關注圖中紅線部分:

  1. 使用user表的row_id,新建臨時表,即該表中只有一個字段row_id,且惟一。即圖中最左邊的部分。

  2. 全表掃描t_user_view,查找知足條件view_count>=10000的記錄,找到5條記錄。即圖中從左向右第二部分灰色的方框,其中,省略了部分記錄。

  3. 將第2步獲得的記錄,經過user_id字段和user表關聯。即圖中標有user_id的紅線。

    3.1 記錄3,10002,...,10000經過user_id關聯user表記錄2,10002,...,2008-02-03

    3.2 記錄7,10005,...,20000經過user_id關聯user表記錄5,10005,...,2008-02-06

    最終獲得關聯表記錄,即圖中第4部分。

  4. 將關聯表記錄插入臨時表。

    4.1 將2,10002,...,10000插入臨時表,因爲user.row_id=2在臨時表中不存在,插入成功。

    4.2 將3,10009,...,15000插入臨時表,因爲user.row_id=3在臨時表中不存在,插入成功。

    4.3 將5,10005,...,30000插入臨時表,因爲user.row_id=5在臨時表中不存在,插入成功。

    4.4 將5,10005,...,20000插入臨時表,因爲user.row_id=5在臨時表中存在,因爲row_id必須惟一,插入失敗。

  5. 最終獲得了子查詢的結果:3條記錄。

經過以上5種子查詢執行策略的逐個分析,咱們發現《FirstMatch》案例中的子查詢語句使用MaterializeLookup性能最好。

同時,上面5種策略分析也是MySQL優化子查詢的過程:逐個分析5種策略的查詢成本,得出最優解,最終,選擇最優的那個查詢策略。

下面咱們經過MySQL自帶的語句優化查詢工具optimizer_trace來驗證一下我對《FirstMatch》案例中的子查詢的分析是否正確,我使用以下語句查看優化策略:

SET OPTIMIZER_TRACE="enabled=on";
SET OPTIMIZER_TRACE_MAX_MEM_SIZE=1000000;
SELECT * FROM user WHERE user_id IN (SELECT user_id FROM t_user_view WHERE view_count >= 10000);
SELECT * FROM INFORMATION_SCHEMA.OPTIMIZER_TRACE;
複製代碼

因爲執行後的結果很長,我就截取5種優化策略的成本結果:

  • LooseScan

    因爲案例語句不能使用該策略,因此,MySQL沒有分析該策略成本。

  • FirstMatch

image-20210615222741066.png

  • MaterializeScan

image-20210615222828977.png

  • MaterializeLookup

image-20210615223116262.png

  • DuplicatesWeedout

image-20210616003645068.png

從5種策略的執行成原本看,的確是MaterializeLookup成本最低,因此,MySQL選擇MaterializeLookup策略來優化《FirstMatch》中的案例SQL。

講到這裏,咱們就清楚了MySQL優化子查詢語句的5種策略:LooseScan、FirstMatch、MaterializeScan、MaterializeLookup和DuplicatesWeedout。MySQL經過對比這幾種策略的執行成本,決定最終使用哪一種策略執行子查詢。

總結

文章經過真實的子查詢案例(固然還有其餘子查詢結構,^_^),講解了MySQL對子查詢的優化策略,其中提到的表關聯叫作SEMIJOIN。這裏我再從新梳理一下,總結出MySQL優化子查詢的5種策略以下:

執行策略 觸發條件 優化方案
LooseScan 1. 子查詢語句中內層子查詢個數不能超過64
2. 子查詢語句中內層子查詢必須可以命中索引
3. 子查詢語句中內層查詢必須是等值查詢
在掃描子查詢內層表索引記錄過程當中,若是出現相同的記錄,只掃描第一條記錄,而後,逐條去外層表查找對應記錄
FirstMatch 掃描子查詢語句外層表,而後,逐條查找語句內層表索引或內層表對應記錄
MaterializeScan 新建臨時表去重,而後,掃描臨時表(或臨時表索引),以後用臨時表記錄逐條匹配外層表記錄
MaterializeLookup 新建臨時表去重,經過掃描子查詢語句外層表,逐條記錄匹配臨時表記錄
DuplicatesWeedout 新建臨時表,臨時表中只存子查詢外層或內層表row_id,經過row_id來去重關聯表記錄

經過上面的總結,咱們發現MySQL這幾種子查詢優化策略都是經過去重記錄來實現查詢性能的優化。對比Join查詢,咱們很容易發現,Left Join/Right Join查詢在出現關聯字段值重複時,不會去重,所以,在關聯掃表的狀況下,很是影響性能。

因此,咱們就知道表達相同的語義時,什麼狀況下使用子查詢,什麼狀況下使用Join查詢了?

  • 當關聯表的關聯字段出現重複值時,建議使用子查詢,利用其去重優化策略來提高查詢性能。
  • 當關聯表的關聯字段值惟一時,子查詢和Join查詢的性能差別不大,均可以使用。
相關文章
相關標籤/搜索