gitbook/MySQL实战45讲/docs/80147.md
2022-09-03 22:05:03 +08:00

312 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 35 | join语句怎么优化?
在上一篇文章中,我和你介绍了join语句的两种算法,分别是Index Nested-Loop Join(NLJ)和Block Nested-Loop Join(BNL)。
我们发现在使用NLJ算法的时候,其实效果还是不错的,比通过应用层拆分成多个语句然后再拼接查询结果更方便,而且性能也不会差。
但是,BNL算法在大表join的时候性能就差多了,比较次数等于两个表参与join的行数的乘积,很消耗CPU资源。
当然了,这两个算法都还有继续优化的空间,我们今天就来聊聊这个话题。
为了便于分析,我还是创建两个表t1、t2来和你展开今天的问题。
```
create table t1(id int primary key, a int, b int, index(a));
create table t2 like t1;
drop procedure idata;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=1000)do
insert into t1 values(i, 1001-i, i);
set i=i+1;
end while;
set i=1;
while(i<=1000000)do
insert into t2 values(i, i, i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();
```
为了便于后面量化说明,我在表t1里,插入了1000行数据,每一行的a=1001-id的值。也就是说,表t1中字段a是逆序的。同时,我在表t2中插入了100万行数据。
# Multi-Range Read优化
在介绍join语句的优化方案之前,我需要先和你介绍一个知识点,即:Multi-Range Read优化(MRR)。这个优化的主要目的是尽量使用顺序读盘。
在[第4篇文章](https://time.geekbang.org/column/article/69236)中,我和你介绍InnoDB的索引结构时,提到了“回表”的概念。我们先来回顾一下这个概念。回表是指,InnoDB在普通索引a上查到主键id的值后,再根据一个个主键id的值到主键索引上去查整行数据的过程。
然后,有同学在留言区问到,回表过程是一行行地查数据,还是批量地查数据?
我们先来看看这个问题。假设,我执行这个语句:
```
select * from t1 where a>=1 and a<=100;
```
主键索引是一棵B+树,在这棵树上,每次只能根据一个主键id查到一行数据。因此,回表肯定是一行行搜索主键索引的,基本流程如图1所示。
![](https://static001.geekbang.org/resource/image/97/05/97ae269061192f6d7a632df56fa03605.png)
图1 基本回表流程
如果随着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优化后的执行流程和explain结果。
![](https://static001.geekbang.org/resource/image/d5/c7/d502fbaea7cac6f815c626b078da86c7.jpg)
图2 MRR执行流程
![](https://static001.geekbang.org/resource/image/a5/32/a513d07ebaf1ae044d44391c89bc6432.png)
图3 MRR执行流程的explain结果
从图3的explain结果中,我们可以看到Extra字段多了Using MRR,表示的是用上了MRR优化。而且,由于我们在read\_rnd\_buffer中按照id做了排序,所以最后得到的结果集也是按照主键id递增顺序的,也就是与图1结果集中行的顺序相反。
到这里,我们小结一下。
**MRR能够提升性能的核心**在于,这条查询语句在索引a上做的是一个范围查询(也就是说,这是一个多值查询),可以得到足够多的主键id。这样通过排序以后,再去主键索引查数据,才能体现出“顺序性”的优势。
# Batched Key Access
理解了MRR性能提升的原理,我们就能理解MySQL在5.6版本后开始引入的Batched Key Access(BKA)算法了。这个BKA算法,其实就是对NLJ算法的优化。
我们再来看看上一篇文章中用到的NLJ算法的流程图:
![](https://static001.geekbang.org/resource/image/10/3d/10e14e8b9691ac6337d457172b641a3d.jpg)
图4 Index Nested-Loop Join流程图
NLJ算法执行的逻辑是:从驱动表t1,一行行地取出a的值,再到被驱动表t2去做join。也就是说,对于表t2来说,每次都是匹配一个值。这时,MRR的优势就用不上了。
那怎么才能一次性地多传些值给表t2呢?方法就是,从表t1里一次性地多拿些行出来,一起传给表t2。
既然如此,我们就把表t1的数据取出来一部分,先放到一个临时内存。这个临时内存不是别人,就是join\_buffer。
通过上一篇文章,我们知道join\_buffer 在BNL算法里的作用,是暂存驱动表的数据。但是在NLJ算法里并没有用。那么,我们刚好就可以复用join\_buffer到BKA算法中。
如图5所示,是上面的NLJ算法优化后的BKA算法的流程。
![](https://static001.geekbang.org/resource/image/68/88/682370c5640244fa3474d26cc3bc0388.png)
图5 Batched Key Access流程
图中,我在join\_buffer中放入的数据是P1~P100,表示的是只会取查询需要的字段。当然,如果join buffer放不下P1~P100的所有数据,就会把这100行数据分成多段执行上图的流程。
那么,这个BKA算法到底要怎么启用呢?
如果要使用BKA优化算法的话,你需要在执行SQL语句之前,先设置
```
set optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on';
```
其中,前两个参数的作用是要启用MRR。这么做的原因是,BKA算法的优化要依赖于MRR。
# BNL算法的性能问题
说完了NLJ算法的优化,我们再来看BNL算法的优化。
我在上一篇文章末尾,给你留下的思考题是,使用Block Nested-Loop Join(BNL)算法时,可能会对被驱动表做多次扫描。如果这个被驱动表是一个大的冷数据表,除了会导致IO压力大以外,还会对系统有什么影响呢?
在[第33篇文章](https://time.geekbang.org/column/article/79407)中,我们说到InnoDB的LRU算法的时候提到,由于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的热数据被淘汰,影响内存命中率。
我们执行语句之前,需要通过理论分析和查看explain结果的方式,确认是否要使用BNL算法。如果确认优化器会使用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;
```
我们在文章开始的时候,在表t2中插入了100万行数据,但是经过where条件过滤后,需要参与join的只有2000行数据。如果这条语句同时是一个低频的SQL语句,那么再为这个语句在表t2的字段b上创建一个索引就很浪费了。
但是,如果使用BNL算法来join的话,这个语句的执行流程是这样的:
1. 把表t1的所有字段取出来,存入join\_buffer中。这个表只有1000行,join\_buffer\_size默认值是256k,可以完全存入。
2. 扫描表t2,取出每一行数据跟join\_buffer中的数据进行对比,
* 如果不满足t1.b=t2.b,则跳过;
* 如果满足t1.b=t2.b, 再判断其他条件,也就是是否满足t2.b处于\[1,2000\]的条件,如果是,就作为结果集的一部分返回,否则跳过。
我在上一篇文章中说过,对于表t2的每一行,判断join是否满足的时候,都需要遍历join\_buffer中的所有行。因此判断等值条件的次数是1000\*100万=10亿次,这个判断的工作量很大。
![](https://static001.geekbang.org/resource/image/92/60/92fbdbfc35da3040396401250cb33f60.png)
图6 explain结果
![](https://static001.geekbang.org/resource/image/d8/9c/d862bc3e88305688df2c354a4b26809c.png)
图7 语句执行时间
可以看到,explain结果里Extra字段显示使用了BNL算法。在我的测试环境里,这条语句需要执行1分11秒。
在表t2的字段b上创建索引会浪费资源,但是不创建索引的话这个语句的等值条件要判断10亿次,想想也是浪费。那么,有没有两全其美的办法呢?
这时候,我们可以考虑使用临时表。使用临时表的大致思路是:
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);
```
图8就是这个语句序列的执行效果。
![](https://static001.geekbang.org/resource/image/a8/c7/a80cdffe8173fa0fd8969ed976ac6ac7.png)
图8 使用临时表的执行效果
可以看到,整个过程3个语句执行时间的总和还不到1秒,相比于前面的1分11秒,性能得到了大幅提升。接下来,我们一起看一下这个过程的消耗:
1. 执行insert语句构造temp\_t表并插入数据的过程中,对表t2做了全表扫描,这里扫描行数是100万。
2. 之后的join语句,扫描表t1,这里的扫描行数是1000;join比较过程中,做了1000次带索引的查询。相比于优化前的join语句需要做10亿次条件判断来说,这个优化效果还是很明显的。
总体来看,不论是在原表上加索引,还是用有索引的临时表,我们的思路都是让join语句能够用上被驱动表上的索引,来触发BKA算法,提升查询性能。
# 扩展-hash join
看到这里你可能发现了,其实上面计算10亿次那个操作,看上去有点儿傻。如果join\_buffer里面维护的不是一个无序数组,而是一个哈希表的话,那么就不是10亿次判断,而是100万次hash查找。这样的话,整条语句的执行速度就快多了吧?
确实如此。
这,也正是MySQL的优化器和执行器一直被诟病的一个原因:不支持哈希join。并且,MySQL官方的roadmap,也是迟迟没有把这个优化排上议程。
实际上,这个优化思路,我们可以自己实现在业务端。实现流程大致如下:
1. `select * from t1;`取得表t1的全部1000行数据,在业务端存入一个hash结构,比如C++里的set、PHP的数组这样的数据结构。
2. `select * from t2 where b>=1 and b<=2000;` 获取表t2中满足条件的2000行数据。
3. 把这2000行数据,一行一行地取到业务端,到hash结构的数据表中寻找匹配的数据。满足匹配的条件的这行数据,就作为结果集的一行。
理论上,这个过程会比临时表方案的执行速度还要快一些。如果你感兴趣的话,可以自己验证一下。
# 小结
今天,我和你分享了Index Nested-Loop Join(NLJ)和Block Nested-Loop Join(BNL)的优化方法。
在这些优化方法中:
1. BKA优化是MySQL已经内置支持的,建议你默认使用;
2. BNL算法效率低,建议你都尽量转成BKA算法。优化的方向就是给被驱动表的关联字段加上索引;
3. 基于临时表的改进方案,对于能够提前过滤出小数据的join语句来说,效果还是很好的;
4. MySQL目前的版本还不支持hash join,但你可以配合应用端自己模拟出来,理论上效果要好于临时表的方案。
最后,我给你留下一道思考题吧。
我们在讲join语句的这两篇文章中,都只涉及到了两个表的join。那么,现在有一个三个表join的需求,假设这三个表的表结构如下:
```
CREATE TABLE `t1` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
create table t2 like t1;
create table t3 like t2;
insert into ... //初始化三张表的数据
```
语句的需求实现如下的join逻辑:
```
select * from t1 join t2 on(t1.a=t2.a) join t3 on (t2.b=t3.b) where t1.c>=X and t2.c>=Y and t3.c>=Z;
```
现在为了得到最快的执行速度,如果让你来设计表t1、t2、t3上的索引,来支持这个join语句,你会加哪些索引呢?
同时,如果我希望你用straight\_join来重写这个语句,配合你创建的索引,你就需要安排连接顺序,你主要考虑的因素是什么呢?
你可以把你的方案和分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
我在上篇文章最后留给你的问题,已经在本篇文章中解答了。
这里我再根据评论区留言的情况,简单总结下。根据数据量的大小,有这么两种情况:
* @长杰 和 @老杨同志 提到了数据量小于old区域内存的情况;
* @Zzz 同学,很认真地看了其他同学的评论,并且提了一个很深的问题。对被驱动表数据量大于Buffer Pool的场景,做了很细致的推演和分析。
给这些同学点赞,非常好的思考和讨论。