大家好。我们在日常开发中经常会遇到多表联查的场景。今天我来为大家讲一下我们在进行多表联查时,表与表之间连接的原理。
为了方便讲解,我们先创建两个表,并填充一些数据。
如图所示,我创建了t1、t2两张表,每张表中插入了三条数据,我们查询一下:
一、连接简介
1、连接的本质
从本质上来说,连接就是把各个表中的数据取出来进行依次匹配的过程。例如,把t1和t2两个表连接起来的过程就像下图这样:
这个过程看起来就是把t1表的记录和t2的记录连起来组成新的更大的记录,所以这个查询过程称之为连接查询。连接查询的结果集中包含一个表中的每一条记录与另一个表中的每一条记录相互匹配的组合,像这样的结果 集就可以称之为笛卡尔积。
在MySQL中,连接查询的语法也很随意,只要在FROM语句后边跟多个表名就好了,比如我们把t1表和t2表连接起来的查询语句可以写成这样:
2、连接过程简介
如果我们乐意,我们可以连接任意数量张表,但是如果没有任何限制条件的话,这些表连接起来产生的笛卡尔积可能是非常巨大的。所以在连接的时候过滤掉特定记录组合是有必要的,在连接查询中的过滤条件可以分成两种:
涉及单表的条件: 就是指只涉及到两个表中的一个表的查询条件,比如t1.m1 > 1 是只针对t1 表的过滤条件,t2.n2 < ‘d’ 是只针对 t2 表的过滤条件。
涉及两表的条件: 就是指涉及到两个表的查询条件,,比如t1.m1 = t2.m2 、 t1.n1 > t2.n2 等。
下边我们就要看一下携带过滤条件的连接查询的大致执行过程了,比方说下边这个查询语句:
SELECT * FROM t1, t2 WHERE t1.m1 > 1 AND t1.m1 = t2.m2 AND t2.n2 < 'd';
在这个查询中我们指明了这三个过滤条件:
t1.m1 > 1
t1.m1 = t2.m2
t2.n2 < 'd'
那么这个连接查询的大致执行过程如下:
1、首先确定第一个需要查询的表,这个表称之为驱动表。
此处假设使用 t1 作为驱动表,那么就需要到t1 表中找满足t1.m1 > 1 的记录,因为表中的数据太少,而且我没在表上建立二级索引,所以此处查询t1表的访问方法采用全表扫描的方式执行单表查询。查询过程就如下图所示:
2、针对上一步骤中从驱动表产生的结果集中的每一条记录,分别需要到t2表中查找匹配的记录。
因为是根据t1表中的记录去找t2表中的记录,所以t2表也可以被称之为被驱动表。上一步从驱动表中得到了2条记录,所以需要查询2次t2表。此时涉及两个表的列的过滤条件t1.m1 = t2.m2 就派上用场了:
当t1.m1 = 2 时,过滤条件 t1.m1 = t2.m2 就相当于 t2.m2 = 2 ,所以此时 t2 表相当于有了 t2.m2 = 2 、t2.n2 < ‘d’ 这两个过滤条件,然后到 t2 表中执行单表查询。
当t1.m1 = 3 时,过滤条件 t1.m1 = t2.m2 就相当于 t2.m2 = 3 ,所以此时 t2 表相当于有了 t2.m2 = 3 、t2.n2 < ‘d’ 这两个过滤条件,然后到 t2 表中执行单表查询。所以整个连接查询的执行过程就如下图所示:
所以这条sql查询最后的结果只有两条符合过滤条件的记录:
3、内连接和外连接
我们在日常开发中,经常会遇到两表联查时,驱动表中的记录即使在被驱动表中没有匹配的记录,也仍然需要加入到结果集的情况,为了解决这个问题,就有了内连接和外连接的概念:
内连接: 驱动表中的记录在被驱动表中找不到匹配的记录,该记录不会加入到最后的结果集。
上边三个sql都是内连接,只是写法不同。可以看到,结果集中值包含满足 t1.n1 = ‘a’ 条件的记录。
外连接: 驱动表中的记录即使在被驱动表中没有匹配的记录,也仍然需要加入到结果集。
在MySQL 中,根据选取驱动表的不同,外连接仍然可以细分为2种:
左外连接: 选取左侧的表为驱动表。
我们发现左连接的结果集中包括了所有依赖表(t1)的记录。
右外连接: 选取右侧的表为驱动表。
我们发现左连接的结果集中包括了所有依赖表(t2)的记录。
在外连接中,WHERE过滤条件放在不同地方是有不同语义的:
WHERE子句中的过滤条件: WHERE 子句中的过滤条件就是我们平时见的那种,不论是内连接还是外连接,凡是不符合WHERE子句中的过滤条件的记录都不会被加入最后的结果集。例如:
ON 子句中的过滤条件: 对于外连接的驱动表的记录来说,如果无法在被驱动表中找到匹配ON子句中的过滤条件的记录,那么该记录仍然会被加入到结果集中,对应的被驱动表记录的各个字段使用NULL值填充。 例如:
注意:ON子句是专门为外连接驱动表中的记录在被驱动表找不到匹配记录时应不应该把该记录加入结果集这个场景下提出的,所以如果把ON子句放到内连接中,MySQL会把它和WHERE子句一样对待,也就是说:内连接中的WHERE子句和ON子句是等价的。
二、连接的原理
上边简单介绍了一下连接、内连接、外连接这些概念,下面我们讲一讲今天的的重点–MySQL采用了什么样的算法来进行表与表之间的连接。
1、嵌套循环连接(Nested-Loop Join)
对于两表连接来说,驱动表只会被访问一遍,但被驱动表却要被访问到好多遍。具体访问几遍取决于对驱动表执行单表查询后的结果集中的记录条数。对于内连接来说,选取哪个表为驱动表都没关系,而外连接的驱动表是固定的,也就是说左(外)连接的驱动表就是左边的那个表,右(外)连接的驱动表就是右边的那个表。例如:t1表和t2表执行内连接查询的大致过程如下:
步骤1:选取驱动表,使用与驱动表相关的过滤条件,选取代价最低的单表访问方法来执行对驱动表的单表查询。
步骤2:对上一步骤中查询驱动表得到的结果集中每一条记录,都分别到被驱动表中查找匹配的记录。
通用的两表连接过程如下图所示:
这个过程就像是一个嵌套的循环,所以这种驱动表只访问一次,但被驱动表却可能被多次访问,访问次数取决于对驱动表执行单表查询后的结果集中的记录条数的连接执行方式称之为嵌套循环连接(Nested-Loop Join ), 这是最简单,也是最笨拙的一种连接查询算法。
2、使用索引加快连接速度
上边说到:嵌套循环连接的步骤2中可能需要访问多次被驱动表,如果访问被驱动表的方式都是全表扫描的话,那查询速度可想而知的慢。查询t2表其实就相当于一次单表扫描,我们可以利用索引来加快查询速度。
我们再来看一下上边介绍的t1表和t2表进行内连接的例子以及它的连接过程:
查询驱动表t1后的结果集中有两条记录,嵌套循环连接算法需要对被驱动表查询2次。可以看到,原来的t1.m1 = t2.m2 这个涉及两个表的过滤条件在针对 t2 表做查询时关于 t1 表的条件就已经确定了,所以我们只需要单单优化对t2表的查询了,上述两个对t2表的查询语句中利用到的列是m2和n2列。我们可以进行如下尝试:
在m2 列上建立索引:因为对m2列的条件是等值查找,比如t2.m2 = 2 、 t2.m2 = 3 等,所以可能使用到 ref 的访问方法,假设使用ref 的访问方法去执行对t2表的查询的话,需要回表之后再判断t2.n2 < d 这个条件是否成立。
这里有一个比较特殊的情况,就是假设m2列是t2表的主键或者唯一二级索引列,那么使用t2.m2 = 常数 值这样的条件从t2表中查找记录的过程的代价就是常数级别的。我们知道在单表中使用主键值或者唯一二级索引列的值进行等值查找的方式称之为const,而这种在连接查询中对被驱动表使用主键值或者唯一二级索引列的值进行等值查找的查询执行方式称之为:eq_ref。
在n2 列上建立索引:涉及到的条件是t2.n2 < ‘d’ ,可能用到 range 的访问方法,假设使用 range 的访问 方法对t2 表的查询的话,需要回表之后再判断在m2列上的条件是否成立。假设m2 和n2 列上都存在索引的话,那么就需要从这两个里边儿挑一个代价更低的去执行对t2表的查询。
当 然,建立了索引不一定使用索引,只有在二级索引 + 回表的代价比全表扫描的代价更低时才会使用索引。另外,有时候连接查询的查询列表和过滤条件中可能只涉及被驱动表的部分列,而这些列都是某个索引的一部分,这种情况下即使不能使用eq_ref、ref 、ref_or_null 或者 range 这些访问方法执行对被驱动表的查询的话,也可以使用索引扫描,也就是index的访问方法来查询被驱动表。所以我们建议在真实工作中最好不要使 用*作为查询列表,最好把真实用到的列作为查询列表。
3、基于块的嵌套循环连接(Block Nested-Loop Join)
我们知道扫描一个表的过程其实是先把这个表从磁盘上加载到内存中,然后从内存中比较匹配条件是否满足。我们在开发过程中表中的数据往往成千上万条记录都是少的,几百万、几千万甚至几亿条记录的表都会有。内存里可能并不能完全存放下表中所有的记录,所以在扫描表前边记录的时候后边的记录可能还在磁盘上,等扫描到后边记录的时候可能内存不足,所以需要把前边的记录从内存中释放掉。我们前边又说过,采用嵌 套循环连接算法的两表连接过程中,被驱动表可是要被访问好多次的,如果这个被驱动表中的数据特别多而且不能使用索引进行访问,那就相当于要从磁盘上读好几次这个表,这个I/O代价就非常大了,所以我们得想办法:尽量减少访问被驱动表的次数。
上边我们讲到:驱动表结果集中有多少条记录,就得把被驱动表从磁盘上加载到内存中多少次。那么我们是不是可以在把被驱动表的记录加载到内存的时候,一次性和多条驱动表中的记录做匹配呢,这样就可以大大减少重复从磁盘上加载被驱动表的代价了。所以MySQL就有了一个 join buffer(连接缓冲区)的概念, join buffer就是执行连接查询前申请的一块固定大小的内存,先把若干条驱动表结果集中的记录装在这个join buffer中,然后开始扫描被驱动表,每一条被驱动表的记录一次性和join buffer 中的多条驱动表记录做匹配,因为匹配的过程都是在内存中完成的,所以这样可以显著减少被驱动表的I/O代价。使用join buffer 的过程如下图所示:
最好的情况是join buffer足够大,能容纳驱动表结果集中的所有记录,这样只需要访问一次被驱动表就可以完成连接操作了。这种加入了join buffer 的嵌套循环连接算法称之为基于块的嵌套连接(Block Nested-Loop Join)算法。
这个join buffer 的大小是可以通过启动参数或者系统变量 join_buffer_size 进行配置,默认大小为 262144字节(也就是256KB ),最小可以设置为128字节。当然,对于优化被驱动表的查询来说,最好是为被驱动表加上效率高的索引,如果实在不能使用索引,并且自己的机器的内存也比较大可以尝试调大join_buffer_size 的值来对连接查询进行优化。
另外需要注意的是,驱动表的记录并不是所有列都会被放到join buffer 中,只有查询列表中的列和过滤条件中的列才会被放到join buffer 中,所以再次提醒我们,最好不要把 * 作为查询列表,只需要把我们关心的列放到 查询列表就好了,这样还可以在join buffer中放置更多的记录。
到这里今天的内容就讲完了,欢迎大家在评论区进行讨论。最后依旧是请各位老板有钱的捧个人场,没钱的也捧个人场,谢谢各位老板!