为了说明 join 语句到底是怎么执行的,我们先创建两个表,并执行初始化语句:

  1. CREATE TABLE `t2` (
  2. `id` int(11) NOT NULL,
  3. `a` int(11) DEFAULT NULL,
  4. `b` int(11) DEFAULT NULL,
  5. PRIMARY KEY (`id`),
  6. KEY `a` (`a`)
  7. ) ENGINE=InnoDB;
  8. drop procedure idata;
  9. delimiter ;;
  10. create procedure idata()
  11. begin
  12. declare i int;
  13. set i=1;
  14. while(i<=1000)do
  15. insert into t2 values(i, i, i);
  16. set i=i+1;
  17. end while;
  18. end;;
  19. delimiter ;
  20. call idata();
  21. create table t1 like t2;
  22. insert into t1 (select * from t2 where id<=100)

可以看到,这两个表都有一个主键索引 id 和一个索引 a,字段 b 上无索引。存储过程 idata() 向表 t2 里插入了 1000 行数据,在表 t1 里插入的是 100 行数据。

join 工作过程

1. Index Nested-Loop Join

我们来看一下这个语句:

  1. select * from t1 straight_join t2 on (t1.a=t2.a);

如果直接使用 join 语句,MySQL 优化器可能会选择表 t1 或 t2 作为驱动表,这会影响我们分析 SQL 语句的执行过程。为了便于分析执行过程中的性能问题,这里改用 straight_join 让 MySQL 使用固定的连接方式执行查询,这样优化器只会按照我们指定的方式去 join。在这个语句里,t1 是驱动表,t2 是被驱动表。
image.png
可以看到在这条语句里,被驱动表 t2 的字段 a 上有索引,join 过程用上了该索引,因此这个语句的执行流程为:

  • 从表 t1 中读入一行数据 R
  • 从数据行 R 中,取出 a 字段到表 t2 里去查找
  • 取出表 t2 中满足条件的行,跟 R 组成一行,作为结果集的一部分
  • 重复执行步骤 1 到 3,直到表 t1 的末尾循环结束。

这个过程是先遍历表 t1,然后根据从表 t1 中取出的每行数据中的 a 值,去表 t2 中查找满足条件的记录。这个过程在形式上跟嵌套查询类似,并且可以用上被驱动表的索引,所以称之为:Index Nested-Loop Join,简称 NLJ。它对应的流程图如下所示:
image.png
在这个流程里:

  • 对驱动表 t1 做了全表扫描,这个过程需要扫描 100 行
  • 而对于每一行 R,根据 a 字段去表 t2 查找,走的是树搜索过程。由于我们构造的数据都是一一对应的,因此每次的搜索过程都只扫描一行即可,也是总共扫描 100 行
  • 所以,整个执行流程,总扫描行数是 200。

怎么选择驱动表?

在上面这个 join 语句的执行过程中,驱动表(t1)走的是全表扫描,而被驱动表(t2)走的是树搜索。假设被驱动表的行数是 M。每次在被驱动表查一行数据,要先搜索索引 a,再搜索主键索引。每次搜索一棵树的近似复杂度为 log2M,所以在被驱动表上查一行的时间复杂度是 2*log2M。

假设驱动表的行数是 N,执行过程就要扫描驱动表 N 行,然后对于每一行,到被驱动表上匹配一次。因此整个执行过程的近似复杂度是 N + N2log2M。显然,N 对扫描行数的影响更大,因此应该让小表来做驱动表。但这个结论的前提是“可以使用被驱动表的索引”。

因此,如果可以使用被驱动表上的索引,那在使用 join 语句的时候,需要让小表做驱动表。接下来,我们再看看如果被驱动表用不上索引的时候要怎么办呢?

2. Block Nested-Loop Join

现在,我们把 SQL 语句改成这样:

  1. select * from t1 straight_join t2 on (t1.a=t2.b);

由于表 t2 的字段 b 上没有索引,因此再用上图的执行流程时,每次到 t2 去匹配的时候,就要做一次全表扫描。这时由于被驱动表上没有可用的索引,那算法的流程是这样的:

  • 把表 t1 的数据读入线程内存 join_buffer 中,由于是 select *,因此把整个表 t1 放入了内存
  • 扫描表 t2,把表 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,如果满足 join 条件,则作为结果集的一部分返回

这个过程的流程图如下:
image.png
我们来分析下,这个 SQL 语句的 explain 结果:
image.png
可以看到,该过程对表 t1 和 t2 都做了一次全表扫描,因此总的扫描行数是 1100。由于 join_buffer 是以无序数组的方式组织的,因此对表 t2 中的每一行,都要做 100 次判断,总共需要在内存中做的判断次数是:100*1000=10 万次。但因为这 10 万次判断是内存操作,速度上会快很多,性能也更好。

2.1 如何选择驱动表

假设小表的行数是 N,大表的行数是 M,那在这个算法里:

  • 两个表都做一次全表扫描,所以总的扫描行数是 M+N;
  • 内存中的判断次数是 M*N。

可以看到,调换这两个算式中的 M 和 N 是没差别的,因此这个时候选择大表还是小表做驱动表,执行耗时是一样的。但如果表 t1 是一个大表,join_buffer 放不下怎么办呢?join_buffer 的大小由参数 join_buffer_size 设定的,默认值是 256K。如果放不下表 t1 的所有数据话,策略很简单,就是分段放。那执行过程就变为:

  • 扫描表 t1,顺序读取数据行放入 join_buffer 中,假设放完第 88 行 join_buffer 满了,进行第 2 步
  • 扫描表 t2,把 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,如果满足 join 条件,则作为结果集的一部分返回
  • 清空 join_buffer
  • 继续扫描表 t1,顺序读取最后的 12 行数据放入 join_buffer 中,继续执行第 2 步。

执行流程图也就变成这样,图中的步骤 4 和 5 表示清空 join_buffer 再复用:
image.png
这个流程才体现出了这个算法名字中 “Block” 的由来,即分块去 join。可以看到,此时由于表 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 越大,分段数 K 越大。那 N 固定时,什么参数会影响 K 的大小呢?答案是 join_buffer_size。join_buffer_size 越大,则一次可以放入的行越多,分成的段数也就越少,对被驱动表的全表扫描次数就越少。因此如果你的 join 语句很慢,可以尝试把 join_buffer_size 改大。

因此,如果你的 join 语句使用的是 Index Nested-Loop Join 算法,也就是说可以用上被驱动表上的索引,其实是没问题的。但如果使用的是 Block Nested-Loop Join 算法,扫描行数就会过多。尤其是在大表上的 join 操作,这样可能要扫描被驱动表很多次,占用大量的系统资源。所以这种 join 尽量不要用。你在判断要不要使用 join 语句时可以看 explain 结果里面,Extra 字段里面有没有出现“Block Nested Loop”字样。

2.2 如何判断小表

通过上面的分析,我们知道在使用 join 语句时:

  • 如果是 Index Nested-Loop Join 算法,应该选择小表做驱动表;
  • 如果是 Block Nested-Loop Join 算法:在 join_buffer_size 足够大时是一样的;在 join_buffer_size 不够大时(这种情况更常见),应该选择小表做驱动表。

所以结论就是,总是应该使用小表做驱动表。那我们如何判断什么叫作小表?如果我在语句的 where 条件加上 t2.id<=50 这个限定条件,再来看下这两条语句:

  1. select * from t1 straight_join t2 on (t1.b=t2.b) where t2.id<=50;
  2. select * from t2 straight_join t1 on (t1.b=t2.b) where t2.id<=50;

注意,为了让两条语句的被驱动表都用不上索引,所以 join 字段都使用了没有索引的字段 b。如果是用第二个语句的话,join_buffer 只需要放入 t2 的前 50 行,显然是更好的。因为第一个语句会放入 t1 的所有行数据。所以这里,“t2 的前 50 行”是那个相对小的表,也就是小表。

我们再来看另外一组例子:

  1. select t1.b,t2.* from t1 straight_join t2 on (t1.b=t2.b) where t2.id<=100;
  2. 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 的各个字段的总数据量,数据量小的那个表就是“小表”,应该作为驱动表。

join 语句优化

1. Multi-Range Read

Multi-Range Read(MRR)这个优化的主要目的是尽量使用顺序读盘。我们知道,索引回表是指 InnoDB 在普通索引 a 上查到主键 id 的值后,再根据一个个主键 id 的值到主键索引上去查整行数据的过程。那这个回表过程是一行行地查数据,还是批量地查数据呢?我们以下面这条语句为例,a 列为普通索引:

  1. select * from t1 where a>=1 and a<=100;

主键索引是一棵 B+ 树,在这棵树上,每次只能根据一个主键 id 查到一行数据。因此,回表肯定是一行行搜索主键索引的。但如果随着 a 的值递增顺序查询的话,id 的值就变成随机的,那么就会出现随机访问,性能相对较差。虽然“按行查”这个机制不能改,但是调整查询的顺序,还是能够加速的。

因为大多数的数据都是按照主键递增顺序插入得到的,所以,如果按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能。这就是 MRR 优化的设计思路。此时,语句的执行流程为:

  • 根据索引 a 定位到满足条件的记录,先将 id 值放入 read_rnd_buffer 中 ;
  • 将 read_rnd_buffer 中的主键 id 值进行递增排序;
  • 排序后的 id 数组,依次到主键 id 索引中查记录,并作为结果返回。

这里,read_rnd_buffer 的大小是由 read_rnd_buffer_size 参数控制的。如果步骤 1 中,read_rnd_buffer 放满了,就会先执行完步骤 2 和 3,然后清空 read_rnd_buffer。之后继续找索引 a 的下个记录,并继续循环。
image.png
分析下该语句的 explain 执行计划:
image.png
可以看到 Extra 字段多了 Using MRR,表示的是用上了 MRR 优化。而且,由于我们在 read_rnd_buffer 中按照 id 做了排序,所以最后得到的结果集也是按照主键 id 递增顺序的。MRR 能够提升性能的核心在于,这条查询语句在索引 a 上做的是一个范围查询,会得到足够多的主键 id。这样通过排序以后,再去主键索引查数据,才能体现出“顺序性”的优势。

2. Batched Key Access

理解了 MRR 性能提升的原理,我们就能理解 MySQL 在 5.6 版本后开始引入的 Batched Key Access(BKA)算法了。这个 BKA 算法其实是对 NLJ 算法的优化。

NLJ 算法执行的逻辑是:从驱动表 t1,一行行地取出 a 的值,再到被驱动表 t2 去做 join。也就是说,对于表 t2 来说,每次都是匹配一个值。这时,MRR 的优势就用不上了。那怎么才能一次性地多传些值给表 t2 呢?方法就是从表 t1 里一次性地多拿些行出来,一起传给表 t2。

既然如此,我们就把表 t1 的数据取出来一部分,先放到一个临时内存,这个临时内存就是 join_buffer。我们知道 join_buffer 在 BNL 算法里的作用是暂存驱动表的数据。但在 NLJ 算法里并没有用。那么,我们刚好就可以复用 join_buffer 到 BKA 算法中。下图就是 NLJ 算法优化后的 BKA 算法的流程。
image.png

3. BNL 算法的性能问题

说完了 NLJ 算法的优化,我们再来看 BNL 算法的优化。当使用 Block Nested-Loop Join(BNL)算法时,可能会对被驱动表做多次扫描。如果这个被驱动表是一个大的冷数据表,除了会导致 IO 压力大以外,还会影响 Buffer Pool 中的缓存数据

由于 InnoDB 对 Bufffer Pool 的 LRU 算法做了优化,即第一次从磁盘读入内存的数据页,会先放在 old 区域。如果 1 秒后这个数据页不再被访问了,就不会被移动到 LRU 链表头部,这样对 Buffer Pool 的命中率影响不大。但如果一个使用 BNL 算法的 join 语句,会多次扫描一个冷表,而且这个语句执行时间超过 1 秒,就会在再次扫描冷表的时候,把冷表的数据页移到 LRU 链表头部。这种情况对应的,是冷表的数据量小于整个 Buffer Pool 的 3/8,能够完全放入 old 区域的情况。

如果这个冷表很大,就会出现另外一种情况:业务正常访问的数据页,没有机会进入 young 区域。由于优化机制的存在,一个正常访问的数据页,要进入 young 区域,需要隔 1 秒后再次被访问到。但是,由于我们的 join 语句在循环读磁盘和淘汰内存页,进入 old 区域的数据页,很可能在 1 秒之内就被淘汰了。这样,就会导致这个 MySQL 实例的 Buffer Pool 在这段时间内,young 区域的数据页没有被合理地淘汰。

大表 join 操作虽然对 IO 有影响,但是在语句执行结束后,对 IO 的影响也就结束了。但是,对 Buffer Pool 的影响是持续性的,需要依靠后续的查询请求慢慢恢复内存命中率。为了减少这种影响,可以增大 join_buffer_size 的值,减少对被驱动表的扫描次数。

总结一下,BNL 算法对系统的影响主要包括三个方面:

  • 可能会多次扫描被驱动表,占用磁盘 IO 资源;
  • 判断 join 条件需要执行 M*N 次对比,如果是大表会占用非常多的 CPU 资源;
  • 可能会导致 Buffer Pool 的热数据被淘汰,影响内存命中率。

我们执行 join 语句前,需要通过理论分析和查看 explain 结果的方式,确认是否要使用 BNL 算法。如果确认优化器会使用 BNL 算法,就需要做优化。优化的常见做法是,给被驱动表的 join 字段加上索引,把 BNL 算法转成 BKA 算法。