MySQL深入30-join深入
# MySQL深入30-join使用
关于join语句使用的问题,一般有如下两类:
- 使用 join 有什么问题呢?
- 两个大小不同的表做join,应该用哪个表做驱动表呢?
# Index Nested-Loop Join
CREATE TABLE `t1` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `a` (`a`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `t2` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `a` (`a`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
创建两张表,表中a字段有索引,b字段没有索引。
select * from t1 straight_join t2 on (t1.a=t2.a);
直接使用join语句,MySQL优化器可能会选择t1 或 t2 作为驱动表,为了便于分析执行过程中的性能问题,采用straight_join 让 MySQL 使用固定的连接方式执行查询,这样优化器只会按照我们指定的方式去 join。在这个语句里,t1 是驱动表,t2 是被驱动表。
EXPLAIN select * from t1 straight_join t2 on (t1.a=t2.a);
可以看到被驱动表t2的字段a上的索引在join过程中被使用了,因此这条语句执行流程如下:
- 从表t1中读入一行数据R
- 从数据行R中,取出a字段到表t2里去查找
- 取出表 t2 中满足条件的行,跟 R 组成一行,作为结果集的一部分;
- 重复执行步骤 1 到 3,直到表 t1 的末尾循环结束。
这个过程相当于是先遍历驱动表,然后根据驱动表取出的每行数据中a的值,去被驱动表中查找满足条件的记录,执行流程如下:
问题一:能不能用join
如果我们不使用join,对于上面这条sql语句,采用单表查询的方式去实现:
- 执行select * from t1,查出表 t1 的所有数据,这里有 100 行;
- 循环遍历这 100 行数据:
- 从每一行 R 取出字段 a 的值 $R.a;
- 执行select * from t2 where a=$R.a;
- 把返回的结果和 R 构成结果集的一行。
可以看到,这个查询,明显多了更多次于数据库的交互,相比join的一次交互性能肯定不太好。
问题二:如何选择驱动表
在该join 语句执行过程中,驱动表是走全表扫描,而被驱动表是走树搜索。
假设被驱动表的行数是 M。每次在被驱动表查一行数据,要先搜索索引 a,再搜索主键索引。每次搜索一棵树近似复杂度是以 2 为底的 M 的对数,记为 log2M,所以在被驱动表上查一行的时间复杂度是 2*log2M。
假设驱动表的行数是 N,执行过程就要扫描驱动表 N 行,然后对于每一行,到被驱动表上匹配一次。
因此整个执行过程,近似复杂度是 N + N2log2M。
显然,N 对扫描行数的影响更大,因此应该让小表来做驱动表。
综上,在可以使用被驱动表的索引有如下结论:
- 使用 join 语句,性能比强行拆成多个单表执行 SQL 语句的性能要好;
- 如果使用 join 语句的话,需要让小表做驱动表。
# Simple Nested-Loop Join
现在SQL语句如下:
select * from t1 straight_join t2 on (t1.a=t2.b);
由于表 t2 的字段 b 上没有索引,因此再用图 2 的执行流程时,每次到 t2 去匹配的时候,就要做一次全表扫描。
如果t1中有100条数据,t2中有1000条数据,那么需要扫描的行为100*1000=10 万行。如果两个表是大表,就会显得这个查询非常笨重。
MySQL 也没有使用这个 Simple Nested-Loop Join 算法,而是使用了另一个叫作“Block Nested-Loop Join”的算法,简称 BNL。
# Block Nested-Loop Join
被驱动表上没有可用的索引,算法的流程如下:
- 把表 t1 的数据读入线程内存 join_buffer 中,由于我们这个语句中写的是 select *,因此是把整个表 t1 放入了内存;
- 扫描表 t2,把表 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回。
EXPLAIN select * from t1 straight_join t2 on (t1.a=t2.b);
可以看到,在这个过程中,对表 t1 和 t2 都做了一次全表扫描,因此总的扫描行数是 1100。由于 join_buffer 是以无序数组的方式组织的,因此对表 t2 中的每一行,都要做 100 次判断,总共需要在内存中做的判断次数是:100*1000=10 万次。
相比于Simple Nested-Loop Join,两者算法时间复杂度差不多,但是Block Nested-Loop Join的10万次判断是内存操作,速度更快,性能更好。
再来分析一下如何选择驱动表:
假设小表的行数是 N,大表的行数是 M,那么在这个算法里:
- 两个表都做一次全表扫描,所以总的扫描行数是 M+N;
- 内存中判断的次数是M*N
可以看到M和N互换不影响最终扫描和判断的次数,因此选择大表还是小表区别不大。
但是如果我扫描t1是一个大表,join_buffer放不下这么办呢?
join_buffer 的大小是由参数 join_buffer_size 设定的,默认值是 256k。如果放不下表 t1 的所有数据话,策略很简单,就是分段放。现在把join_buffer_size改小,再执行:
select * from t1 straight_join t2 on (t1.a=t2.b);
上诉SQL语句执行流程如下:
- 扫描表 t1,顺序读取数据行放入 join_buffer 中,放完第 88 行 join_buffer 满了,继续第 2 步;
- 扫描表 t2,把 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回;
- 清空 join_buffer;
- 继续扫描表 t1,顺序读取最后的 12 行数据放入 join_buffer 中,继续执行第 2 步。
可以看到,这时候由于表 t1 被分成了两次放入 join_buffer 中,导致表 t2 会被扫描两次。虽然分成两次放入 join_buffer,但是判断等值条件的次数还是不变的,依然是 (88+12)*1000=10 万次。
再来分析一下驱动表选择问题:
假设,驱动表的数据行数是 N,需要分 K 段才能完成算法流程,被驱动表的数据行数是 M。
注意,这里的 K 不是常数,N 越大 K 就会越大,因此把 K 表示为λ*N,显然λ的取值范围是 (0,1)。
扫描行数是 N+λ*N*M;
内存判断 N*M 次。
显然,内存判断次数是不受选择哪个表作为驱动表影响的。而考虑到扫描行数,在 M 和 N 大小确定的情况下,N 小一些,整个算式的结果会更小。所以结论是,应该让小表当驱动表。
join语句的使用
- 如果可以使用 Index Nested-Loop Join 算法,也就是说可以用上被驱动表上的索引,其实是没问题的;
- 如果使用 Block Nested-Loop Join 算法,扫描行数就会过多。尤其是在大表上的 join 操作,这样可能要扫描被驱动表很多次,会占用大量的系统资源。所以这种 join 尽量不要用。
所以判断要不要使用 join 语句时,就是看 explain 结果里面,Extra 字段里面有没有出现“Block Nested Loop”字样。
驱动表的选择
- 如果是 Index Nested-Loop Join 算法,应该选择小表做驱动表;
- 如果是 Block Nested-Loop Join 算法:
- 在 join_buffer_size 足够大的时候,是一样的;
- 在 join_buffer_size 不够大的时候(这种情况更常见),应该选择小表做驱动表。
在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与 join 的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表。
# 小结
- 如果可以使用被驱动表的索引,join 语句还是有其优势的;
- 不能使用被驱动表的索引,只能使用 Block Nested-Loop Join 算法,这样的语句就尽量不要使用;
- 在使用 join 的时候,应该让小表做驱动表。