不要再問我 in,exists 走不走索引了...

前言

最近,有一個業務需求,給我一份數據 A ,把它在數據庫 B 中存在,而又比 A 多出的部分算出來。因爲數據比較雜亂,我這裏簡化模型。html

而後就會發現,我去,這不就是 not in ,not exists 嘛。mysql

那麼問題來了,in,  not in , exists , not exists 它們有什麼區別,效率如何?web

曾經從網上據說,in 和 exists 不會走索引,那麼事實真的是這樣嗎?sql

帶着疑問,咱們研究下去。數據庫

注意: 在說這個問題時,不說明 MySQL 版本的都是耍流氓,我這裏用的是 5.7.18 。緩存

用法講解

爲了方便,咱們建立兩張表 t1 和 t2 。並分別加入一些數據。(id爲主鍵,name爲普通索引)服務器

-- t1
DROP TABLE IF EXISTS `t1`;
CREATE TABLE `t1` (
  `id` int(11NOT NULL AUTO_INCREMENT,
  `name` varchar(255DEFAULT NULL,
  `address` varchar(255DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_t1_name` (`name`(191)) USING BTREE
ENGINE=InnoDB AUTO_INCREMENT=1009 DEFAULT CHARSET=utf8mb4;

INSERT INTO `t1` VALUES ('1001''張三''北京'), ('1002''李四''天津'), ('1003''王五''北京'), ('1004''趙六''河北'), ('1005''傑克''河南'), ('1006''湯姆''河南'), ('1007''貝爾''上海'), ('1008''孫琪''北京');

-- t2
DROP TABLE IF EXISTS `t2`;
CREATE TABLE `t2`  (
  `id` int(11NOT NULL AUTO_INCREMENT,
  `name` varchar(255CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  `address` varchar(255CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  PRIMARY KEY (`id`USING BTREE,
  INDEX `idx_t2_name`(`name`(191)) USING BTREE
ENGINE = InnoDB AUTO_INCREMENT = 1014 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

INSERT INTO `t2` VALUES (1001'張三''北京');
INSERT INTO `t2` VALUES (1004'趙六''河北');
INSERT INTO `t2` VALUES (1005'傑克''河南');
INSERT INTO `t2` VALUES (1007'貝爾''上海');
INSERT INTO `t2` VALUES (1008'孫琪''北京');
INSERT INTO `t2` VALUES (1009'曹操''魏國');
INSERT INTO `t2` VALUES (1010'劉備''蜀國');
INSERT INTO `t2` VALUES (1011'孫權''吳國');
INSERT INTO `t2` VALUES (1012'諸葛亮''蜀國');
INSERT INTO `t2` VALUES (1013'典韋''魏國');

那麼,對於當前的問題,就很簡單了,用 not in 或者 not exists 均可以把 t1 表中比 t2 表多出的那部分數據給挑出來。(固然,t2 比 t1 多出來的那部分不算)微信

這裏假設用 name 來匹配數據。編輯器

select * from t1 where name not in (select name from t2);
或者用
select * from t1 where not exists (select name from t2 where t1.name=t2.name);

獲得的結果都是同樣的。函數

可是,須要注意的是,not in  和 not exists 仍是有不一樣點的。

在使用 not in 的時候,須要保證子查詢的匹配字段是非空的。如,此表 t2 中的 name 須要有非空限制。如若否則,就會致使 not in 返回的整個結果集爲空。

例如,我在 t2 表中加入一條 name 爲空的數據。

INSERT INTO `t2` VALUES (1014NULL'魏國');

則此時,not in 結果就會返回空。

另外須要明白的是, exists 返回的結果是一個 boolean 值 true 或者 false ,而不是某個結果集。由於它不關心返回的具體數據是什麼,只是外層查詢須要拿這個布爾值作判斷。

區別是,用 exists 時,若子查詢查到了數據,則返回真。用 not exists 時,若子查詢沒有查到數據,則返回真。

因爲 exists 子查詢不關心具體返回的數據是什麼。所以,以上的語句徹底能夠修改成以下,

-- 子查詢中 name 能夠修改成其餘任意的字段,如此處改成 1 。
select * from t1 where not exists (select 1 from t2 where t1.name=t2.name);

從執行效率來講,1 > column > * 。所以推薦用 select 1。(準確的說應該是常量值)

in, exists 執行流程

一、 對於 in 查詢來講,會先執行子查詢,如上邊的 t2 表,而後把查詢獲得的結果和外表 t1 作笛卡爾積,再經過條件進行篩選(這裏的條件就是指 name 是否相等),把每一個符合條件的數據都加入到結果集中。

sql 以下,

select * from t1 where name in (select name from t2);

僞代碼以下:

for(x in A){
    for(y in B){
     if(condition is true) {result.add();}
    }
}

這裏的 condition 其實就是對比兩張表中的 name 是否相同。

二、對於 exists 來講,是先查詢遍歷外表 t1 ,而後每次遍歷時,再檢查在內表是否符合匹配條件,即檢查是否存在 name 相等的數據。

sql 以下,

select * from t1 where name exists (select 1 from t2);

僞代碼以下:

for(x in A){
  if(exists condition is true){result.add();}
}

對應於此例,就是從 id 爲 1001 開始遍歷 t1 表 ,而後遍歷時檢查 t2 中是否有相等的 name 。

如 id=1001時,張三存在於 t2 表中,則返回 true,把 t1 中張三的這條記錄加入到結果集,繼續下次循環。id=1002 時,李四不在 t2 表中,則返回 false,不作任何操做,繼續下次循環。直到遍歷完整個 t1 表。

是否走索引?

針對網上說的 in 和 exists 不走索引,那麼到底是否如此呢?

咱們在 MySQL 5.7.18 中驗證一下。(注意版本號哦)

單表查詢

首先,驗證單表的最簡單的狀況。咱們就以 t1 表爲例,id爲主鍵, name 爲普通索引。

分別執行如下語句,

explain select * from t1 where id in (1001,1002,1003,1004);
explain select * from t1 where id in (1001,1002,1003,1004,1005);
explain select * from t1 where name in ('張三','李四');
explain select * from t1 where name in ('張三','李四','王五');

爲何我要分別查不一樣的 id 個數呢?看截圖,

會驚奇的發現,當 id 是四個值時,還走主鍵索引。而當 id 是五個值時,就不走索引了。這就很回味無窮了。

再看 name 的狀況,

一樣的當值多了以後,就不走索引了。

因此,我猜想這個跟匹配字段的長度有關。按照漢字是三個字節來計算,且程序設計中喜歡用2的n次冪的尿性,這裏大概就是以 16 個字節爲分界點。

然而,我又以一樣的數據,去個人服務器上查詢(版本號 5.7.22),發現四個id值時,就不走索引了。所以,估算這裏的臨界值爲 12 個字節。

無論怎樣,這說明了,在 MySQL 中應該對 in 查詢的字節長度是有限制的。(沒有官方確切說法,因此,僅供參考)

多表涉及子查詢

咱們主要是去看當前的這個例子中的兩表查詢時, in 和 exists 是否走索引。

1、分別執行如下語句,主鍵索引(id)和普通索引(name),在 in , not in 下是否走索引。

explain select * from t1 where id in (select id from t2); --1
explain select * from t1 where name in (select name from t2); --2
explain select * from t1 where id not in (select id from t2); --3
explain select * from t1 where name not in (select name from t2); --4

結果截圖以下,

一、t1 走索引,t2 走索引。

1

二、t1 不走索引,t2不走索引。(此種狀況,實測若把name改成惟一索引,則t1也會走索引)

2

三、t1 不走索引,t2走索引。

3

四、t1不走索引,t2不走索引。

4

我滴天,這結果看起來亂七八糟的,好像走不走索引,徹底看心情。

可是,咱們發現只有第一種狀況,即用主鍵索引字段匹配,且用 in 的狀況下,兩張表才都走索引。

這個究竟是不是規律呢?有待考察,且往下看。

2、接下來測試,主鍵索引和普通索引在 exists 和 not exists 下的狀況。sql以下,

explain select * from t1 where exists (select 1 from t2 where t1.id=t2.id);
explain select * from t1 where exists (select 1 from t2 where t1.name=t2.name);
explain select * from t1 where not exists (select 1 from t2 where t1.id=t2.id);
explain select * from t1 where not exists (select 1 from t2 where t1.name=t2.name);

這個結果就很是有規律了,且看,

有沒有發現, t1 表哪一種狀況都不會走索引,而 t2 表是有索引的狀況下就會走索引。爲何會出現這種狀況?

其實,上一小節說到了 exists 的執行流程,就已經說明問題了。

它是之外層表爲驅動表,不管如何都會循環遍歷的,因此會全表掃描。而內層表經過走索引,能夠快速判斷當前記錄是否匹配。

效率如何?

針對網上說的 exists 必定比 in 的執行效率高,咱們作一個測試。

分別在 t1,t2 中插入 100W,200W 條數據。

我這裏,用的是自定義函數來循環插入,語句參考以下,(沒有把表名抽離成變量,由於我沒有找到方法,尷尬)

-- 傳入須要插入數據的id開始值和數據量大小,函數返回結果爲最終插入的條數,此值正常應該等於數據量大小。
-- id自增,循環往 t1 表添加數據。這裏爲了方便,id、name取同一個變量,address就爲北京。
delimiter // 
drop function if exists insert_datas1//
create function insert_datas1(in_start int(11),in_len int(11)) returns int(11)
begin  
  declare cur_len int(11default 0;
  declare cur_id int(11);
  set cur_id = in_start;
 
  while cur_len < in_len do
     insert into t1 values(cur_id,cur_id,'北京');
  set cur_len = cur_len + 1;
  set cur_id = cur_id + 1;
  end while
  return cur_len;
end  
//
delimiter ;
-- 一樣的,往 t2 表插入數據
delimiter // 
drop function if exists insert_datas2//
create function insert_datas2(in_start int(11),in_len int(11)) returns int(11)
begin  
  declare cur_len int(11default 0;
  declare cur_id int(11);
  set cur_id = in_start;
 
  while cur_len < in_len do
     insert into t2 values(cur_id,cur_id,'北京');
  set cur_len = cur_len + 1;
  set cur_id = cur_id + 1;
  end while
  return cur_len;
end  
//
delimiter ;

在此以前,先清空表裏的數據,而後執行函數,

select insert_datas1(1,1000000);

對 t2 作一樣的處理,不過爲了兩張表數據有交叉,就從 70W 開始,而後插入 200W  數據。

select insert_datas2(700000,2000000);

在家裏的電腦,實際執行時間,分別爲 36s 和 74s。

不知爲什麼,家裏的電腦尚未在 Docker 虛擬機中跑的腳本快。。害,就這樣湊合着用吧。

等我有了新歡錢,就把它換掉,哼哼。

一樣的,把上邊的執行計劃都執行一遍,進行對比。我這裏就不貼圖了。

in 和 exists 孰快孰慢

爲了方便,主要拿如下這兩個 sql 來對比分析。

select * from t1 where id in (select id from t2);
select * from t1 where exists (select 1 from t2 where t1.id=t2.id);

執行結果顯示,兩個 sql 分別執行 1.3s  和 3.4s 。

注意此時,t1 表數據量爲 100W, t2 表數據量爲 200W 。

按照網上對 in 和 exists 區別的通俗說法,

若是查詢的兩個表大小至關,那麼用in和exists差異不大;若是兩個表中一個較小一個較大,則子查詢表大的用exists,子查詢表小的用in;

對應於此處就是:

  • 當 t1 爲小表, t2 爲大表時,應該用 exists ,這樣效率高。
  • 當 t1 爲大表,t2 爲小表時,應該用 in,這樣效率較高。

而我用實際數據測試,就把第一種說法給推翻了。由於很明顯,t1 是小表,可是 in 比 exists 的執行速度還快。

爲了繼續測驗它這個觀點,我把兩個表的內表外表關係調換一下,讓 t2 大表做爲外表,來對比查詢,

select * from t2 where id in (select id from t1);
select * from t2 where exists (select 1 from t1 where t1.id=t2.id);

執行結果顯示,兩個 sql 分別執行 1.8s 和 10.0s 。

是否是頗有意思。能夠發現,

  • 對於 in 來講,大表小表調換了內外層關係,執行時間並沒有太大區別。一個是 1.3s,一個是 1.8s。
  • 對於 exists 來講,大小表調換了內外層關係,執行時間天壤之別,一個是 3.4s ,一個是 10.0s,足足慢了兩倍。

1、以查詢優化器維度對比。

爲了探究這個結果的緣由。我去查看它們分別在查詢優化器中優化後的 sql 。

select * from t1 where id in (select id from t2); 爲例,順序執行如下兩個語句。

-- 此爲 5.7 寫法,若是是 5.6版本,須要用 explain extended ...
explain select * from t1 where id in (select id from t2);
-- 本意爲顯示警告信息。可是和 explain 一起使用,就會顯示出優化後的sql。須要注意使用順序。
show warnings;

在結果 Message 裏邊就會顯示咱們要的語句。

-- message 優化後的sql
select `test`.`t1`.`id` AS `id`,`test`.`t1`.`name` AS `name`,`test`.`t1`.`address` AS `address` from `test`.`t2` join `test`.`t1` where (`test`.`t2`.`id` = `test`.`t1`.`id`)

能夠發現,這裏它把 in 轉換爲了 join 來執行。

這裏沒有用 on,而用了 where,是由於當只有 join 時,後邊的 on 能夠用 where 來代替。即 join on 等價於 join where 。

PS: 這裏咱們也能夠發現,select * 最終會被轉化爲具體的字段,知道爲何咱們不建議用 select * 了吧。

一樣的,以 t2 大表爲外表的查詢狀況,也查看優化後的語句。

explain select * from t2 where id in (select id from t1);
show warnings;

咱們會發現,它也會轉化爲 join 的。

select `test`.`t2`.`id` AS `id`,`test`.`t2`.`name` AS `name`,`test`.`t2`.`address` AS `address` from `test`.`t1` join `test`.`t2` where (`test`.`t2`.`id` = `test`.`t1`.`id`)

這裏再也不貼 exists 的轉化 sql ,其實它沒有什麼大的變化。

2、以執行計劃維度對比。

咱們再以執行計劃維度來對比他們的區別。

explain select * from t1 where id in (select id from t2);
explain select * from t2 where id in (select id from t1);
explain select * from t1 where exists (select 1 from t2 where t1.id=t2.id);
explain select * from t2 where exists (select 1 from t1 where t1.id=t2.id);

執行結果分別爲,

1
2
3
4

能夠發現,對於 in 來講,大表 t2 作外表仍是內表,都會走索引的,小表 t1 作內表時也會走索引。看它們的 rows 一列也能夠看出來,前兩張圖結果同樣。

對於 exists 來講,當小表 t1 作外表時,t1 全表掃描,rows 近 100W;當 大表 t2 作外表時, t2 全表掃描,rows 近 200W 。這也是爲何 t2 作外表時,執行效率很是低的緣由。

由於對於 exists 來講,外表總會執行全表掃描的,固然表數據越少越好了。

最終結論: 外層大表內層小表,用in。外層小表內層大表,in和exists效率差很少(甚至 in 比 exists 還快,而並非網上說的 exists 比 in 效率高)。

not in 和 not exists 孰快孰慢

此外,實測對比 not in 和 not exists 。

explain select * from t1 where id not in (select id from t2);
explain select * from t1 where not exists (select 1 from t2 where t1.id=t2.id);
explain select * from t1 where name not in (select name from t2);
explain select * from t1 where not exists (select 1 from t2 where t1.name=t2.name);

explain select * from t2 where id not in (select id from t1);
explain select * from t2 where not exists (select 1 from t1 where t1.id=t2.id);
explain select * from t2 where name not in (select name from t1);
explain select * from t2 where not exists (select 1 from t1 where t1.name=t2.name);

小表作外表的狀況下。對於主鍵來講, not exists 比 not in 快。對於普通索引來講, not in 和 not exists 差不了多少,甚至 not in 會稍快。

大表作外表的狀況下,對於主鍵來講, not in 比 not exists 快。對於普通索引來講, not in 和 not exists 差不了多少,甚至 not in 會稍快。

感興趣的同窗,可自行嘗試。以上邊的兩個維度(查詢優化器和執行計劃)分別來對比一下。

join 的嵌套循環 (Nested-Loop Join)

爲了理解爲何這裏的 in 會轉換爲 join ,我感受有必要了解一下 join 的三種嵌套循環鏈接。

一、簡單嵌套循環鏈接,Simple Nested-Loop Join ,簡稱 SNLJ

join 便是 inner join ,內鏈接,它是一個笛卡爾積,即利用雙層循環遍歷兩張表。

咱們知道,通常在 sql 中都會以小表做爲驅動表。因此,對於 A,B 兩張表,若A的結果集較少,則把它放在外層循環,做爲驅動表。天然,B 就在內層循環,做爲被驅動表。

簡單嵌套循環,就是最簡單的一種狀況,沒有作任何優化。

所以,複雜度也是最高的,O(mn)。僞代碼以下,

for(id1 in A){
    for(id2 in B){
        if(id1==id2){
            result.add();
        }
    }
}

二、索引嵌套循環鏈接,Index Nested-Loop Join ,簡稱 INLJ

看名字也能看出來了,這是經過索引進行匹配的。外層表直接和內層表的索引進行匹配,這樣就不須要遍歷整個內層表了。利用索引,減小了外層表和內層表的匹配次數。

因此,此種狀況要求內層表的列要有索引。

僞代碼以下,

for(id1 in A){
    if(id1 matched B.id){
        result.add();
    }
}

三、塊索引嵌套鏈接,Block Nested-Loop Join  ,簡稱 BNLJ

塊索引嵌套鏈接,是經過緩存外層表的數據到 join buffer 中,而後 buffer 中的數據批量和內層表數據進行匹配,從而減小內層循環的次數。

之外層循環100次爲例,正常狀況下須要在內層循環讀取外層數據100次。若是以每10條數據存入緩存buffer中,並傳遞給內層循環,則內層循環只須要讀取10次(100/10)就能夠了。這樣就下降了內層循環的讀取次數。

MySQL 官方文檔也有相關說明,能夠參考:https://dev.mysql.com/doc/refman/5.7/en/nested-loop-joins.html#block-nested-loop-join-algorithm

因此,這裏轉化爲 join,能夠用到索引嵌套循環鏈接,從而提升了執行效率。

聲明: 以上是以個人測試數據爲準,測出來的結果。實際真實數據和測試結果頗有可能會不太同樣。若是有不一樣意見,歡迎留言討論。



掃描二維碼

獲取更多精彩

煙雨星空


本文分享自微信公衆號 - 煙雨星空(mistyskys)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索