在项目中碰到一个SQL的慢查询,查阅之后发现是因为SQL中使用了IN子查询,也许大部分有开发经验的人都会语重心长的告诉你“千万别用IN,使用JOIN或者EXISTS代替它”。好吧,我承认我不喜欢这句话,因为任何事物都有它存在的理由,所以今天来探讨一下IN关于子查询的问题
问题
首先定义一下表结构,假如现在有3张表employee,user,user_dept它们分别是雇员表,用户表,用户部门关系表
CREATE TABLE `employee` (
`id` int(20) NOT NULL AUTO_INCREMENT
`user_id` varchar(50) DEFAULT NULL,
`dept_id` varchar(50) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ID` (`id`) USING HASH
) ENGINE=InnoDB
CREATE TABLE `user_dept` (
`id` int(22) NOT NULL AUTO_INCREMENT,
`user_id` varchar(50) DEFAULT NULL,
`dept_id` varchar(50) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ID` (`id`) USING HASH
) ENGINE=InnoDB
CREATE TABLE `user` (
`id` varchar(50) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
UNIQUE KEY `ID` (`id`)
) ENGINE=InnoDB
现在需要查询:某一个特定用户所在部门下的所有人(听起来有点拗口)
首先我们尝试使用含有IN的子查询SQL语句
select a.* from employee a, user b where a.user_id = b.id and a.dept_id IN (select dept_id from user_dept where user_id = 'specific user_id')
运行SQL后发现时间是6.783s(真实的employee表有5000多条记录,user表有3000多条记录, user_dept表有1000多条记录)
数据量并不多的情况下竟然如此耗时,看来IN中嵌套子查询确实不是什么好主意,观察一下该SQL的日志发现Handler_read_rnd_next = 6677791,这意味着MYSQL在处理过程中扫描(遍历)表多达百万级别,怪不得运行的时候会如此的长
为了找出原因我尝试将子查询分开分别执行2次SQL
select a.* from employee a, user b where a.user_id = b.id
select dept_id from user_dept where user_id = 'specific user_id'
运行结果都是在毫秒级别,并且第一个sql的Handler_read_rnd_next = 5179,第二个sql的Handler_read_rnd_next = 1280
细心的人会发现1280 乘以 5179 约等于 6677791, 这是不是意味着加入IN中含有子查询,外围的查询每遍历一次都需要在重复执行子查询中的语句,也就是说IN中含有子查询的算法复杂度为
$$O(M * N) 其中M为外围查询的时间,N为子查询的时间$$
接下来我尝试使用JOIN语句来执行SQL
select a.* from employee a, user b, user_dept c where a.user_id = b.id and a.dept_id = c.dept_id and a.user_id = 'specific user_id'
执行时间在毫秒级别,并且Handler_read_rnd_next = 6459,然后我发现当表做JOIN查询时候,遍历表的总数 约等于 表中记录总数的总和,算法复杂度为$$O(M + N) 其中M,N为关联表的总记录数$$
但是令人疑惑的地方出现了为何在IN中使用独立子查询(和外围查询没有任何关联)的算法复杂度变成$$O(M * N)$$难道MYSQL不应该是先将独立子查询只运行一次,然后在由外围查询当条件使用这样的效率最高吗?伪代码如下:
// 根据上面使用IN子查询的SQL来编写伪代码,使用最简单的Nested-Loop Join来实现
// 1.执行一次子查询,获取到子查询的结果集合
Set subSet = subquery();
// 2.遍历外围查询
for(employee e : employeeList) {
for(user u : userList) {
// 根据条件过滤数据,这步相对于使用IN来判断
if(subSet.contains(e.dept_id) && e.user_id == u.user_id) {
// 输出数据
sys.out();
}
}
}
理想中的算法复杂度应该是$$O(S1 + N M C)$$
其中S1为子查询的算法复杂度, N为employee表的数量,M为user表的数量,C为子查询结果集的大小,一般为常数
复杂度为$$O(N^2)$$
如果user表中的user_id有做索引的话,其算法复杂度为:$$O(N)$$
这已经是相当快的速度了,为了验证我的想法,我使用EXPLAIN命令查看MYSQL的执行计划,结果很意外
子查询的select_type竟然是DEPENDENT SUBQUERY(相关子查询),但是很显然我们的SQL子查询中并没有和外围查询有关联的条件,难道MYSQL做了什么特殊的优化?为了考清楚这个问题,我尝试在MYSQL官方手册寻找答案,结果查找到一篇文章Optimizing Subqueries with EXISTS Strategy
其中有这么一段:
outer_expr IN (SELECT inner_expr FROM ... WHERE subquery_where)
MySQL evaluates queries “from outside to inside.” That is, it first obtains the value of the outer expression outer_expr, and then runs the subquery and captures the rows that it produces.
A very useful optimization is to “inform” the subquery that the only rows of interest are those where the inner expression inner_expr is equal to outer_expr. This is done by pushing down an appropriate equality into the subquery's WHERE clause. That is, the comparison is converted to this:
EXISTS (SELECT 1 FROM ... WHERE subquery_where AND outer_expr=inner_expr)
大概意思就是说当MYSQL碰到IN子查询时MYSQL的优化器会将IN子查询转化为EXISTS相关子查询
所以我猜想MYSQL应该是在执行的时候把我们上面的SQL改成了
select a.* from employee a, user b where a.user_id = b.id and EXISTS(select 1 from user_dept c where c.user_id = 'specific user_id' and a.dept_id = c.dept_id)
使用EXPLAIN命令查看执行计划发现和使用IN的使用完全一样
Oop~为什么MYSQL要做如此“画蛇添足”的事呢?
for(employee e : employeeList) {
for(user u : userList) {
for(user_dept ud : user_deptList) {
if(ud.dept_id == a.dept_id && e.user_id == u.user_id) {
// 输出数据
sys.out();
}
}
}
}
算法复杂度为$$O(N^3)$$
结束语
关于MYSQL使用IN子查询的问题就暂时告已段落了,由于本人水平有限,不能再更深入的研究下去,关于为什么MYSQL会将IN中的子查询转化为EXISTS中的相关子查询,如果有哪位高手知晓原因请告知
对于那句“千万别用IN,使用JOIN或者EXISTS代替它”,应该理解为“尽力避免嵌套子查询,使用索引来优化它们”