在上一篇文章中,我和你介绍了 join 语句的两种算法,分别是 Index Nested-LoopJoin(NLJ) 和 Block Nested-Loop Join(BNL)。算法
咱们发如今使用 NLJ 算法的时候,其实效果仍是不错的,比经过应用层拆分红多个语句而后再拼接查询结果更方便,并且性能也不会差。数组
可是,BNL 算法在大表 join 的时候性能就差多了,比较次数等于两个表参与 join 的行数的乘积,很消耗 CPU 资源。bash
固然了,这两个算法都还有继续优化的空间,咱们今天就来聊聊这个话题。数据结构
为了便于分析,我仍是建立两个表 t一、t2 来和你展开今天的问题。oop
为了便于后面量化说明,我在表 t1 里,插入了 1000 行数据,每一行的 a=1001-id 的值。也就是说,表 t1 中字段 a 是逆序的。同时,我在表 t2 中插入了 100 万行数据。性能
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();
在介绍 join 语句的优化方案以前,我须要先和你介绍一个知识点,即:Multi-RangeRead 优化 (MRR)。这个优化的主要目的是尽可能使用顺序读盘。测试
在第 4 篇文章中,我和你介绍 InnoDB 的索引结构时,提到了“回表”的概念。咱们先来回顾一下这个概念。回表是指,InnoDB 在普通索引 a 上查到主键 id 的值后,再根据一个
个主键 id 的值到主键索引上去查整行数据的过程。优化
而后,有同窗在留言区问到,回表过程是一行行地查数据,仍是批量地查数据?咱们先来看看这个问题。假设,我执行这个语句:spa
select * from t1 where a>=1 and a<=100;
主键索引是一棵 B+ 树,在这棵树上,每次只能根据一个主键 id 查到一行数据。所以,回表确定是一行行搜索主键索引的,设计
基本流程如图 1 所示。
图 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 优化的话,须要设置setoptimizer_switch="mrr_cost_based=off"。(官方文档的说法,是如今的优化器策略,判断消耗的时候,
会更倾向于不使用 MRR,把 mrr_cost_based 设置为 off,就是固定使用 MRR 了。)
下面两幅图就是使用了 MRR 优化后的执行流程和 explain 结果。
图 2 MRR 执行流程
图 3 MRR 执行流程的 explain 结果
从图 3 的 explain 结果中,咱们能够看到 Extra 字段多了 Using MRR,表示的是用上了MRR 优化。并且,因为咱们在 read_rnd_buffer 中按照 id 作了排序,因此最后获得的结
果集也是按照主键 id 递增顺序的,也就是与图 1 结果集中行的顺序相反。
到这里,咱们小结一下。
MRR 可以提高性能的核心在于,这条查询语句在索引 a 上作的是一个范围查询(也就是说,这是一个多值查询),能够获得足够多的主键 id。这样经过排序之后,再去主键索引
查数据,才能体现出“顺序性”的优点。
理解了 MRR 性能提高的原理,咱们就能理解 MySQL 在 5.6 版本后开始引入的 BatchedKey Acess(BKA) 算法了。这个 BKA 算法,其实就是对 NLJ 算法的优化。
咱们再来看看上一篇文章中用到的 NLJ 算法的流程图:
图 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 算法的流程。
图 5 Batched Key Acess 流程
图中,我在 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';
说完了 NLJ 算法的优化,咱们再来看 BNL 算法的优化。
我在上一篇文章末尾,给你留下的思考题是,使用 Block Nested-Loop Join(BNL) 算法时,可能会对被驱动表作屡次扫描。若是这个被驱动表是一个大的冷数据表,除了会致使
IO 压力大之外,还会对系统有什么影响呢?
在第 33 篇文章中,咱们说到 InnoDB 的 LRU 算法的时候提到,因为 InnoDB 对 BuffferPool 的 LRU 算法作了优化,即:第一次从磁盘读入内存的数据页,会先放在 old 区域。
若是 1 秒以后这个数据页再也不被访问了,就不会被移动到 LRU 链表头部,这样对 BufferPool 的命中率影响就不大。
可是,若是一个使用 BNL 算法的 join 语句,屡次扫描一个冷表,并且这个语句执行时间超过 1 秒,就会在再次扫描冷表的时候,把冷表的数据页移到 LRU 链表头部。
这种状况对应的,是冷表的数据量小于整个 Buffer Pool 的 3/8,可以彻底放入 old 区域的状况。
若是这个冷表很大,就会出现另一种状况:业务正常访问的数据页,没有机会进入young 区域。
因为优化机制的存在,一个正常访问的数据页,要进入 young 区域,须要隔 1 秒后再次被访问到。可是,因为咱们的 join 语句在循环读磁盘和淘汰内存页,进入 old 区域的数据
页,极可能在 1 秒以内就被淘汰了。这样,就会致使这个 MySQL 实例的 Buffer Pool 在这段时间内,young 区域的数据页没有被合理地淘汰。
也就是说,这两种状况都会影响 Buffer Pool 的正常运做。
大表 join 操做虽然对 IO 有影响,可是在语句执行结束后,对 IO 的影响也就结束了。可是,对为了减小这种影响,你能够考虑增大 join_buffer_size 的值,减小对被驱动表的扫描次数。
也就是说,BNL 算法对系统的影响主要包括三个方面:
1. 可能会屡次扫描被驱动表,占用磁盘 IO 资源;
2. 判断 join 条件须要执行 M*N 次对比(M、N 分别是两张表的行数),若是是大表就会占用很是多的 CPU 资源;
3. 可能会致使 Buffer Pool 的热数据被淘汰,影响内存命中率。
咱们执行语句以前,须要经过理论分析和查看 explain 结果的方式,确认是否要使用 BNL算法。若是确认优化器会使用 BNL 算法,就须要作优化。优化的常见作法是,给被驱动表
的 join 字段加上索引,把 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 中的数据进行对比,
我在上一篇文章中说过,对于表 t2 的每一行,判断 join 是否知足的时候,都须要遍历join_buffer 中的全部行。所以判断等值条件的次数是 1000*100 万 =10 亿次,这个判断
的工做量很大。
图 6 explain 结果
图 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 就是这个语句序列的执行效果。
图 8 使用临时表的执行效果
能够看到,整个过程 3 个语句执行时间的总和还不到 1 秒,相比于前面的 1 分 11 秒,性能获得了大幅提高。接下来,咱们一块儿看一下这个过程的消耗:
整体来看,不管是在原表上加索引,仍是用有索引的临时表,咱们的思路都是让 join 语句可以用上被驱动表上的索引,来触发 BKA 算法,提高查询性能。
看到这里你可能发现了,其实上面计算 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-LoopJoin(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;
如今为了获得最快的执行速度,若是让你来设计表 t一、t二、t3 上的索引,来支持这个 join语句,你会加哪些索引呢?
同时,若是我但愿你用 straight_join 来重写这个语句,配合你建立的索引,你就须要安排链接顺序,你主要考虑的因素是什么呢?
你能够把你的方案和分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一块儿阅读。
我在上篇文章最后留给你的问题,已经在本篇文章中解答了。
这里我再根据评论区留言的状况,简单总结下。根据数据量的大小,有这么两种状况:
@长杰 和 @老杨同志 提到了数据量小于 old 区域内存的状况;
@Zzz 同窗,很认真地看了其余同窗的评论,而且提了一个很深的问题。对被驱动表数据量大于 Buffer Pool 的场景,作了很细致的推演和分析。
给这些同窗点赞,很是好的思考和讨论