MySQL:join 的原理

这篇文章主要介绍 join 在不同情况下有哪些算法以及这些算法的区别。

Index Nested-Loop Join

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

用straight_join让MySQL使用固定的连接方式执行查询,这样优化器只会按照指定的方式去join。在这个语句里,t1 是驱动表,t2是被驱动表。

在这条语句里,被驱动表t2的字段a上有索引,join过程用上了这个索引,这个语句的执行流程是这样的:

这个过程是先遍历表t1,然后根据从表t1中取出的每行数据中的a值,去表t2中查找满足条件的记录,重复执行,直到表t1的末尾循环结束。这个过程可以用上被驱动表的索引,称之为“Index Nested-Loop Join”,简称NLJ。

在这个流程里:

  1. 对驱动表t1做了全表扫描,这个过程需要扫描row1行;
  2. 而对于每一行R,根据a字段去表t2查找,走的是树搜索过程。由于构造的数据都是一一对应的,因此每次的搜索过程都只扫描一行,总共扫描row2行;
  3. 所以,整个执行流程,总扫描行数是row1+row2。

join语句执行过程中,驱动表是走全表扫描,而被驱动表是走树搜索。

假设被驱动表的行数是M。每次在被驱动表查一行数据,要先搜索索引a,再搜索主键索引。每次搜索一棵树近似复杂度是以2为底的M的对数,记为log2M,所以在被驱动表上查一行的时间复杂度是 2*log2M。

假设驱动表的行数是N,执行过程就要扫描驱动表N行,然后对于每一行,到被驱动表上匹配一次。

因此整个执行过程,近似复杂度是 N + N2log2M。

显然N对扫描行数的影响更大,因此应该让小表来做驱动表

这个结论的前提是“可以使用被驱动表的索引”。

Block Nested-Loop Join

把语句的t2.a改成t2.b,b上没有索引。

被驱动表没有使用索引时,还是使用上面的算法时叫做“Simple Nested-Loop Join”。性能很差。

被驱动表没有使用索引时,MySQL使用了另一个叫作“Block Nested-Loop Join”的算法,简称BNL。

算法流程:

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

使用“Block Nested-Loop Join”算法判断次数是:100*1000=10万次。如果使用Simple Nested-Loop Join算法进行查询,扫描行数也是10万行。从时间复杂度上来说,这两个算法是一样的。但是,Block Nested-Loop Join算法的这10万次判断是内存操作,速度上会快很多,性能也更好。

要是表t1是一个大表,join_buffer放不下怎么办呢?

join_buffer的大小是由参数join_buffer_size设定的,默认值是256k。如果放不下表t1的所有数据话,策略就是分段放。

执行过程就变成了:

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

扫描行数:N+λ*N*M,判断次数N*M

在这种情况下驱动表的选择也是应该让小表当驱动表

能不能使用join语句?

  1. 如果可以使用Index Nested-Loop Join算法,即可以用上被驱动表上的索引,其实是没问题的;
  2. 如果使用Block Nested-Loop Join算法,扫描行数就会过多。尤其是在大表上的join操作,这样可能要扫描被驱动表很多次,会占用大量的系统资源。所以这种join尽量不要用。

所以判断要不要使用join语句就是看explain结果里面,Extra字段里面有没有出现“Block Nested Loop”字样。

如果要使用join,应该选择大表做驱动表还是选择小表做驱动表

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

结论就是,总是应该使用小表做驱动表。

什么是小表

在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与join的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表

Join 优化:Multi-Range Read优化

这个优化的主要目的是尽量使用顺序读盘。

回表时是一行行搜索主键索引的。如果随着a的值递增顺序查询的话,id的值就变成随机的,那么就会出现随机访问,性能相对较差。虽然“按行查”这个机制不能改,但是调整查询的顺序,还是能够加速的。

因为大多数的数据都是按照主键递增顺序插入得到的,所以可以认为,如果按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能。

MRR优化的语句执行流程变成了这样:

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

read_rnd_buffer的大小是由read_rnd_buffer_size参数控制的。如果步骤1中,read_rnd_buffer放满了,就会先执行完步骤2和3,然后清空read_rnd_buffer。之后继续找索引a的下个记录,并继续循环。

如果想要稳定地使用MRR优化,需要设置set optimizer_switch="mrr_cost_based=off"。(官方文档的说法,是现在的优化器策略,判断消耗的时候,会更倾向于不使用MRR,把mrr_cost_based设置为off,就是固定使用MRR了。)

MRR能够提升性能的核心在于,这条查询语句在索引a上做的是一个范围查询(也就是说,这是一个多值查询),可以得到足够多的主键id。这样通过排序以后,再去主键索引查数据,才能体现出“顺序性”的优势。

Batched Key Access

MySQL在5.6版本后开始引入的Batched Key Acess(BKA)算法了。BKA算法其实就是对NLJ算法的优化。

NLJ算法执行的逻辑是:从驱动表t1,一行行地取出a的值,再到被驱动表t2去做join。即对于表t2来说,每次都是匹配一个值。这时,MRR的优势就用不上了。

从表t1里一次性地多拿些行出来,一起传给表t2就能一次性地多传些值给表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';

其中,前两个参数的作用是要启用MRR。这么做的原因是,BKA算法的优化要依赖于MRR。

BNL算法的性能问题

使用Block Nested-Loop Join(BNL)算法时,可能会对被驱动表做多次扫描。如果这个被驱动表是一个大的冷数据表,除了会导致IO压力大以外,还会对系统有什么影响呢?

由于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区域的数据页没有被合理地淘汰。

也就是说,这两种情况都会影响Buffer Pool的正常运作。

大表join操作虽然对IO有影响,但是在语句执行结束后,对IO的影响也就结束了。但是,对Buffer Pool的影响就是持续性的,需要依靠后续的查询请求慢慢恢复内存命中率。

为了减少这种影响,可以考虑增大join_buffer_size的值,减少对被驱动表的扫描次数。

即BNL算法对系统的影响主要包括三个方面:

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

如果确认会使用BNL算法,就需要做优化。优化的常见做法是,给被驱动表的join字段加上索引,把BNL算法转成BKA算法。

BNL转BKA

可以直接在被驱动表上建索引,这时就可以直接转成BKA算法了。

但是有时候会碰到一些不适合在被驱动表上建索引的情况。比如下面这个语句:

select * from t1 join t2 on (t1.b=t2.b) where t2.b>=1 and t2.b<=2000;

如果这条语句同时是一个低频的SQL语句,那么再为这个语句在表t2的字段b上创建一个索引就很浪费了。

这时可以考虑使用临时表。使用临时表的大致思路是:

  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算法,提升查询性能。

参考资料

《MySQL 必知必会》

《MySQL 45讲》

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值