在我看来,能够帮我们扩展一个逻辑的边界的问题,就是好问题。因为通过解决这样的问题,能够加深我们对这个逻辑的理解,或者帮我们关联到另外一个知识点,进而可以帮助我们建立起自己的知识网络。
在工作中会问好问题,是一个很重要的能力。
join的写法
在35篇文章《join语句怎么优化?》中,我在介绍join执行顺序的时候,用的都是strainght_join。有同学哉问候提出了两个问题:
- 如果用left join的话,左边的表一定是驱动表吗?
- 如果两个表的join包含多个条件的等值匹配,是都要写到on里面呢,还是只把一个条件写到on里面,其他条件写到where部分?
为了同时回答这两个问题,我来构造两个表a和b:
1 | create table a(f1 int, f2 int, index(f1))engine=innodb; |
表a和b都有两个字段f1和f2,不同的是表a的字段f1上有索引。然后,我往两个表中都插入了6条记录,其中在表a和b上同时存在的数据有4行。
同学提到的第二个问题,其实就是下面这两种写法的区别:
1 | select * from a left join b on (a.f1=b.f1) and (a.f2=b.f2); /*Q1*/ |
我把这两条语句分别记为Q1和Q2。
首先,需要说明的是,这两个left join语句的语义逻辑并不相同。我们先来看一下它们的执行结果。
可以看到:
- 语句Q1返回的数据集是6行,表a中即使没有满足匹配条件的记录,查询结果中也会返回一行,并将表b的各个字段值填成NULL。
- 语句Q2返回的是4行。从逻辑上可以这么理解,最后的两行,由于表b中没有匹配的字段,结果集里面b.f2的值是空,不满足where部分的条件判断,因此不能作为结果集的一部分。
接下来,我们看看实际执行这两条语句时,MySQL是怎么做的。
我们先一起看看语句Q1的explain结果:
可以看到,这个结果符合我们的预期:
- 驱动表是表a,被驱动表是表b;
- 由于表b的f1字段上没有索引,所以使用的是Block Nested Loop Join(简称BNL)算法。
看到BNL算法,你就应该知道这条语句执行流程其实是这样的:
- 把表a的内容读入joiin_buffer中。因为是select *,所以字段f1和f2都被放入join_buffer了。
- 顺序扫描表b,对于每一行数据,判断join条件(也就是(a.f1=b.f1) and (a.f1=1))是否满足,满足条件的记录,作为结果集的一行返回。如果语句中有where子句,需要先判断where部分满足条件后,再返回。
- 表b扫描完成后,对于没有被匹配的表a的行(在这个例子中就是(1,1)、(2,2)这两行),把剩余字段补上NULL,再放入结果集中。
对应的流程图如下:
可以看到,这条语句确实是以表a为驱动表,而且从执行效果看,也和使用straight_join是一样的。
你可能会想,语句Q2的查询结果里面少了最后两行数据,是不是就是把上面流程中的步骤3去掉呢?我们还是先看一下语句Q2的explain结果吧。
可以看到,这条语句是以表b为驱动表的。而如果一条join语句的Extra字段什么都没写的话,就表示使用的是Index Nested-Loop Join (NLJ) 算法。
因此,语句Q2使用了a的f1索引,执行流程是这样的:顺序扫描表b,每一行用b.f1到表a中去查,匹配到记录后判断a.f2=b.f2是否满足,满足条件的话就作为结果集的一部分返回。
那么,为什么语句Q1和Q2这两个查询的执行流程会差距这么大呢?其实,这是因为优化器基于Q2这个查询的语义做了优化。
为了理解这个问题,我需要再和你交代一个背景知识点:在MySQL里,NULL跟任何值执行等值判断和不等值判断的结果,都是NULL。这里包括,select NULL = NULL的结果,也是返回NULL。
因此,语句Q2里面where a.f2=b.f2就表示,查询结果里面不会包含b.f2是NULL的行,这样这个left join的语义就是"找到这两个表里面,f1、f2对应相同的行。对于表a中存在,而表b中匹配不到的行,就放弃"。
这样,这条语句虽然用的是left join,但是语义跟join是一致的。
因此,优化器就把这条语句的left join改写成了join,然后因为表a的f1上有索引,就把表b作为驱动表,,这样就可以用上NLJ算法。在执行explain之后,你再执行show warnings,就能看到这个改写的结果,如图5所示。
这个例子说明,即使我们在SQL语句中写成left join,执行过程还是有可能不是从左到右连接的。也就是说,使用left join时,左边的表不一定是驱动表。
这样看来,如果需要left join的语义,就不能把被驱动表的字段放在where条件里面做等值判断或不等值判断,必须都写在on里面。那如果是join语句呢?
这时候,我们再看看这两条语句:
1 | select * from a join b on(a.f1=b.f1) and (a.f2=b.f2); /*Q3*/ |
我们再使用一次看explain和show warnings的方法,看看优化器是怎么做的。
可以看到,这两条语句都被改写成:
1 | select * from a join b where (a.f1=b.f1) and (a.f2=b.f2); |
执行计划自然也是一模一样的。
也就是所,在这种情况下,join将判断条件是否全部放在on部分就没有区别了。
Simple Nested Loop Join的性能问题
我们知道,join语句使用不同的算法,对语句的性能影响会很大。在34篇文章《到底可不可以使用join?》的评论区中,有同学提了一个很不错的问题。
我们在文中说道,虽然BNL算法和Simple Nested Loop Join算法都是要判断M*N次(M和N分别是join的两个表的行数),但是Simple Nested Loop Join算法的每轮判断都要走全表扫描,因此性能上BNL算法执行起来会快很多。
为了便于说明,我还是先为你简单描述一下这两个算法。
BNL算法的执行逻辑是:
- 首先,将驱动表的数据全部读入内存join_buffer中,这里join_buffer是无序数组;
- 然后,顺序遍历被驱动表的所有行,每一行数据都跟join_buffer中的数据进行匹配,匹配成功则作为结果集的一部分返回。
Simple Nested Loop join算法的执行逻辑是:顺序取出驱动表中的每一行数据,到被驱动表做全表扫描匹配,匹配成功则作为结果集的一部分返回。
同学的疑问是,Simple Nested Loop Join算法,其实也是把数据读到内存里,然后按照匹配条件进行判断,为什么性能差距会这么大呢?
解释这个问题,需要用到MySQL中索引结构和Buffer Pool的相关知识点:
在对被驱动表做全表扫描的时候,如果数据没有在Buffer Pool中,就需要等待这部分数据从磁盘读入;
从磁盘读入数据到内存中,会影响正常业务的Buffer Pool命中率,而且这个算法天然会对被驱动表的数据做多次访问,更容易将这些数据页放到Buffer Pool的头部(请参考第35篇文章中的相关内容);
即使被驱动表数据都是在内存中,每次查找”下一个记录的操作“,都是类似指针操作。而join_buffer中的是数组,遍历的成本更低。
所以说,BNL算法的性能会更好。
distinct和group by的性能
在37篇文章《什么时候会使用内部临时表?》中,同学提了一个好问题:如果只需要去重,不需要执行聚合函数,distinct和group by哪种效率高一些呢?
我来展开一下他的问题:如果表t的字段a上没有索引,那么下面这两条语句:
1 | select a from t group by a order by null; |
的性能是不是相同的?
首先需要说明的是,这种group by的写法,并不是SQL标准的写法。标准的group by语句,是需要在select部分加一个聚合函数,比如:
1 | selecrt a, count(*) from t group by a order by null; |
这条语句的逻辑是:按照字段a分组,计算每组的a出现次数。在这个结果里,由于做的是聚合计算,相同的a只出现一次。
备注:这里你可以顺便复习一下第37篇文章中关于group by 的相关内容。
没有了count(*)以后,也就是不再需要执行"计算总数"的逻辑时,第一条语句的逻辑就变成是:按照字段a做分组,相同的a的值只返回一行。而这就是distinct的语义,所以不需要执行聚合函数时,distinct和group by这两条语句的语义和执行流程是相同的,因此执行性能也相同。
这两条语句的执行流程是下面这样的:
- 创建一个临时表,临时表有一个字段a,并且在这个字段a上创建一个唯一索引;
- 遍历表t,依次取数据插入临时表中:
- 如果发现唯一键冲突,就跳过;
- 否则插入成功;
- 遍历完成后,将临时表作为结果集返回给客户端。
备库自增主键问题
除了性能问题,大家对细节的追问也很到位。在第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里面直接写了每一行的所有字段的值。
而至于为什么不会发生不一致的情况,我们来看一下下面的这个例子。
1 | create table t(id, it auto_increment primary key); |
可以看到,在insert语句之前,还有一句SET INSERT_ID=1。这条命令的意思是,这个线程里下一次需要用到自增值的时候,不论当前表的自增值是多少,固定用1这个值。
这个SET INSERT_ID语句是固定跟在insert语句之前的,比如同学提到的场景,主库上语句A的id是1,语句B的id是2,但是写入binlog的顺序先B后A,那么binlog就变成:
1 | SET INSERT_ID=2; |
你看,在备库上语句B用到的INSERT_ID依然是2,跟主库相同。
因此,即使两个INSERT语句在主备库的执行顺序不同,自增主键字段的值也不会不一致。
小结
今天这篇答疑文章,我选了4个好问题和你分享,并做了分析。在我看来,能够提出好问题,首先表示这些同学理解了我们文章的内容,进而又做了深入思考。
答疑文章也是要有课后思考题的。
在第8篇文章的评论区,有同学提了一个问题:他查看了一下innodb_trx,发现这个事务的trx_id是一个很大的数(281479535353408),而且似乎在同一个session中启动会话得到的trx_Id是保持不变的。当执行任何加写锁的语句后,trx_id都会变成一个很小的数字(118378)。
你可以通过实验验证一下,然后分析看看,事务id的分配规则是什么,以及MySQL为什么要这么设计呢?
解答: 只读事务id是临时生成的,原本trx_id的值就是0。计算的算法是:把当前事务的trx变量的指针地址转成整数,再加上2^48。
实际上,在T1时刻,session A还没有涉及到更新,是一个只读事务。而对于只读事务,InnoDB并不会分配trx_id。也就是说:
- 在T1时刻,trx_id的值其实就是0。而这个很大的数,只是显示用的。一会儿我会再和你说说这个数据的生成逻辑。
- 直到session A在T3时刻执行insert语句的时候,InnoDB才真正分配了trx_id。所以,T4时刻,session B查到的这个trx_id的值就是1289。
需要注意的是,除了显而易见的修改类语句外,如果select语句后面加上for update,这个事务也不是只读事务。
在上一篇文章的评论区,有同学提出,实验的时候发现不止加1。这是因为:
- update和delete语句除了事务本身,还涉及到标记删除旧数据,也就是要把数据放到purge队列里等待后续的物理删除,这个操作也会把max_trx_id+1,因此在一个事务中至少加2;
- 而InnoDB的后台操作,比如表的索引信息统计这类操作,也是会启动内部事务的,因此你可能看到,trx_id值并不是按照加1递增的。
那么,T2时刻查到的这个很大的数字是怎么来的呢?
其实,这个数字是每次查询的时候由系统临时计算出来的。它的算法是:把当前事务的trx变量的指针地址转成整数,再加上2^48。使用这个算法,就可以保证以下两点:
- 因为同一个只读事务在执行期间,它的指针地址是不会变的,所以不论是在innodb_trx还是在innodb_locks表里,同一个只读事务查出来的trx_id就会是一样的。
- 如果有并行的多个只读事务,每个事务的trx变量的指针地址肯定不同。这样,不同的并发只读事务,查出来的trx_id就是不同的。
那么,为什么还要再加上2^48呢?
在显示值里面加上2^48,目的是保证只读事务显示的trx_id值比较大,正常情况下就会区别于读写事务的id。但是,trx_id跟row_id的逻辑类似,定义长度也是8个字节。因此,在理论上还是可能出现一个读写事务与一个只读事务显示的trx_id相同的情况。不过,这个概率很低,并且也没有什么实质的危害,可以不管它。
另一个问题是,只读事务不分配trx_id,有什么好处呢?
- 一个好处是,这样做可以减小事务视图里面活跃事务数组的大小。因为正在运行的只读事务,是不影响数据的可见性判断的,所以在创建事务的一致性视图时,InnoDB就只需要拷贝读写事务的trx_id;
- 另一个好处是,可以减少trx_id的申请次数。在InnoDB里,即使你只是执行一个普通的select语句,在执行过程中,也是要对应一个只读事务的。所以只读事务优化后,普通的查询语句不需要申请trx_id,就大大减少了并发事务申请trx_id时的锁冲突。
由于只读事务不分配trx_id,一个自然而然的结果就是trx_id的增加速度变慢了。