mysql连接原理

在使用mysql过程中我们一定遇到过下面几种规范:

  • 禁止大表join
  • 即使要join,也不能超过两张表
  • 非必要不要进行JOIN查询,如果要进行JOIN查询,被JOIN的字段必须类型相同(字符类型和字符集都要相同),并建立索引

为什么会有这样的规定?为什么这些规定能够避免连接查询慢的问题?我们今天来研究一下mysql的join究竟是怎么运作的

连接查询简介

连接查询就是将两个或多个表的数据结合起来。

下图展示了JOIN的7种用法,其中mysql不支持FULL OUTER JOIN,可以用UNION实现

图1:

mysql中的连接查询

  • 内连接(下面两种其实是等价的)
    • inner join
    • cross join
  • 外连接
    • 左连接 left join
    • 右连接 right join

数据准备

我们先创建两张示例表,并填充几条数据(mysql版本基于5.7.25,innodb存储引擎)

CREATE TABLE `class` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `class_name` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
INSERT INTO `test`.`class`(`id`, `class_name`) VALUES (1, '01班');
INSERT INTO `test`.`class`(`id`, `class_name`) VALUES (2, '02班');
CREATE TABLE `student` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL,
  `class_id` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
INSERT INTO `test`.`student`(`id`, `name`, `class_id`) VALUES (1, '张三', 1);
INSERT INTO `test`.`student`(`id`, `name`, `class_id`) VALUES (2, '李四', 1);
INSERT INTO `test`.`student`(`id`, `name`, `class_id`) VALUES (3, '老王', 1);

连接查询

笛卡尔积

在了解连接之前,我们需要理解笛卡尔积的概念,因为其实mysql的连接过程就是一个笛卡尔积运算。
笛卡尔积通俗的理解就是假设有两个集合A[a],B[b]那集合A、B笛卡尔积会产生axb个元素,A和B的笛卡尔积就是将A中的所有元素逐一和集合B中的元素组合形成一个大的集合。

比如我们的示例数据,两表不带条件内连接的查询就是一个笛卡尔积。

驱动表|被驱动表

在连接查询中,首选查询的表称为驱动表,依据驱动表得到的数据查询的表称之为被驱动表

内连接

语法

-- 显式连接
select * from1 inner join2 on 连接条件 where 筛选条件;
select * from1 cross join2 on 连接条件 where 筛选条件;
-- 隐式连接
select * from1,2 where 筛选条件;

从图1中可以知道内连接是取交集的操作,所以驱动表(表1)和被驱动表(表2)位置调换也不会影响结果;连接条件可以等价为筛选条件,对交集的获取没有影响,意味着on后面的条件写到where后面是等价的。

实际上上面3种写法可以,下面都以隐式连接的写法举例

select * from class,student;
+----+------------+----+--------+----------+
| id | class_name | id | name   | class_id |
+----+------------+----+--------+----------+
|  1 | 01|  1 | 张三 |        1 |
|  2 | 02|  1 | 张三 |        1 |
|  1 | 01|  2 | 李四 |        1 |
|  2 | 02|  2 | 李四 |        1 |
|  1 | 01|  3 | 老王 |        1 |
|  2 | 02|  3 | 老王 |        1 |
+----+------------+----+--------+----------+
6 rows in set (0.00 sec)

以上是没有条件的内连接查询,可以知道两个表的内查询结果数量=两表条数相乘
假如A表有100条数据B表有100条数据C表有100条数据,不带条件内连接查询得出的结果将会是100x100x100=1000000条。这个数据的量是很恐怖的,为了减少产生的数据我们需要增加查询条件

select * from class c,student s where c.id > 0 and c.id = s.class_id;
+----+------------+----+--------+----------+
| id | class_name | id | name   | class_id |
+----+------------+----+--------+----------+
|  1 | 01|  1 | 张三 |        1 		 |
|  1 | 01|  2 | 李四 |        1     |
|  1 | 01|  3 | 老王 |        1     |
+----+------------+----+--------+----------+
3 rows in set (0.00 sec)

我们知道sql是解释型语言,最终会被解析器解析然后执行对应的方法

select * from class c,student s where c.id > 0 and c.id = s.class_id; 

上面这条sql将会先执行(实际是按执行成本优化后的顺序执行,这里举例):

select * from class c where c.id > 0;
+----+------------+
| id | class_name |
+----+------------+
|  1 | 01|
|  2 | 02|
+----+------------+
2 rows in set (0.00 sec)

得到两条结果,然后会再根据查第一个班级表class得到的结果依次循环与第二张学生表student匹配,相当于会再执行下面两条sql:

select * from student s where s.class_id = 1;
select * from student s where s.class_id = 2;

用伪代码来表示的话,类似一个嵌套循环:

for(遍历查表classc.id>0得到的结果A){
    
    
  for(遍历内连接第二张表student){
    
    
    if(是否满足连接条件c.id = s.class_id){
    
    
      得到连接结果: class表数据 + student表数据
    }
  }
}

从上面的分析可以看出,被驱动表(student表)会被查询多次,查询多少次取决于驱动表(class表)筛选出的结果数量,这也是禁止大表join的原因之一,假如大表作为驱动表,得到的结果大概率也会很多,那相对的被驱动表被查询的次数也会更多,严重影响查询效率;

因为被驱动表会根据驱动表查出的结果依次查寻,会不断的访问,当增加join的表时这种类似嵌套查询次数会指数增加,严重影响查询效率。所以需要join查询时避免大于两张表,确实避免不了时可以采用拆分的方式拆成两个join查询(例如 t1 join t2 join t3 拆成 t1 join t2 = temp , temp join t3)。

外连接

语法

select 列名 from1  left|right join2 on 连接条件 where 筛选条件

上面的例子中只查出了01班的数据,02班没有对应的学生,就没有查出数据。假如要把class中没有学生的班级数据也查出来,则需要用外连接查询。内连接和外连接的区别为:

内连接:驱动表中的数据需要在被驱动表中也存在才会加入到结果集

外连接:驱动表中的记录即使在被驱动表中没有记录,依然加入到结果集中,被驱动表字段用null填充

外连接又分为左连接和右连接,两者的区别就是驱动表的选择,左连接选择左边的表作为驱动表,右连接选择右边的表作为驱动表

所以当我们需要把没有学生的02班也显示出来时,需要使用外连接查询,该语句也属于左连接查询:

select * from class c left join student t on c.id = t.class_id ;
+----+------------+------+--------+----------+
| id | class_name | id   | name   | class_id |
+----+------------+------+--------+----------+
|  1 | 01|    1 | 张三 |        1 |
|  1 | 01|    2 | 李四 |        1 |
|  1 | 01|    3 | 老王 |        1 |
|  2 | 02| NULL | NULL   | NULL     |
+----+------------+------+--------+----------+
4 rows in set (0.00 sec)

结合图1我们会发现,外连接可以简单的理解为两表的并集,排除被驱动表中不满足连接条件的数据。

所以驱动表和被驱动表是不能更换位置的。

JOIN算法原理

简单的嵌套循环连接(Simple Nested-Loop Join)

简单来说嵌套循环连接算法就是一个双层for 循环 ,通过循环外层表的行数据,逐个与内层表的所有行数据进行比较来获取结果,用上面的例子举例

select * from class c left join student t on c.id = t.class_id ;

执行上面的sql大致的过程为:

  • 第一步:先查按条件查询驱动表 select * from class c ;

  • 第二步:然后根据第一步中得到的驱动表的结果集,逐一到被驱动表中查找匹配记录;

特点:

Nested-Loop Join 简单粗暴容易理解,就是通过双层循环比较数据来获得结果,但是这种算法显然太过于粗鲁,如果每个表有1万条数据,那么对数据比较的次数=1万 * 1万 =1亿次,很显然这种查询效率会非常慢。

当然mysql 肯定不会这么粗暴的去进行表的连接,所以就出现了后面的两种对Nested-Loop Join 优化算法,在执行join 查询时mysql 会根据情况选择 后面的两种优join优化算法的一种进行join查询。


索引嵌套循环连接(Index Nested-Loop Join)

Index Nested-Loop Join其优化的思路 主要是为了减少被驱动表数据的匹配次数, 简单来说Index Nested-Loop Join 就是通过驱动表匹配条件 直接与被驱动表索引进行匹配,避免和被驱动表的每条记录去进行比较, 这样极大的减少了对内层表的匹配次数,从原来的匹配次数=外层表行数 * 内层表行数,变成了 外层表的行数 * 内层表索引的高度,极大的提升了 join的性能。还是用上面的例子举例

select * from class c left join student t on c.id = t.class_id ;

假如我们在student表的class_id字段添加索引

ALTER TABLE `test`.`student` ADD INDEX `i_class_id`(`class_id`) USING BTREE;

这个时候的第二步就会变成根据第一步中得到的驱动表的结果集,逐一到被驱动表中与i_class_id索引匹配,然后通过回表操作得到最终匹配的记录(实际情况要索引+回表的成本低于全表扫描时才会选择走索引)。

通过索引匹配,极大的减少了对被驱动表的查询操作,提高join性能。

我们也可以推理得出join查询语句中,on后面的连接条件最好要建立索引


缓存块嵌套循环连接(Block Nested-Loop Join)

Block Nested-Loop Join 其优化思路是减少被驱动表的扫表次数。

简单嵌套循环连接算法中,匹配驱动表得到的每一条记录都要逐一和被驱动表的记录进行扫表匹配,查询驱动表要是得到100条记录,那被驱动表就需要被扫描100次,这样IO成本是很大的。假如可以不逐一比较,而是每次50条记录和被驱动比较,那被驱动表就只需要被扫描2次,极大的减少了被驱动表的扫描次数。

mysql通过join buffer实现这个缓存功能,join buffer就是执行连接查询前申请的一块固定大小的内存,先把若干条驱动表结果集中的记录装在join buffer中,然后再扫描被驱动表,每一次被驱动表的记录都和join buffer中的多条驱动表记录匹配。假如join buffer足够大,可以将100条驱动表的结果集都保存,那被驱动表就只需要扫表一次了。

如果无法使用Index Nested-Loop Join的时候,数据库是默认使用的是Block Nested-Loop Join算法的

上面的例子中我们不给student表的class_id字段添加索引,默认会使用Block Nested-Loop Join算法。

使用Block Nested-Loop Join 算法需要开启优化器管理配置的optimizer_switch的设置block_nested_loop为on 默认为开启,如果关闭则使用Simple Nested-Loop Join 算法

通过指令:show variables like ‘optimizer_switc%’; 查看配置

连接缓冲区(join buffer)

join buffer 默认大小为262144字节(256kb),可以用Show variables like 'join_buffer_size%'查看配置,最小可设置为128字节;

每个连接缓冲区的大小由join_buffer_size系统变量的值确定 ;

仅当连接的类型为ALLindex(即没有命中索引)时才使用此缓冲区 (走索引的效率还是要高于用join buffer,即使不能走索引,也可以通过调整join_buffer_size大小来优化join效率);

不是所有的列都会放到join buffer中,只有使用到的列(查询列表中的列和过滤条件中的列)才会被放到join buffer中,这也可以推理出在写连接sql时尽量不要用*作为查询列表,这样join buffer就能缓存更多的有效记录;

更多关于join buffer的内容可以查看官网介绍:https://dev.mysql.com/doc/internals/en/join-buffer-size.html

总结

  • 禁止大表join;即使要join,也不能超过两张表;为条件字段增加索引;用小结果集驱动大结果集(驱动表选择能得到结果集更小的表);结合具体环境增大join buffer size的大小;

    其实上面的几个点本质都是为了减少被驱动表的匹配扫描次数

  • 连接sql尽量不要用*作为查询列表

    一方面是为了能让join buffer缓存更多的数据;另一个作用是增加索引覆盖的概率,有几率避免回表;

  • 非必要不要进行JOIN查询,如果要进行JOIN查询,被JOIN的字段必须类型相同(字符类型和字符集都要相同)

    • 当被驱动表的列是字符串类型,而驱动表的列类型是非字符串时,则会发生类型隐式转换,无法使用索引;
    • 当被驱动表和驱动表的列都是字符串类型,两边无论是 CHAR 还是 VARCHAR,均不会发生类型隐式转换,都可以使用索引;
    • 当被驱动表的列是字符串且其字符集比驱动表的列采用的字符集更小或无法被包含时(latin比utf8mb4小,gb2312 比 utf8mb4 小,另外 gb2312 虽然比 latin1 大,但并不兼容,也不行,详见下方测试 ),则会发生类型隐式转换,无法使用索引;
    • 综上,虽然有很多场景下,JOIN列类型不一致也能用到索引,但保不准啥时候就掉坑了。因此,JOIN列的类型定义最好是完全一致,包括长度,尤其是字符集。
  • A left join B返回的记录可能会大于A表总条数,所以假如查询结果有唯一要求,要记得增加去重条件

    mysql的连表查询实际上就是一个笛卡尔积运算,当驱动表A中的一条记录在B表中匹配到多条时就会导致返回的记录可能大于A表中的记录

    上面的例子其实已经表现出了这个特点,驱动表class中只有两条记录,但是连接查询后的记录大于驱动表的总记录

  • 在java项目中应该尽量避免连接操作,可以用其他方式来达到连接查询的目的。

    比如上面的例子:

    select * from class c left join student t on c.id = t.class_id;
    

    可以拆成两条sql来执行,变成两条单表select查询

    select * from class c
    
    select * from student t where t.class_id in (1,2);
    

    然后再将查出的集合结合业务用代码来组装数据格式

猜你喜欢

转载自blog.csdn.net/m0_53121042/article/details/115861687
今日推荐