在Mysql的实现中,Nested-Loop Join有3种实现的算法:
- Simple Nested-Loop Join:SNLJ,简单嵌套循环连接
- Index Nested-Loop Join:INLJ,索引嵌套循环连接
- Block Nested-Loop Join:BNLJ,缓存块嵌套循环连接
在选择Join算法时,会有优先级,理论上会优先判断能否使用INLJ、BNLJ: Index Nested-LoopJoin > Block Nested-Loop Join > Simple Nested-Loop Join
本文围绕连个问题展开:
- DBA不让使用join,使用join有什么问题?
- 不同的大小的表,如何选择驱动表?
创建表t1和t2:
代码语言:javascript复制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;
drop procedure idata;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=1000)do
insert into t2 values(i, i, i);
set i=i 1;
end while;
end;;
delimiter ;
call idata();
create table t1 like t2;
insert into t1 (select * from t2 where id<=100)
这两个表都有一个主键索引 id 和一个索引 a,字段 b 上无索引。存储过程 idata() 往表 t2 里插入了 1000 行数据,在表 t1 里插入的是 100 行数据。
Index Nested-Loop Join
代码语言:javascript复制select * from t1 straight_join t2 on (t1.a=t2.a);
如果直接使用 join 语句,MySQL 优化器可能会选择表 t1 或 t2 作为驱动表,这样会影响我们分析 SQL 语句的执行过程。所以,为了便于分析执行过程中的性能问题,我改用 straight_join 让 MySQL 使用固定的连接方式执行查询,这样优化器只会按照我们指定的方式去 join。在这个语句里,t1 是驱动表,t2 是被驱动表。
explain的结果:
在这条语句里,被驱动表 t2 的字段 a 上有索引,join 过程用上了这个索引。
该语句的执行流程是这样的:
- 从表 t1 中读入一行数据 R;
- 从数据行 R 中,取出 a 字段到表 t2 里去查找;
- 取出表 t2 中满足条件的行,跟 R 组成一行,作为结果集的一部分;
- 重复执行步骤 1 到 3,直到表 t1 的末尾循环结束。
这个过程是先遍历表 t1,然后根据从表 t1 中取出的每行数据中的 a 值,去表 t2 中查找满足条件的记录。在形式上,这个过程就跟我们写程序时的嵌套查询类似(也有点像嵌套的for循环),并且可以用上被驱动表的索引,所以我们称之为“Index Nested-Loop Join”,简称 NLJ。
NLJ执行流程图:
在这个流程里:
- 对驱动表 t1 做了全表扫描,这个过程需要扫描 100 行;
- 以上扫描出来的每一行R,根据a字段去表t2查询,走的是树搜索过程(唯一索引),因此每次搜索的过程只扫描一行,也是总共扫描100行;
- 所以共计扫描的行数是200行(t1 100行 t2 100行)。
能不能使用 join?
假设不使用join查询,使用单表查询:
- 执行
select * from t1
,查出表 t1 的所有数据,这里有 100 行; - 循环遍历这 100 行数据:
- 从每一行 R 取出字段 a 的值 $R.a;
- 执行select * from t2 where a=$R.a;
- 把返回的结果和 R 构成结果集的一行。
在这个查询过程,也是扫描了 200 行,但是总共执行了 101 条语句,比直接 join 多了 100 次交互。除此之外,客户端还要自己拼接 SQL 语句和结果。 显然使用join会好一点。
怎么选择驱动表?
以上这个 join 语句执行过程中,驱动表是走全表扫描,而被驱动表是走树搜索。
假设被驱动表的行数是 M。每次在被驱动表查一行数据,要先搜索索引 a,再搜索主键索引。每次搜索一棵树近似复杂度是以 2 为底的 M 的对数,记为 log2M,所以在被驱动表上查一行的时间复杂度是 2*log2M。
假设驱动表的行数是 N,执行过程就要扫描驱动表 N 行,然后对于每一行,到被驱动表上匹配一次。
因此整个执行过程,近似复杂度是 N N2log2M。
显然 N 的增大 比M 的增大要 大得多。因此用小表做驱动好一点。
结论:
- 使用 join 语句,性能比强行拆成多个单表执行 SQL 语句的性能要好;
- 如果使用 join 语句的话,需要让小表做驱动表。
以上结论的前提是 “可以使用被驱动表的索引”;
Simple Nested-Loop Join
再来看看 被驱动表用不上索引的情况:
代码语言:javascript复制EXPLAIN select * from t1 straight_join t2 on (t1.a=t2.b);
由于表 t2 的字段 b 上没有索引,因此再用图 2 的执行流程时,每次到 t2 去匹配的时候,就要做一次全表扫描。
t2的扫描是1000行,以上截图只是MySQL的估计。实则扫描的行数是 100*1000。
简单嵌套循环连接实际上就是简单粗暴的嵌套循环,如果table1有1万条数据,table2有1万条数据,那么数据比较的次数=1万 * 1万 =1亿次,这种查询效率会非常慢。
以上这种情况就是这种情况,但是MySQL使用了优化的 Block Nested-Loop Join 算法 。
代码语言:javascript复制EXPLAIN select * from t2 straight_join t1 on (t1.a=t2.b);
假如把t2换成驱动表,就不一样了。
- 全表扫描 t2,取出t2所有的行;
- 根据b字段去表t1查询,走的是树搜索过程(a是 唯一索引),因此每次搜索的过程只扫描一行,总共扫描100行;
- 整个过程一共扫描 1000 100 行。 用到的算法是 Index Nested-Loop Join 。
Block Nested-Loop Join
上述红框用到了一个 Using join buffer ,其实就是该优化的算法 Block Nested-Loop Join
这时候,被驱动表上没有可用的索引,算法的流程是这样的:
- 把表 t1 的数据读入线程内存 join_buffer 中,由于我们这个语句中写的是 select *,因此是把整个表 t1 放入了内存;
- 扫描表 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 λNM;
- 内存判断 N*M 次。
显然,内存判断次数是不受选择哪个表作为驱动表影响的。而考虑到扫描行数,在 M 和 N 大小确定的情况下,N 小一些,整个算式的结果会更小。所以结论是,应该让小表当驱动表。
N 固定的时候,参数join_buffer_size就影响了k(也就是λ)join_buffer_size 越大,一次可以放入的行越多,分成的段数也就越少,对被驱动表的全表扫描次数就越少。因为 join_buffer 不够大,需要对被驱动表做多次全表扫描,也就造成了“长事务”。
第一个问题:能不能使用 join 语句?
- 如果可以使用 Index Nested-Loop Join 算法,也就是说可以用上被驱动表上的索引,其实是没问题的;
- 如果使用 Block Nested-Loop Join 算法,扫描行数就会过多。尤其是在大表上的 join 操作,这样可能要扫描被驱动表很多次,会占用大量的系统资源。所以这种 join 尽量不要用。
所以你在判断要不要使用 join 语句时,就是看 explain 结果里面,Extra 字段里面有没有出现“Block Nested Loop”字样。(Using join buffer)
第二个问题是:如果要使用 join,应该选择大表做驱动表还是选择小表做驱动表?
- 如果是 Index Nested-Loop Join 算法,应该选择小表做驱动表;
- 如果是 Block Nested-Loop Join 算法:
- 在 join_buffer_size 足够大的时候,是一样的;
- 在 join_buffer_size 不够大的时候(这种情况更常见),应该选择小表做驱动表。所以,这个问题的结论就是,总是应该使用小表做驱动表。
小表:
demo1:
代码语言:javascript复制select * from t1 straight_join t2 on (t1.b=t2.b) where t2.id<=50;
select * from t2 straight_join t1 on (t1.b=t2.b) where t2.id<=50;
明显第二个语句t2要相对小一点,只需要放入50行到join_buffer中。
demo2:
代码语言:javascript复制select t1.b,t2.* from t1 straight_join t2 on (t1.b=t2.b) where t2.id<=100;
select t1.b,t2.* from t2 straight_join t1 on (t1.b=t2.b) where t2.id<=100;
这个例子里,表 t1 和 t2 都是只有 100 行参加 join。但是,这两条语句每次查询放入 join_buffer 中的数据是不一样的:
- 表 t1 只查字段 b,因此如果把 t1 放到 join_buffer 中,则 join_buffer 中只需要放入 b 的值;
- 表 t2 需要查所有的字段,因此如果把表 t2 放到 join_buffer 中的话,就需要放入三个字段 id、a 和 b。
这里,我们应该选择表 t1 作为驱动表。也就是说在这个例子里,“只需要一列参与 join 的表 t1”是那个相对小的表。
所以,更准确地说,在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与 join 的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表。
结论: 以上两种算法是由能否使用被驱动表的索引决定的。而能否用上被驱动表的索引,对 join 语句的性能影响很大。
- 如果可以使用被驱动表的索引,join 语句还是有其优势的;
- 不能使用被驱动表的索引,只能使用 Block Nested-Loop Join 算法,这样的语句就尽量不要使用;
- 在使用 join 的时候,应该让小表做驱动表。
结合 lru淘汰算法:
对被驱动表进行全表扫描,会把冷数据的page加入到buffer pool(链表).,并且block nested-loop要扫描多次,两次扫描的时间可能会超过1秒,使lru的那个优化失效,把热点数据从buffer pool中淘汰掉,影响正常业务的查询效率。
代码语言:javascript复制explain select * from t1 straight_join t2 on (t1.a=t2.a) where t1.a < 50;
这里没有用到索引,如果数据量不够多,并且满足a<50的行,占比比较高的话,优化器有可能会认为“还要回表,还不如直接扫主键id”
代码语言:javascript复制explain select t1.a,t2.* from t2 straight_join t1 on (t1.a=t2.a) where t1.a < 50;
这个就用到了索引,因为t1不用回表。