MySQL实战45讲学习笔记:第四十四讲

1、引子

这是咱们专栏的最后一篇答疑文章,今天咱们来讲说一些好问题。算法

在我看来,可以帮咱们扩展一个逻辑的边界的问题,就是好问题。由于经过解决这样的问题,可以加深咱们对这个逻辑的理解,或者帮咱们关联到另一个知识点,进而能够帮助
咱们创建起本身的知识网络。数组

在工做中会问好问题,是一个很重要的能力。bash

通过这段时间的学习,从评论区的问题我能够感受出来,紧跟课程学习的同窗,对 SQL 语句执行性能的感受愈来愈好了,提出的问题也愈来愈细致和精准了。网络

接下来,咱们就一块儿看看同窗们在评论区提到的这些好问题。在和你一块儿分析这些问题的时候,我会指出它们具体是在哪篇文章出现的。同时,在回答这些问题的过程当中,我会假session

设你已经掌握了这篇文章涉及的知识。固然,若是你印象模糊了,也能够跳回文章再复习一次。函数

2、join 的写法

一、两个 join 的查询结果

在第 35 篇文章《join 语句怎么优化?》中,我在介绍 join 执行顺序的时候,用的都是straight_join。@郭健 同窗在文后提出了两个问题:oop

1. 若是用 left join 的话,左边的表必定是驱动表吗?
2. 若是两个表的 join 包含多个条件的等值匹配,是都要写到 on 里面呢,仍是只把一个条件写到 on 里面,其余条件写到 where 部分?性能

为了同时回答这两个问题,我来构造两个表 a 和 b:学习

create table a(f1 int, f2 int, index(f1))engine=innodb;
create table b(f1 int, f2 int)engine=innodb;
insert into a values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6);
insert into b values(3,3),(4,4),(5,5),(6,6),(7,7),(8,8);

表 a 和 b 都有两个字段 f1 和 f2,不一样的是表 a 的字段 f1 上有索引。而后,我往两个表中都插入了 6 条记录,其中在表 a 和 b 中同时存在的数据有 4 行。
@郭健 同窗提到的第二个问题,其实就是下面这两种写法的区别:优化

我把这两条语句分别记为 Q1 和 Q2。

首先,须要说明的是,这两个 left join 语句的语义逻辑并不相同。咱们先来看一下它们的执行结果。

图 1 两个 join 的查询结果

能够看到:

语句 Q1 返回的数据集是 6 行,表 a 中即便没有知足匹配条件的记录,查询结果中也会返回一行,并将表 b 的各个字段值填成 NULL。
语句 Q2 返回的是 4 行。从逻辑上能够这么理解,最后的两行,因为表 b 中没有匹配的字段,结果集里面 b.f2 的值是空,不知足 where 部分的条件判断,所以不能做为结果集的一部分。

接下来,咱们看看实际执行这两条语句时,MySQL 是怎么作的。

咱们先一块儿看看语句 Q1 的 explain 结果:

图 2 Q1 的 explain 结果

能够看到,这个结果符合咱们的预期:驱动表是表 a,被驱动表是表 b;

因为表 b 的 f1 字段上没有索引,因此使用的是 Block Nexted Loop Join(简称BNL) 算法。

二、left join -BNL 算法

看到 BNL 算法,你就应该知道这条语句的执行流程实际上是这样的:

1. 把表 a 的内容读入 join_buffer 中。由于是 select * ,因此字段 f1 和 f2 都被放入join_buffer 了。
2. 顺序扫描表 b,对于每一行数据,判断 join 条件(也就是 a.f1=b.f1 and a.f2=b.f2) 是否知足,知足条件的记录, 做为结果集的一行返回。若是语句中有 where 子句,须要先判断 where 部分知足条件后,再返回。
3. 表 b 扫描完成后,对于没有被匹配的表 a 的行(在这个例子中就是 (1,1)、(2,2) 这两行),把剩余字段补上 NULL,再放入结果集中。

对应的流程图以下:

图 3 left join -BNL 算法

能够看到,这条语句确实是以表 a 为驱动表,并且从执行效果看,也和使用 straight_join是同样的。

你可能会想,语句 Q2 的查询结果里面少了最后两行数据,是否是就是把上面流程中的步骤 3 去掉呢?咱们仍是先看一下语句 Q2 的 expain 结果吧。

图 4 Q2 的 explain 结果

这里先和你说一句题外话,专栏立刻就结束了,我也和你一块儿根据 explain 结果“脑补”了不少次一条语句的执行流程了,因此我但愿你已经具有了这个能力。今天,咱们再
一块儿分析一次 SQL 语句的 explain 结果。

能够看到,这条语句是以表 b 为驱动表的。而若是一条 join 语句的 Extra 字段什么都没写的话,就表示使用的是 Index Nested-Loop Join(简称 NLJ)算法。

所以,语句 Q2 的执行流程是这样的:顺序扫描表 b,每一行用 b.f1 到表 a 中去查,匹配到记录后判断 a.f2=b.f2 是否知足,知足条件的话就做为结果集的一部分返回。

三、为何语句 Q1 和 Q2 这两个查询的执行流程会差距这么大呢?

那么,为何语句 Q1 和 Q2 这两个查询的执行流程会差距这么大呢?其实,这是由于优化器基于 Q2 这个查询的语义作了优化。

为了理解这个问题,我须要再和你交代一个背景知识点:在 MySQL 里,NULL 跟任何值执行等值判断和不等值判断的结果,都是 NULL。这里包括, select NULL = NULL 的结
果,也是返回 NULL。

所以,语句 Q2 里面 where a.f2=b.f2 就表示,查询结果里面不会包含 b.f2 是 NULL 的行,这样这个 left join 的语义就是“找到这两个表里面,f一、f2 对应相同的行。对于表 a
中存在,而表 b 中匹配不到的行,就放弃”。

这样,这条语句虽然用的是 left join,可是语义跟 join 是一致的。

所以,优化器就把这条语句的 left join 改写成了 join,而后由于表 a 的 f1 上有索引,就把表 b 做为驱动表,这样就能够用上 NLJ 算法。在执行 explain 以后,你再执行 show
warnings,就能看到这个改写的结果,如图 5 所示。

图 5 Q2 的改写结果

这个例子说明,即便咱们在 SQL 语句中写成 left join,执行过程仍是有可能不是从左到右链接的。也就是说,使用 left join 时,左边的表不必定是驱动表。

这样看来,若是须要 left join 的语义,就不能把被驱动表的字段放在 where 条件里面作等值判断或不等值判断,必须都写在 on 里面。那若是是 join 语句呢?

这时候,咱们再看看这两条语句:

select * from a join b on(a.f1=b.f1) and (a.f2=b.f2); /*Q3*/
select * from a join b on(a.f1=b.f1) where (a.f2=b.f2);/*Q4*/

咱们再使用一次看 explain 和 show warnings 的方法,看看优化器是怎么作的。

图 6 join 语句改写

能够看到,这两条语句都被改写成:

select * from a join b where (a.f1=b.f1) and (a.f2=b.f2);

执行计划天然也是如出一辙的。

也就是说,在这种状况下,join 将判断条件是否所有放在 on 部分就没有区别了。

3、Simple Nested Loop Join 的性能问题

咱们知道,join 语句使用不一样的算法,对语句的性能影响会很大。在第 34 篇文章《到底可不可使用 join?》的评论区中,@书策稠浊 和 @朝夕心 两位同窗提了一个很不错的问题。

咱们在文中说到,虽然 BNL 算法和 Simple Nested Loop Join 算法都是要判断 M*N 次(M 和 N 分别是 join 的两个表的行数),可是 Simple Nested Loop Join 算法的每轮
判断都要走全表扫描,所以性能上 BNL 算法执行起来会快不少。

为了便于说明,我仍是先为你简单描述一下这两个算法。

BNL 算法的执行逻辑是:

1. 首先,将驱动表的数据所有读入内存 join_buffer 中,这里 join_buffer 是无序数组;
2. 而后,顺序遍历被驱动表的全部行,每一行数据都跟 join_buffer 中的数据进行匹配,匹配成功则做为结果集的一部分返回。

Simple Nested Loop Join 算法的执行逻辑是:顺序取出驱动表中的每一行数据,到被驱动表去作全表扫描匹配,匹配成功则做为结果集的一部分返回。

这两位同窗的疑问是,Simple Nested Loop Join 算法,其实也是把数据读到内存里,而后按照匹配条件进行判断,为何性能差距会这么大呢?

解释这个问题,须要用到 MySQL 中索引结构和 Buffer Pool 的相关知识点:

1. 在对被驱动表作全表扫描的时候,若是数据没有在 Buffer Pool 中,就须要等待这部分数据从磁盘读入;从磁盘读入数据到内存中,会影响正常业务的 Buffer Pool 命中率,并且这个算法自然

会对被驱动表的数据作屡次访问,更容易将这些数据页放到 Buffer Pool 的头部(请参考第 35 篇文章中的相关内容);

2. 即便被驱动表数据都在内存中,每次查找“下一个记录的操做”,都是相似指针操做。而 join_buffer 中是数组,遍历的成本更低。

因此说,BNL 算法的性能会更好。

4、distinct 和 group by 的性能

在第 37 篇文章《何时会使用内部临时表?》中,@老杨同志 提了一个好问题:若是只须要去重,不须要执行聚合函数,distinct 和 group by 哪一种效率高一些呢?

我来展开一下他的问题:若是表 t 的字段 a 上没有索引,那么下面这两条语句:

select a from t group by a order by null;
select distinct a from t;

的性能是否是相同的?

首先须要说明的是,这种 group by 的写法,并非 SQL 标准的写法。标准的 group by语句,是须要在 select 部分加一个聚合函数,好比:

select a,count(*) from t group by a order by null;

这条语句的逻辑是:按照字段 a 分组,计算每组的 a 出现的次数。在这个结果里,因为作的是聚合计算,相同的 a 只出现一次。

备注:这里你能够顺便复习一下第 37 篇文章中关于 group by 的相关内容。

没有了 count(*) 之后,也就是再也不须要执行“计算总数”的逻辑时,第一条语句的逻辑就变成是:按照字段 a 作分组,相同的 a 的值只返回一行。而这就是 distinct 的语义,因此
不须要执行聚合函数时,distinct 和 group by 这两条语句的语义和执行流程是相同的,

所以执行性能也相同。

这两条语句的执行流程是下面这样的。

1. 建立一个临时表,临时表有一个字段 a,而且在这个字段 a 上建立一个惟一索引;
2. 遍历表 t,依次取数据插入临时表中:若是发现惟一键冲突,就跳过;不然插入成功;

3. 遍历完成后,将临时表做为结果集返回给客户端。

5、备库自增主键问题

除了性能问题,你们对细节的追问也很到位。在第 39 篇文章《自增主键为何不是连续的?》评论区,@帽子掉了 同窗问到:在 binlog_format=statement 时,语句 A 先获取
id=1,而后语句 B 获取 id=2;接着语句 B 提交,写 binlog,而后语句 A 再写 binlog。

这时候,若是 binlog 重放,是否是会发生语句 B 的 id 为 1,而语句 A 的 id 为 2 的不一致状况呢?

首先,这个问题默认了“自增 id 的生成顺序,和 binlog 的写入顺序多是不一样的”,这个理解是正确的。

其次,这个问题限定在 statement 格式下,也是对的。由于 row 格式的 binlog 就没有这个问题了,Write row event 里面直接写了每一行的全部字段的值。

而至于为何不会发生不一致的状况,咱们来看一下下面的这个例子。

create table t(id int auto_increment primary key);
insert into t values(null);

图 7 insert 语句的 binlog

能够看到,在 insert 语句以前,还有一句 SET INSERT_ID=1。这条命令的意思是,这个线程里下一次须要用到自增值的时候,不论当前表的自增值是多少,固定用 1 这个值。

这个 SET INSERT_ID 语句是固定跟在 insert 语句以前的,好比 @帽子掉了同窗提到的场景,主库上语句 A 的 id 是 1,语句 B 的 id 是 2,可是写入 binlog 的顺序先 B 后 A,那
么 binlog 就变成:

SET INSERT_ID=2;
语句B;
SET INSERT_ID=1;
语句A;

你看,在备库上语句 B 用到的 INSERT_ID 依然是 2,跟主库相同。

所以,即便两个 INSERT 语句在主备库的执行顺序不一样,自增主键字段的值也不会不一致。

6、小结

今天这篇答疑文章,我选了 4 个好问题和你分享,并作了分析。在我看来,可以提出好问题,首先表示这些同窗理解了咱们文章的内容,进而又作了深刻思考。有大家在认真的阅
读和思考,对我来讲是鼓励,也是动力。

说实话,短短的三篇答疑文章没法所有展开同窗们在评论区留下的高质量问题,以后有的同窗还会二刷,也会有新的同窗加入,你们想到新的问题就请给我留言吧,我会继续关注
评论区,和你在评论区交流。

老规矩,答疑文章也是要有课后思考题的。

在第 8 篇文章的评论区, @XD 同窗提到一个问题:他查看了一下 innodb_trx,发现这个事务的 trx_id 是一个很大的数(281479535353408),并且彷佛在同一个 session 中
启动的会话获得的 trx_id 是保持不变的。当执行任何加写锁的语句后,trx_id 都会变成一个很小的数字(118378)。

你能够经过实验验证一下,而后分析看看,事务 id 的分配规则是什么,以及 MySQL 为何要这么设计呢?

你能够把你的结论和分析写在留言区,我会在下一篇文章和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一块儿阅读。

7、上期问题时间

上期的问题是,怎么给分区表 t 建立自增主键。因为 MySQL 要求主键包含全部的分区字段,因此确定是要建立联合主键的。

这时候就有两种可选:一种是 (ftime, id),另外一种是 (id, ftime)。若是从利用率上来看,应该使用 (ftime, id) 这种模式。由于用 ftime 作分区 key,说明大
多数语句都是包含 ftime 的,使用这种模式,能够利用前缀索引的规则,减小一个索引。

这时的建表语句是:

CREATE TABLE `t` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `ftime` datetime NOT NULL,
  `c` int(11) DEFAULT NULL,
  PRIMARY KEY (`ftime`,`id`)
) ENGINE=MyISAM DEFAULT CHARSET=latin1
PARTITION BY RANGE (YEAR(ftime))
(PARTITION p_2017 VALUES LESS THAN (2017) ENGINE = MyISAM,
 PARTITION p_2018 VALUES LESS THAN (2018) ENGINE = MyISAM,
 PARTITION p_2019 VALUES LESS THAN (2019) ENGINE = MyISAM,
 PARTITION p_others VALUES LESS THAN MAXVALUE ENGINE = MyISAM);

固然,个人建议是你要尽可能使用 InnoDB 引擎。InnoDB 表要求至少有一个索引,以自增字段做为第一个字段,因此须要加一个 id 的单独索引。

CREATE TABLE `t` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `ftime` datetime NOT NULL,
  `c` int(11) DEFAULT NULL,
  PRIMARY KEY (`ftime`,`id`),
  KEY `id` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1
PARTITION BY RANGE (YEAR(ftime))
(PARTITION p_2017 VALUES LESS THAN (2017) ENGINE = InnoDB,
 PARTITION p_2018 VALUES LESS THAN (2018) ENGINE = InnoDB,
 PARTITION p_2019 VALUES LESS THAN (2019) ENGINE = InnoDB,
 PARTITION p_others VALUES LESS THAN MAXVALUE ENGINE = InnoDB);

固然把字段反过来,建立成:

PRIMARY KEY (`id`,`ftime`),
KEY `id` (`ftime`)

也是能够的。

评论区留言点赞板:

@夹心面包 、@郭江伟 同窗提到了最后一种方案。

@aliang 同窗提了一个好问题,关于 open_files_limit 和innodb_open_files 的关系,我在回复中作了说明,你们能够看一下。

@万勇 提了一个好问题,实际上对于如今官方的版本,将字段加在中间仍是最后,在性能上是没差异的。可是,我建议你们养成习惯(若是你是 DBA就帮业务开发同窗养成习惯),将字段加在最后面,由于这样仍是比较方便操做的。这个问题,我也在评论的答复中作了说明,你能够看一下。

相关文章
相关标签/搜索