1. 背景
在之前介紹MySQL執行計划的博文中已經談及了一些關於子查詢相關的執行計划與優化。本文將重點介紹MySQL中與子查詢相關的內容,設計子查詢優化策略,包含半連接子查詢的優化與非半連接子查詢的優化。其中非半連接子查詢優化由於策略較少不作詳細展開。
2. 子查詢概念
子查詢簡單理解就是在sql中嵌套了select查詢子句。子查詢的優點在於它的可讀性會比較高(相比寫join和union)。子查詢根據查詢結果的形式可以大致分為標量子查詢、列子查詢、行子查詢、表子查詢等。根據與外部語句的關聯性,也可以分為相關子查詢和獨立子查詢。
有一個謬誤是所有子查詢能做的事情,用連接也能做。
舉例,如下在where中嵌套一個其他表最大值子查詢來等值比較的子查詢就沒法用join做到(不要鑽牛角尖硬套join)
select x from a where x = (select max(y) from b);
3. 子查詢的效率
很多人都會關心子查詢效率,是否子查詢真的就很慢?這個問題很難回答。但在MySQL低版本(5.5以下)對子查詢的支持確實不是太好。在《高性能MySQL》一書中的6.5.1(關聯子查詢)章節中也提及了MySQL對於where子句中帶有in子查詢的處理,就是用exists轉寫為關聯子查詢,這樣的話查詢復雜度非常高,性能很差。所以在這種情況下,建議把子查詢改寫為關聯查詢並保證從表的連接字段上有索引。
4. 子查詢的優化
在MySQL 5.6及更高版本,有了更多的子查詢優化策略,具體如下:
- 對於in或者=any子查詢:
- Semi-Join(半連接)
- Materialization(物化)
- EXISTS策略
- 對於not in或者<>any子查詢:
- Materialization(物化)
- EXISTS策略
- 對於派生表(from子句中的子查詢):
- 把派生表結果與外部查詢塊合並
- 把派生表物化為臨時表
4.1 半連接優化
只有滿足如下條件的子查詢,MySQL可以應用半連接優化。
- in或者=any出現在where或者on的頂層
- 子查詢為不含union的單一select
- 子查詢不含有group和having
- 子查詢不能帶有聚合函數
- 子查詢不能帶有order by... limit
- 子查詢不能帶有straight_join查詢提示
- 外層表和內層表的總數不能超過join允許最大的表數量
如果滿足如上條件,MySQL會基於成本代價從如下幾種優化策略選擇一個來進行半連接子查詢優化
4.1.1 Table pullout優化
Table pullout優化將半連接子查詢的表提取到外部查詢,將查詢改寫為join。這與人們在使用低版本MySQL時的常見的手工調優策略很相似。
下面來舉例看看:
首先建立兩張表,一張為stu(學生表),一張為stu_cls(學生班級關聯表)
create table stu (id int primary key auto_increment);
create table stu_cls(s_id int, c_id int, unique key(s_id));
往stu表里初始化一些數據
insert into stu select null;
insert into stu select null from stu;
insert into stu select null from stu;
insert into stu select null from stu;
insert into stu select null from stu;
insert into stu select null from stu;
insert into stu select null from stu;
insert into stu select null from stu;
insert into stu select null from stu;
insert into stu select null from stu;
往stu_cls表中初始化一些數據
insert into stu_cls select 1,1;
insert into stu_cls select 2,1;
insert into stu_cls select 3,2;
insert into stu_cls select 4,3;
insert into stu_cls select 5,2;
insert into stu_cls select 6,2;
insert into stu_cls select 7,4;
insert into stu_cls select 8,2;
insert into stu_cls select 9,1;
insert into stu_cls select 10,3;
在MySQL5.5的環境下,當查詢下面的執行計划時,顯示的結果很可能是這樣的
mysql> explain extended select * from stu where id in (select s_id from stu_cls)\G
*************************** 1. row ***************************
id: 1
select_type: PRIMARY
table: stu
type: index
possible_keys: NULL
key: PRIMARY
key_len: 4
ref: NULL
rows: 512
filtered: 100.00
Extra: Using where; Using index
*************************** 2. row ***************************
id: 2
select_type: DEPENDENT SUBQUERY
table: stu_cls
type: index_subquery
possible_keys: s_id
key: s_id
key_len: 5
ref: func
rows: 1
filtered: 100.00
Extra: Using index; Using where
2 rows in set, 1 warning (0.01 sec)
可以看到stu進行了一次索引全掃描(因為我們的試驗表結構比較簡單,stu表里就一個主鍵id),子查詢的類型為相關子查詢。近一步查看MySQL改寫過的SQL。
mysql> show warnings\G
*************************** 1. row ***************************
Level: Note
Code: 1003
Message: select `test`.`stu`.`id` AS `id` from `test`.`stu` where <in_optimizer>(`test`.`stu`.`id`,<exists>(<index_lookup>(<cache>(`test`.`stu`.`id`) in stu_cls on s_id where (<cache>(`test`.`stu`.`id`) = `test`.`stu_cls`.`s_id`))))
1 row in set (0.00 sec)
可以看到在MySQL5.5中采用了EXISTS策略將in子句轉寫為了exists子句。
在MySQL5.7的環境下,上面的執行計划如下所示
mysql> explain select * from stu where id in (select s_id from stu_cls)\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: stu_cls
partitions: NULL
type: index
possible_keys: s_id
key: s_id
key_len: 5
ref: NULL
rows: 10
filtered: 100.00
Extra: Using where; Using index
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: stu
partitions: NULL
type: eq_ref
possible_keys: PRIMARY
key: PRIMARY
key_len: 4
ref: test.stu_cls.s_id
rows: 1
filtered: 100.00
Extra: Using index
2 rows in set, 1 warning (0.00 sec)
可以看到執行計划中的兩行id是一樣的,從rows列的信息可以看到,預估掃描記錄數僅僅為之前的2%;查看改寫過的SQL如下
mysql> show warnings\G
*************************** 1. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select `test`.`stu`.`id` AS `id` from `test`.`stu_cls` join `test`.`stu` where (`test`.`stu`.`id` = `test`.`stu_cls`.`s_id`)
1 row in set (0.00 sec)
可以看到原來的子查詢已經被改寫為join了。
值得一提的是,Table pullout優化沒有單獨的優化器開關。如果想要禁用Table pullout優化,則需要禁用semijoin優化。如下所示:
mysql> set optimizer_switch='semijoin=off';
Query OK, 0 rows affected (0.00 sec)
mysql> explain select * from stu where id in (select s_id from stu_cls)\G
*************************** 1. row ***************************
id: 1
select_type: PRIMARY
table: stu
partitions: NULL
type: index
possible_keys: NULL
key: PRIMARY
key_len: 4
ref: NULL
rows: 512
filtered: 100.00
Extra: Using where; Using index
*************************** 2. row ***************************
id: 2
select_type: SUBQUERY
table: stu_cls
partitions: NULL
type: index
possible_keys: s_id
key: s_id
key_len: 5
ref: NULL
rows: 10
filtered: 100.00
Extra: Using index
2 rows in set, 1 warning (0.00 sec)
mysql> show warnings\G
*************************** 1. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select `test`.`stu`.`id` AS `id` from `test`.`stu` where <in_optimizer>(`test`.`stu`.`id`,`test`.`stu`.`id` in ( <materialize> (/* select#2 */ select `test`.`stu_cls`.`s_id` from `test`.`stu_cls` where 1 ), <primary_index_lookup>(`test`.`stu`.`id` in <temporary table> on <auto_key> where ((`test`.`stu`.`id` = `materialized-subquery`.`s_id`)))))
1 row in set (0.00 sec)
可以看到當禁用semijoin優化后,MySQL5.7會采用非半連接子查詢的物化策略來優化來處理此查詢。
4.1.2 FirstMatch優化
下面來講講FirstMatch優化,這也是在處理半連接子查詢時可能會用到的一種優化策略。
下面展示了一個構造FirstMatch的小demo例子:
create table department (id int primary key auto_increment);
create table employee (id int primary key auto_increment, dep_id int, key(dep_id));
mysql> explain select * from department where id in (select dep_id from employee)\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: department
partitions: NULL
type: index
possible_keys: PRIMARY
key: PRIMARY
key_len: 4
ref: NULL
rows: 1
filtered: 100.00
Extra: Using index
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: employee
partitions: NULL
type: ref
possible_keys: dep_id
key: dep_id
key_len: 5
ref: test.department.id
rows: 1
filtered: 100.00
Extra: Using index; FirstMatch(department)
2 rows in set, 1 warning (0.00 sec)
mysql> show warnings\G
*************************** 1. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select `test`.`department`.`id` AS `id` from `test`.`department` semi join (`test`.`employee`) where (`test`.`employee`.`dep_id` = `test`.`department`.`id`)
1 row in set (0.00 sec)
我們可以看到上面查詢計划中,兩個id都為1,且extra中列可以看到FirstMatch(department)
。MySQL使用了連接來處理此查詢,對於department表的行,只要能在employee表中找到1條滿足即可以不必再檢索employee表。從語義角度來看,和IN-to-EXIST策略轉換為Exist子句是相似的,區別就是FirstMath以連接形式執行查詢,而不是子查詢。
4.1.3 Semi-join Materialization優化
Semi-join Materialization優化策略是將半連接的子查詢物化為臨時表。
我們可以將上面FirstMatch優化策略中的例子進行改造以構造Materialization優化的例子。
mysql> alter table employee drop index dep_id;
Query OK, 0 rows affected (0.10 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> explain select * from department where id in (select dep_id from employee)\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: <subquery2>
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: NULL
filtered: 100.00
Extra: Using where
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: department
partitions: NULL
type: eq_ref
possible_keys: PRIMARY
key: PRIMARY
key_len: 4
ref: <subquery2>.dep_id
rows: 1
filtered: 100.00
Extra: Using index
*************************** 3. row ***************************
id: 2
select_type: MATERIALIZED
table: employee
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 1
filtered: 100.00
Extra: NULL
3 rows in set, 1 warning (0.00 sec)
mysql> show warnings\G
*************************** 1. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select `test`.`department`.`id` AS `id` from `test`.`department` semi join (`test`.`employee`) where (`test`.`department`.`id` = `<subquery2>`.`dep_id`)
1 row in set (0.00 sec)
上述操作,先是drop掉了employee表上的dep_id索引,然后查看相同的執行計划。可以看到查詢計划返回了三段結果,其中前兩個id為1,最后一個id為2,且select_type為MATERIALIZED。這說明MySQL對於此查詢會創建臨時表,會將employee表物化為臨時表
使用該優化策略需要打開優化器的semijoin和materialization開關。
4.1.4 Duplicate Weedout優化
這種優化策略就是將半連接子查詢轉換為INNER JOIN,再刪除重復重復記錄。
weed out有清除雜草的含義,所謂Duplicate Weedout優化策略就是先連接,得到有重復的結果集,再從中刪除重復記錄,好比是清理雜草。
由於構造Duplicate Weedout例子比較困難,我們不妨這里顯式關閉半連接其他幾個優化策略:
set optimizer_switch = 'materialization=off';
set optimizer_switch = 'firstmatch=off';
set optimizer_switch = 'loosescan=off';
以上面Semi-join Materialization優化中的表繼續查詢相同的執行計划:
mysql> explain select * from department where id in (select dep_id from employee)\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: employee
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 1
filtered: 100.00
Extra: Using where; Start temporary
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: department
partitions: NULL
type: eq_ref
possible_keys: PRIMARY
key: PRIMARY
key_len: 4
ref: test.employee.dep_id
rows: 1
filtered: 100.00
Extra: Using index; End temporary
2 rows in set, 1 warning (0.00 sec)
mysql> show warnings\G
*************************** 1. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select `test`.`department`.`id` AS `id` from `test`.`department` semi join (`test`.`employee`) where (`test`.`department`.`id` = `test`.`employee`.`dep_id`)
1 row in set (0.00 sec)
可以看到兩段結果中Extra中分別有Start temporary和End temporary,這正是Duplicate Weedout過程中創建臨時表,保存中間結果到臨時表,從臨時表中刪除重復記錄的過程。
4.1.5 LooseScan優化
LooseScan(松散索引掃描)和group by優化中的LooseScan差不多。
它采用松散索引掃描讀取子查詢的表,然后作為驅動表,外部表作為被驅動表進行連接。
它可以優化如下形式的子查詢
select ... from ... where expr in (select key_part1 from ... where ...);
select ... from ... where expr in (select key_part2 from ... where key_part1=常量);
我們新建一張employee_department關聯表
create table employee_department(id int primary key auto_increment,d_id int,e_id int,unique key(e_id,d_id));
往employee和新建的employee_department關聯表中填充一些數據(填充語句省略)
查詢如下執行計划:
mysql> explain select * from employee where id in (select e_id from employee_department)\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: employee_department
partitions: NULL
type: index
possible_keys: e_id
key: e_id
key_len: 10
ref: NULL
rows: 4
filtered: 100.00
Extra: Using where; Using index; LooseScan
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: employee
partitions: NULL
type: eq_ref
possible_keys: PRIMARY
key: PRIMARY
key_len: 4
ref: test.employee_department.e_id
rows: 1
filtered: 100.00
Extra: NULL
2 rows in set, 1 warning (0.00 sec)
mysql> show warnings\G
*************************** 1. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select `test`.`employee`.`id` AS `id`,`test`.`employee`.`dep_id` AS `dep_id` from `test`.`employee` semi join (`test`.`employee_department`) where (`test`.`employee`.`id` = `test`.`employee_departm
可以看到返回的結果中第一段的extra中帶有LooseScan,且兩段結果的id也都為1,也即采用了半連接查詢優化中的松散索引掃描。
此查詢MySQL會以employee_department來作為驅動表,讀取employee_department的唯一索引,連接employee得到最終結果集。
LooseScan對應的優化器開關為loosecan
4.2 非半連接優化
以上部分介紹了基於半連接的子查詢優化,對於如下一些非半連接子查詢,則無法使用上面的幾種優化方式了:
in與or混在一起
select *
from xxx
where expr1 in (select ...) or expr2;
用了not in
select *
from xxx
where expr not in (select ...);
select中套了子查詢
select ...,(select ...)
from xxx;
having里套了子查詢
select ...
from xxx
where expr
having (select ...);
子查詢里用了union
select ...
form
where expr in (select ... union select ...)
這種非半連接子查詢優化比較少,主要就是Materialization(物化)和IN-to-EXISTS(in轉exist子句),這里不作過多介紹。
5. 參考
MySQL官方手冊
《深入理解MariaDB與MySQL》