JOIN主要使用 Index Nested-Loop Join 和 Block Nested-Loop Join 算法实现
Index Nested-Loop Join
如果 join on 相关的字段存在索引就使用 Index Nested-Loop Join 算法来进行关联
如下sql语句的执行过程:
select * from t1 join t2 on (t1.a=t2.a);
- 对驱动表 t1 做了全表扫描,这个过程需要扫描 100 行;
- 而对于每一行 R,根据 a 字段去表 t2 查找,走的是树搜索过程。由于我们构造的数据 都是一一对应的,因此每次的搜索过程都只扫描一行,也是总共扫描 100 行;
- 所以,整个执行流程,总扫描行数是 200。
假设不使用 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 好。
怎么选择驱动表?
假设驱动表的行数是 N,执行过程就要扫描驱动表 N 行,然后对于每一行,到被驱动表上匹配一次
因此整个执行过程,近似复杂度是 N + N2log2M。显然,N 对扫描行数的影响更大,因此应该让小表来做驱动表
结论:
- 使用 join 语句,性能比强行拆成多个单表执行 SQL 语句的性能要好;
- 如果使用 join 语句的话,需要让小表做驱动表。
但是,你需要注意,这个结论的前提是“可以使用被驱动表的索引”。
Block Nested-Loop Join
如果关联语句中没有索引的话 可能需要扫描 N*M 行 当然Mysql对此有一个优化算法
算法的流程是这样的:
- 把表 t1 的数据读入线程内存 join_buffer 中,由于我们这个语句中写的是 select *,因此是把整个表 t1 放入了内存;
- 扫描表 t2,把表 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条 件的,作为结果集的一部分返回。
Block Nested-Loop Join 算法的这 N*M 次判断是内存操作,速度上会快很多,性能也更好
接下来,我们来看一下,在这种情况下,应该选择哪个表做驱动表。
- 两个表都做一次全表扫描,所以总的扫描行数是 M+N;
- 内存中的判断次数是 M*N。
可以看到,调换这两个算式中的 M 和 N 没差别,因此这时候选择大表还是小表做驱动 表,执行耗时是一样的。
join_buffer 放不下怎么办
如果放不下表 t1 的所有数据话,策略很简单,就是分段放
执行过程就变成了:
- 扫描表 t1,顺序读取数据行放入 join_buffer 中,放完第 88 行 join_buffer 满了,继续第 2 步;
- 扫描表 t2,把 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件 的,作为结果集的一部分返回;
- 清空 join_buffer;
- 继续扫描表 t1,顺序读取最后的 12 行数据放入 join_buffer 中,继续执行第 2 步
假设,驱动表的数据行数是 N,需要分 K 段才能完成算法流程,被驱动表的数据行数是 M。 注意,这里的 K 不是常数,N 越大 K 就会越大,因此把 K 表示为λ*N,显然λ的取值范围 是 (0,1)。 所以,在这个算法的执行过程中:
- 扫描行数是 N+λ* N*M;
- 内存判断 N*M 次。
显然,内存判断次数是不受选择哪个表作为驱动表影响的。而考虑到扫描行数,在 M和 N大小确定的情况下,N小一些,整个算式的结果会更小。
所以结论是,应该让小表当驱动表。
这里我需要说明下,什么叫作“小表”。
在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤, 过滤完成之后,计算参与 join 的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表
join语句怎么优化
Multi-Range Read 优化
在介绍 join 语句的优化方案之前,我需要先介绍一个知识点,即:Multi-Range Read 优化 (MRR)。这个优化的主要目的是尽量使用顺序读盘
因为大多数的数据都是按照主键递增顺序插入得到的,所以我们可以认为,如果按照主键 的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能
这,就是 MRR 优化的设计思路。此时,语句的执行流程变成了这样:
- 根据索引 a,定位到满足条件的记录,将 id 值放入 read_rnd_buffer 中 ; 2. 将 read_rnd_buffer 中的 id 进行递增排序;
- 排序后的 id 数组,依次到主键 id 索引中查记录,并作为结果返回。
另外需要说明的是,如果你想要稳定地使用 MRR 优化的话,需要设置
set optimizer_switch="mrr_cost_based=off"
MRR 能够提升性能的核心在于,这条查询语句在索引 a 上做的是一个范围查询可以得到足够多的主键 id。这样通过排序以后,再去主键索引查数据,才能体现出“顺序性”的优势
Batched Key Access
这个 Batched Key Access (BKA),其实就是对 NLJ 算法的优化
NLJ 算法执行的逻辑是:从驱动表 t1,一行行地取出 a 的值,再到被驱动表 t2 去做 join。也就是说,对于表 t2 来说,每次都是匹配一个值。这时,MRR 的优势就用不上了
那怎么才能一次性地多传些值给表 t2呢?方法就是,从表 t1 里一次性地多拿些行出来, 一起传给表 t2。 既然如此,我们就把表t1 的数据取出来一部分,先放到 join_buffer。 我们知道 join_buffer 在 BNL 算法里的作用,是暂存驱动表的数据。但是在 NLJ 算法里并没有用。那么,我们刚好就可以复用 join_buffer 到 BKA 算法中。
如果要使用 BKA 优化算法的话,你需要在执行 SQL 语句之前,先设置
set optimizer_switch="mrr=on,mrr_cost_based=off,batched_key_access=on";
BNL 算法的性能问题
大表 join 操作虽然对 IO 有影响,但是在语句执行结束后,对 IO 的影响也就结束了。但是,对 Buffer Pool 的影响就是持续性的,需要依靠后续的查询请求慢慢恢复内存命中率
为了减少这种影响,你可以考虑增大 join_buffer_size 的值,减少对被驱动表的扫描次数。
也就是说,BNL 算法对系统的影响主要包括三个方面:
- 可能会多次扫描被驱动表,占用磁盘 IO 资源;
- 判断 join 条件需要执行 M*N 次对比(M、N 分别是两张表的行数),如果是大表就会占用非常多的 CPU 资源;
- 可能会导致 Buffer Pool 的热数据被淘汰,影响内存命中率
我们执行语句之前,需要通过理论分析和查看 explain 结果的方式,确认是否要使用 BNL 算法。如果确认优化器会使用 BNL 算法,就需要做优化。优化的常见做法是,给被驱动表 的 join 字段加上索引,把 BNL 算法转成 BKA 算法
hash join
如果 join_buffer 里面维护的不是一个无序数组,而是一个哈希表的话,那么就不是 10 亿次判 断,而是 100 万次 hash 查找。这样的话,整条语句的执行速度就快多了吧?
实际上,这个优化思路,我们可以自己实现在业务端。实现流程大致如下:
- select * from t1;取得表 t1 的全部 1000 行数据,在业务端存入一个 hash 结构
- select * from t2 where b>=1 and b<=2000; 获取表 t2 中满足条件的 2000 行 数据。
- 把这 2000 行数据,一行一行地取到业务端,到 hash 结构的数据表中寻找匹配的数据。满足匹配的条件的这行数据,就作为结果集的一行。