先创建两个表,都有一个主键索引 id 和一个索引 a ,字段 b 上无索引。存储过程 idata() 往表 t2里插入了 1000 行数据,在表 t1 里插入的是 100 行数据。
接下来我们要探讨两个问题:
1.使用joing有什么问题
2.有两个大小不同的表做 join ,应该用哪个表做驱动表呢?
先看如下的SQL语句
select * from t1 straight_join t2 on (t1.a=t2.a);
如果直接使用 join 语句, MySQL 优化器可能会选择表 t1 或 t2 作为驱动表。为了便于分析执行过程中的性能问题,我改用 straight_join 让MySQL 使用固定的连接方式执行查询,这样优化器只会按照我们指定的方式去 join:t1 是驱动表, t2 是被驱动表。
在这条语句里,被驱动表 t2 的字段 a 上有索引, join 过程用上了这个索引,因此这个语句的执行流程是这样的:
1. 从表 t1 中读入一行数据 R ;
2. 从数据行 R 中,取出 a 字段到表 t2 里去查找;
3. 取出表 t2 中满足条件的行,跟 R 组成一行,作为结果集的一部分;
4. 重复执行步骤 1 到 3 ,直到表 t1 的末尾循环结束。
即为:先遍历表 t1,在根据t1中取出的a值,去表t2中查找满足条件的记录。类似于嵌套查询,并且可以用上被驱动表的索引(很重要的前提,下面的分析是在这个前提下的),所以我们称之为 “Index Nested-Loop Join” ,简称 NLJ 。
它对应的流程图如下:
1. 对驱动表 t1 做了全表扫描,这个过程需要扫描 100 行;2. 而对于每一行 R ,根据 a 字段去表 t2 查找,走的是树搜索过程,也是总共扫描 100 行;3.整个执行流程,总扫描行数是 200 。
下面有两个问题:是否使用join?怎样选择驱动表?注意在能使用被驱动表的前提下
1.能不能使用 join?
假设不适用join,就只能用单表查询:
1) 执行 select * from t1 ,查出表 t1 的所有数据,这里有 100 行;
2) 循环遍历这 100 行数据:
在这个查询过程,也是扫描了 200 行,但是总共执行了 101 条语句,比直接 join 多了100 次交互
所以这样不如直接join好
2.如何选择驱动表?
这个 join 语句执行过程中,驱动表是走全表扫描,而被驱动表是走树搜索。那么,究竟是让大表,还是小表做驱动表?
假设被驱动表的行数是 M 。每次在被驱动表查一行数据,要先搜索索引 a ,再搜索主键索引。(这是回表操作。a是辅助索引,真正的数据在主键索引树上,所以要回表)每次搜索一棵树近似复杂度是以 2 为底的 M 的对数,记为 log2 M ,所以在被驱动表上查一行的时间复杂度是 2*log2 M 。
假设驱动表的行数是 N ,执行过程就要扫描驱动表 N 行,对于每一行,到被驱动表上匹配一次。因此整个过程,近似复杂度是 N + N*2*log M 。所以, N 对扫描行数的影响更大,因此应该让小表来做驱动表
把 SQL 语句改成这样:
select * from t1 straight_join t2 on (t1.a=t2.b);
表 t2 的字段 b 上没有索引,所以每次去t2匹配,都要做全表扫描。所以要扫描表 t2 多达 1000 次,总共扫描 100*1000=10 万行.
当然, MySQL 也没有使用这个 Simple Nested-Loop Join 算法,而是使用了另一个叫作 “BlockNested-Loop Join” 的算法,简称 BNL
如果,被驱动表上没有可用的索引,算法的流程是这样的:
1. 把表 t1 的数据读入线程内存 join_buffer 中,由于我们这个语句中写的是 select * ,因此是把整个表 t1 放入了内存;
2. 扫描表 t2 ,把表 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回。
对表 t1 和 t2 都做了一次全表扫描,因此总的扫描行数是 1100 。由于join_buffer 是以无序数组的方式组织的,因此对表 t2 中的每一行,都要做 100 次判断,总共需要在内存中判断次数:100*1000=10 万次。(在内存中操作,会比扫描十万行快很多)
这个例子里表 t1 才 100 行,要是表 t1 是一个大表, join_buffer 放不下怎么办呢?join_buffer 的大小是由参数 join_buffer_size 设定的,默认值是 256k 。 如果放不下表 t1 的所有数据,策略很简单,就是分段放:清空join_buffer在复用
select * from t1 straight_join t2 on (t1.a=t2.b);
执行过程就变成了:
1. 扫描表 t1 ,顺序读取数据行放入 join_buffer 中,放完第 88 行 join_buffer 满了,继续第 2 步;
2. 扫描表 t2 ,把 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回;
3. 清空 join_buffer ;
4. 继续扫描表 t1 ,顺序读取最后的 12 行数据放入 join_buffer 中,继续执行第 2 步。
这个流程才体现出了这个算法名字中 “Block” 的由来,表示 “ 分块去 join” 。
来看下,在这种情况下驱动表的选择问题:
假设,驱动表的数据行数是 N ,需要分 K 段才能完成算法流程,被驱动表的数据行数是 M 。注意,这里的 K 不是常数, N 越大 K 就会越大,因此把 K 表示为 λ*N ,显然 λ 的取值范围是 (0,1) 。所以,在这个算法的执行过程中:
1. 扫描行数是 N+λ*N*M ;(被驱动表也被分次扫描了)
2. 内存判断 N*M 次。
考虑到扫描行数,在 M 和 N 大小确定的情况下, N 小一些,整个算式的结果会更小:应该让小表当驱动表
第一个问题:能不能使用 join 语句?
1. 如果可以使用 Index Nested-Loop Join 算法,也就是说可以用上被驱动表上的索引,其实是没问题的;
2. 如果使用 Block Nested-Loop Join 算法,扫描行数就会过多。尤其是在大表上的 join 操作,这样可能要扫描被驱动表很多次,会占用大量的系统资源。所以这种 join 尽量不要用。
就是看 explain 结果里面, Extra 字段里面有没有出现 “BlockNested Loop” 字样。这是判断的主要依据
第二个问题是:如果要使用 join ,应该选择大表做驱动表还是选择小表做驱动表?
1. 如果是 Index Nested-Loop Join 算法,应该选择小表做驱动表;
2. 如果是 Block Nested-Loop Join 算法:
所以,这个问题的结论就是,总是应该使用小表做驱动表。
创建两个表 t1 、 t2 ,create table t1(id int primary key, a int, b int, index(a));在表 t1 里,插入了 1000 行数据,每一行的 a=1001-id 的值。也就是说,表 t1 中字段 a 是逆序的。同时,我在表 t2 中插入了 100 万行数据。
Multi-Range Read 优化(MRR优化)
回表过程是一行行地查数据,还是批量地查数据?假如有这么一个语句:select * from t1 where a>=1 and a<=100;
主键索引是一棵 B+ 树,在这棵树上,每次只能根据一个主键 id 查到一行数据。因此,回表肯定是一行行搜索主键索引的
如果随着 a 的值递增顺序查询的话, id 的值就变成随机的,那么就会出现随机访问,性能相对较差。因为大多数的数据都是按照主键递增顺序插入得到的,所以我们可以认为,如果按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能。
这,就是 MRR 优化的设计思路。此时,语句的执行流程变成了这样:
1. 根据索引 a(普通索引 a 上查到主键 id 的值后) ,定位到满足条件的记录,将 id 值放入 read_rnd_buffer 中 ;
2. 将 read_rnd_buffer 中的 id 进行递增排序;
3. 排序后的 id 数组,依次到主键 id 索引中查记录,并作为结果返回。这样能保证顺序性访问数据
MRR 能够提升性能的核心在于,这条查询语句在索引 a 上做的是一个范围查询(也就是说,这是一个多值查询),可以得到足够多的主键 id 。这样通过排序以后,再去主键索引查数据,才能体现出 “ 顺序性 ” 的优势。
Batched Key Access(BKA算法): BKA 算法的优化要依赖于 MRR
MySQL 在 5.6 版本后开始引入的 Batched KeyAcess(BKA) 算法了。这个 BKA 算法,其实就是对 NLJ 算法的优化
NLJ 算法执行的逻辑是:从驱动表 t1 ,一行行地取出 a 的值,再到被驱动表 t2 去做 join 。也就是说,对于表 t2 来说,每次都是匹配一个值。这时, MRR 的优势就用不上了。(因为MRR优势还是范围查询等找到足够多的主键值)
那怎么才能一次性地多传些值给表 t2 呢?方法就是,从表 t1 里一次性地多拿些行出来,一起传给表 t2 。把表 t1 的数据取出来一部分,先放到一个临时内存:join_buffer 中
在 join_buffer 中放入的数据是 P1~P100 ,表示的是只会取查询需要的字段。当然,如果join buffer 放不下 P1~P100 的所有数据,就会把这 100 行数据分成多段执行上图的流程。在t2表中的查数据的回表过程中,再进行MRR优化
使用 Block Nested-Loop Join(BNL) 算法时,可能会对被驱动表做多次扫描。多次扫描一个冷表,而且这个语句执行时间超过 1 秒,
就会在再次扫描冷表的时候,把冷表的数据页移到 LRU 链表头部。
如果这个冷表很大:冷表的数据量大于整个 Buffer Pool 的 3/8,业务正常访问的数据页,没有机会进入 young 区
由于优化机制的存在,一个正常访问的数据页,要进入 young 区域,需要隔 1 秒后再次被访问到。但是,由于我们的 join 语句在循环读磁盘和淘汰内存页,进入 old 区域的数据页,很可能在 1秒之内就被淘汰了。这样,就会导致这个 MySQL 实例的 Buffer Pool 在这段时间内, young 区域的数据页没有被合理地淘汰。
这两种情况都会影响 Buffer Pool 的正常运作:没机会进入young区,或者young区的数据页没有被合理的淘汰
为了减少这种影响,你可以考虑增大 join_buffer_size 的值,减少对被驱动表的扫描次数
也就是说, BNL 算法对系统的影响主要包括三个方面:
1. 可能会多次扫描被驱动表,占用磁盘 IO 资源;
2. 判断 join 条件需要执行 M*N 次对比( M 、 N 分别是两张表的行数),如果是大表就会占用非常多的 CPU 资源;
3. 可能会导致 Buffer Pool 的热数据被淘汰,影响内存命中率。
如果确认优化器会使用 BNL 算法,就需要做优化。优化的常见做法是,给被驱动表的 join 字段加上索引,把 BNL 算法转成 BKA 算法(对NLJ算法进行优化)。
会碰到一些不适合在被驱动表上建索引的情况。比如下面这个语句:
select * from t1 join t2 on (t1.b=t2.b) where t2.b>=1 and t2.b<=2000;
在表 t2 中插入了 100 万行数据,但是经过 where 条件过滤后,需要参与join 的只有 2000 行数据。如果这条语句同时是一个低频的 SQL 语句,那么再为这个语句在表 t2 的字段 b 上创建一个索引就很浪费了。
如果使用 BNL 算法来 join 的话,这个语句的执行流程是这样的:
1. 把表 t1 的所有字段取出来,存入 join_buffer 中。这个表只有 1000 行, join_buffer_size 默认值是 256k ,可以完全存入。
2. 扫描表 t2 ,取出每一行数据跟 join_buffer 中的数据进行对比,
对于表 t2 的每一行,判断 join 是否满足的时候,都需要遍历 join_buffer 中的所有行。因此判断等值条件的次数是 1000*100 万 =10 亿次,工作量很大。
所以,设计一个两全齐美的方法:
考虑使用临时表。使用临时表的大致思路是:
1. 把表 t2 中满足条件的数据放在临时表 tmp_t 中;
2. 为了让 join 使用 BKA 算法,给临时表 tmp_t 的字段 b 加上索引;
3. 让表 t1 和 tmp_t 做 join 操作。
此时,对应的 SQL 语句的写法如下:
create temporary table temp_t(id int primary key, a int, b int, index(b))engine=innodb;
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);
1. 执行 insert 语句构造 temp_t 表并插入数据的过程中,对表 t2 做了全表扫描,这里扫描行数是100 万。
2. 之后的 join 语句,扫描表 t1 ,这里的扫描行数是 1000 ; join 比较过程中,做了 1000 次带索引的查询(走树搜索过程)。相比于优化前的 join 语句需要做 10 亿次条件判断来说,这个优化效果还是很明显的。
总体来看,不论是在原表上加索引,还是用有索引的临时表,我们的思路都是让 join 语句能够用上被驱动表上的索引,来触发 BKA 算法,提升查询性能
在这些优化方法中:
1. BKA 优化是 MySQL 已经内置支持的,建议你默认使用;
2. BNL 算法效率低,建议你都尽量转成 BKA 算法。优化的方向就是给被驱动表的关联字段加上索引;
3. 基于临时表的改进方案,对于能够提前过滤出小数据的 join 语句来说,效果还是很好的;