MySQL子查询(IN)碰到的问题,深入分析

487 查看

在项目中碰到一个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代替它”,应该理解为“尽力避免嵌套子查询,使用索引来优化它们”