还记得我在《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
咱们先来看下上面这条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
那么,如今有了表结构和数据,结合我在《为何MySQL可以支撑千万数据规模的快速查询?》中讲解的索引查找过程,咱们看下MySQL是如何执行《导读》中的子查询的?
咱们关注图中红线部分:
根据条件viewed_user_id=10008
,查找表t_user_view
中的索引树index_viewed_user_user
,定位到知足条件的叶子节点,即图中最左边树中的绿色节点。遍历节点内知足条件的记录,获得结果,即图中从左向右第二部分。
顺序扫描上一步的查询结果记录,图中第二部分向下的箭头。
2.1 根据知足条件的记录10008,10001
,到表user
中的索引树index_user_id
查找user_id=10001
的记录,定位到叶子节点,即图中第三部分索引树右下角橘黄色节点。获得节点内知足user_id=10001
的记录10001
。同理,能够获得知足user_id=10002
的记录10002
。分别对应图中最右边的10001
和10002
。
2.2 根据知足条件的记录10008,100013
,到表user
中的索引树index_user_id
查找user_id=10003
的记录,定位到叶子节点,即图中第三部分索引树下方中间的橘黄色节点。获得节点内知足user_id=10003
的记录10003
。同理,能够获得知足user_id=10005
的记录10005
和知足user_id=10009
的记录10009
。分别对应图中最右边的10003
、10005
和10009
。
结合《告诉面试官,我能优化groupBy,并且知道得很深!》中groupBy原理,对上一步知足条件的记录执行groupBy和count查询。
其中,第2步扫描的过程,MySQL把它叫作LooseScan: 松散扫描
。为何叫松散扫描呢?假设上图第二部分,咱们获得的记录有重复值,MySQL是如何扫描的呢?咱们来看一下:
一样关注图中红线部分:
顺序扫描知足条件的记录,图中向下的箭头。
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又想出了其余策略来优化子查询。
好比:下面这个案例:
假设如今风控团队但愿找出平台上在某个时间点访问单个用户主页总次数大于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。
关注图中红线部分:
根据内层语句查询条件view_count>=10000
,查找t_user_view
表索引树index_vc_user
,定位到叶子节点,即图中左边树中绿色的节点。同时,找到了该节点内知足条件的第一条记录10000,10002
。
扫描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又想出了下面这种策略尝试扫描内层表索引。
仍是以《FirstMatch》中的案例SQL为例,咱们来看看这个执行策略:
关注图中红线部分:
根据内层查询条件view_count>=10000
,查找索引树index_vc_user
,定位到知足条件的节点,即图中左边树的绿色节点。同时,找到节点内知足条件的第一条记录10000,10002
。
新建临时表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
,因此,该插入失败
扫描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
这条记录。
回user
表查找第3步中找到的3条记录10002
、10009
和10005
对应的用户信息。
上面的过程当中,新建立的tmp_table
,因为其包含了一个惟一索引,保证了其插入记录的惟一性,对索引index_vc_user
起到了去重的做用,而后,经过扫描tmp_table
,逐条记录去查找index_user_id
索引。
MySQL把新建临时表去重,而后,扫描临时表(或临时表索引),以后用临时表记录逐条匹配外层表记录,这样一种方式叫作MaterializeScan,其中,新建的
tmp_table
叫作物化表。
仔细看上述过程当中的第3步,因为tmp_table
中的每一条记录都须要从索引树index_user_id
的根节点搜索,这个搜索路径是否是有点重复,因此,MySQL发现其实有不去重复走这个搜索路径的方法,因而,就产生了新的策略来优化《FirstMatch》中的案例SQL。
咱们来看一下这个策略:
关注图中红线部分:
根据内层查询条件view_count>=10000
,查找索引树index_vc_user
,定位到知足条件的节点,即图中左边树的绿色节点。同时,找到节点内知足条件的第一条记录10000,10002
。
新建临时表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
,因此,该插入失败
扫描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
指出的打叉的红色箭头。
至此,从user
表中找出了案例中查询语句的记录1,10002,...,2008-02-03
、3,10009,...,2002-06-07
和5,10005,...,2008-02-06
。
在上面这个过程当中,咱们发现MySQL直接用user
表的记录去匹配tmp_table
中的记录,没有走索引查找,所以,查询效率相比上面MaterializeScan的方式快一些。
MySQL把这种新建临时表,经过扫描子查询语句外层表,逐条记录匹配临时表记录的方式叫作MaterializeLookup。
上面讲了4种子查询执行策略,你会发现它们都有共同点:不管是语句外层表仍是内层表,只要有索引,就能够借助索引提高查询的效率。那么,若是内外表都没有索引,MySQL又是怎么执行子查询的呢?这里又引出了一种新策略,我仍是以《FirstMatch》中的案例SQL为例,咱们来看一下。
关注图中红线部分:
使用user
表的row_id,新建临时表,即该表中只有一个字段row_id,且惟一。即图中最左边的部分。
全表扫描t_user_view
,查找知足条件view_count>=10000
的记录,找到5条记录。即图中从左向右第二部分灰色的方框,其中,省略了部分记录。
将第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.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
必须惟一,插入失败。
最终获得了子查询的结果: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
从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查询了?