书接上文《 SQL夯实基础(八):联接运算符算法归类》。html
这里先解释下EXPLAIN 结果中,第一行出现的表就是驱动表(Important!)。mysql
对驱动表能够直接排序,对非驱动表(的字段排序)须要对循环查询的合并结果(临时表)进行排序(Important!)算法
为了后面一些测试案例,咱们事先建立了两张表,表数据以下:sql
CREATE TABLE t1 (m1 int, n1 char(1)); CREATE TABLE t2 (m2 int, n2 char(1)); INSERT INTO t1 VALUES(1, 'a'), (2, 'b'), (3, 'c'); INSERT INTO t2 VALUES(2, 'b'), (3, 'c'), (4, 'd'), (5, 'e'), (6, 'f');
联接操做的本质就是把各个联接表中的记录都取出来依次匹配的组合加入结果集并返回给用户。若是没有任何限制条件的话,多表联接起来产生的笛卡尔积多是很是巨大的。比方说3个100行记录的表联接起来产生的笛卡尔积就有100×100×100=1000000行数据!因此在联接的时候过滤掉特定记录组合是有必要的,在联接查询中的过滤条件能够分红两种,咱们以一个JOIN查询为例:数据库
SELECT * FROM t1, t2 WHERE t1.m1 > 1 AND t1.m1 = t2.m2 AND t2.n2 < 'd';
1 涉及单表的条件数组
WHERE条件也能够称为搜索条件,好比t1.m1 > 1是只针对t1表的过滤条件,t2.n2 < ‘d’是只针对t2表的过滤条件。缓存
2 涉及两表的条件mysql优化
好比t1.m1 = t2.m二、t1.n1 > t2.n2等,这些条件中涉及到了两个表,咱们稍后会仔细分析这种过滤条件是如何使用的。函数
在这个查询中咱们指明了这三个过滤条件:oop
1. t1.m1 > 1
2. t1.m1 = t2.m2
3. t2.n2 < ‘d’
那么这个联接查询的大体执行过程以下:
首先肯定第一个须要查询的表,这个表称之为驱动表。怎样在单表中执行查询语句,只须要选取代价最小的那种访问方法去执行单表查询语句就行了(就是说从const、ref、ref_or_null、range、index、all这些执行方法中选取代价最小的去执行查询)。此处假设使用t1做为驱动表,那么就须要到t1表中找知足t1.m1 > 1的记录,假设这里并无给t1字段添加索引,因此此处查询t1表的访问方法就设定为all吧,也就是采用全表扫描的方式执行单表查询。关于如何提高联接查询的性能咱们以后再说,如今先把基本概念捋清楚哈。因此查询过程就以下图所示:
针对上一步骤中从驱动表产生的结果集中的每一条记录,分别须要到t2表中查找匹配的记录,所谓匹配的记录,指的是符合过滤条件的记录。由于是根据t1表中的记录去找t2表中的记录,因此t2表也能够被称之为被驱动表(非驱动表)。好比上一步骤从驱动表中获得了2条记录,因此须要查询2次t2表。此时涉及两个表的列的过滤条件t1.m1 = t2.m2就派上用场了:
当t1.m1 = 2时,过滤条件t1.m1 = t2.m2就至关于t2.m2 = 2,因此此时t2表至关于有了t1.m1 = 二、t2.n2 < ‘d’这两个过滤条件,而后到t2表中执行单表查询。
当t1.m1 = 3时,过滤条件t1.m1 = t2.m2就至关于t2.m2 = 3,因此此时t2表至关于有了t1.m1 = 三、t2.n2 < ‘d’这两个过滤条件,而后到t2表中执行单表查询。
因此整个联接查询的执行过程就以下图所示:
也就是说整个联接查询最后的结果只有两条符合过滤条件的记录:
+------+------+------+------+ | m1 | n1 | m2 | n2 | +------+------+------+------+ | 2 | b | 2 | b | | 3 | c | 3 | c | +------+------+------+------+
从上边两个步骤能够看出来,咱们上边说的这个两表联接查询共须要查询1次t1表,2次t2表。固然这是在特定的过滤条件下的结果,若是咱们把t1.m1 > 1这个条件去掉,那么从t1表中查出的记录就有3条,就须要查询3次t2表了。也就是说在两表联接查询中,驱动表只须要访问一次,被驱动表可能被访问屡次,这种方式在MySQL中有一个专有名词,叫Nested-Loops Join(嵌套循环联接)。咱们在真正使用MySQL的时候表动不动就是几百上千万数据,若是都按照Nested-Loops Join算法,一次Join查询的代价也太大了。因此下面就来看看MySQL支持的Join算法都有哪些?
联接算法是MySQL数据库用于处理联接的物理策略。目前MySQL数据库仅支持Nested-Loops Join算法。而MySQL的分支版本MariaDB除了支持Nested-Loops Join算法外,还支持Classic Hash Join算法。当联接的表上有索引时,Nested-Loops Join是很是高效的算法。根据B+树的特性,其联接的时间复杂度为O(N),若没有索引,则可视为最坏的状况,时间复杂度为O(N²)。MySQL数据库根据不一样的使用场合,支持两种Nested-Loops Join算法,一种是Simple Nested-Loops Join(NLJ)算法,另外一种是Block Nested-Loops Join(BNL)算法。
在讲述MySQL的Join类型与算法前,看看两张表的Join的过程:
上图的Fetch阶段是指当内表关联的列是辅助索引时,可是须要访问表中的数据,那么这时就须要再访问主键索引才能获得数据的过程,不论表的存储引擎是InnoDB存储引擎仍是MyISAM,这都是没法避免的,只是MyISAM的回表速度要快点,由于其辅助索引存放的就是指向记录的指针,而InnoDB存储引擎是索引组织表,须要再次经过索引查找才能定位数据。
Fetch阶段也不是必须存在的,若是是汇集索引联接,那么直接就能获得数据,无需回表,也就没有Fetch这个阶段。另外,上述给出了两张表之间的Join过程,多张表的Join就是继续上述这个过程。
接着计算两张表Join的成本,这里有下列几种概念:
外表的扫描次数,记为O。一般外表的扫描次数都是1,即Join时扫描一次外表(驱动表)的数据便可
内表的扫描次数,记为I。根据不一样Join算法,内表(被驱动表)的扫描次数不一样
读取表的记录数,记为R。根据不一样Join算法,读取记录的数量可能不一样
Join的比较次数,记为M。根据不一样Join算法,比较次数不一样
回表的读取记录的数,记为F。若Join的是辅助索引,可能须要回表取得最终的数据
评判一个Join算法是否优劣,就是查看上述这些操做的开销是否比较小。固然,这还要考虑I/O的访问方式,顺序仍是随机,总之Join的调优也是门艺术,并不是想象的那么简单。
Simple Nested-Loops Join算法至关简单、直接。即外表(驱动表)中的每一条记录与内表(被驱动表)中的记录进行比较判断。对于两表联接来讲,驱动表只会被访问一遍,但被驱动表却要被访问到好多遍,具体访问几遍取决于对驱动表执行单表查询后的结果集中的记录条数。对于内联接来讲,选取哪一个表为驱动表都不要紧,而外联接的驱动表是固定的,也就是说左(外)联接的驱动表就是左边的那个表,右(外)联接的驱动表就是右边的那个表。(这个只是通常状况,也有左联接驱动表选择右边的表,mysql优化器有时候会进行优化)
用伪代码表示一下这个过程就是这样:
For each row r in R do -- 扫描R表(驱动表) For each row s in S do -- 扫描S表(被驱动表) If r and s satisfy the join condition -- 若是r和s知足join条件 Then output the tuple <r, s> -- 返回结果集
下图能更好地显示整个SNLJ的过程:
其中R表为外部表(Outer Table),S表为内部表(Inner Table)。这是一个最简单的算法,这个算法的开销其实很是大。假设在两张表R和S上进行联接的列都不含有索引,外表的记录数为RN,内表的记录数位SN。根据上一节对于Join算法的评判标准来看,SNLJ的开销以下表所示:
能够看到读取记录数的成本和比较次数的成本都是SN*RN,也就是笛卡儿积。假设外表内表都是1万条记录,那么其读取的记录数量和Join的比较次数都须要上亿。实际上数据库并不会使用到SNLJ算法。
SNLJ算法虽然简单明了,可是也是至关的粗暴,须要屡次访问内表(每一次都是全表扫描)。所以,在Join的优化时候,一般都会建议在内表创建索引,以此下降Nested-Loop Join算法的开销,减小内表扫描次数,MySQL数据库中使用较多的就是这种算法,如下称为INLJ。来看这种算法的伪代码:
For each row r in R do -- 扫描R表 lookup s in S index -- 查询S表的索引(固定3~4次IO,B+树高度) If find s == r -- 若是r匹配了索引s Then output the tuple <r, s> -- 返回结果集
因为内表上有索引,因此比较的时候再也不须要一条条记录进行比较,而能够经过索引来减小比较,从而加速查询。整个过程以下图所示:
能够看到外表中的每条记录经过内表的索引进行访问,就是读取外部表一行数据,而后去内部表索引进行二分查找匹配;而通常B+树的高度为3~4层,也就是说匹配一次的io消耗也就3~4次,所以索引查询的成本是比较固定的,故优化器都倾向于使用记录数少的表做为外表(这里是否又会存在潜在的问题呢?)。故INLJ的算法成本以下表所示:
上表Smatch表示经过索引找到匹配的记录数量。同时能够发现,经过索引能够大幅下降内表的Join的比较次数,每次比较1条外表的记录,其实就是一次indexlookup(索引查找),而每次index lookup的成本就是树的高度,即IndexHeight。
INLJ的算法并不复杂,也算简单易懂。可是效率是否能达到用户的预期呢?其实若是是经过表的主键索引进行Join,即便是大数据量的状况下,INLJ的效率亦是至关不错的。由于索引查找的开销很是小,而且访问模式也是顺序的(假设大多数汇集索引的访问都是比较顺序的)。
大部分人诟病MySQL的INLJ慢,主要是由于在进行Join的时候可能用到的索引并非主键的汇集索引,而是辅助索引,这时INLJ的过程又须要多一步Fetch的过程,并且这个过程开销会至关的大:
因为访问的是辅助索引,若是查询须要访问汇集索引上的列,那么必要须要进行回表取数据,看似每条记录只是多了一次回表操做,但这才是INLJ算法最大的弊端。首先,辅助索引的index lookup是比较随机I/O访问操做。其次,根据index lookup再进行回表又是一个随机的I/O操做。因此说,INLJ最大的弊端是其可能须要大量的离散操做,这在SSD出现以前是最大的瓶颈。而即便SSD的出现大幅提高了随机的访问性能,可是对比顺序I/O,其仍是慢了不少,依然不在一个数量级上。
另外,在INNER JOIN中,两张联接表的顺序是能够变换的,即R INNER JOIN S ON Condition P等效于S INNER JOIN R ON Condition P。根据前面描述的Simple Nested-Loops Join算法,优化器在通常状况下老是选择将联接列含有索引的表做为内部表。若是两张表R和S在联接列上都有索引,而且索引的高度相同,那么优化器会选择记录数少的表做为外部表,这是由于内部表的扫描次数老是索引的高度,与记录的数量无关。因此,联接列只要有一个字段有索引便可,但最好是数据集多的表有索引;可是,但有WHERE条件的时候又另当别论了。
而后咱们给上面的 t1.m1 和 t2.m2 分别添加主键,看一下下面这个内联接的执行计划:
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.m1 = t2.m2;
能够看到执行计划是将 t1 表做为驱动表,将 t2 表做为被驱动表,由于对 t2.m2 列的条件是等值查找,好比 t2.m2=二、t2.m2=3 等,因此MySQL把在联接查询中对被驱动表使用主键值或者惟一二级索引列(二级索引也叫非聚簇索引,惟一就是unique属性)的值进行等值查找的查询执行方式称之为eq_ref。
Tips:若是被驱动表使用了非惟一二级索引列的值进行等值查询,则查询方式为 ref。另外,若是被驱动表使用了主键或者惟一二级索引列的值进行等值查找,但主键或惟一二级索引若是有多个列的话,则查询类型也会变成 ref。
有时候联接查询的查询列表和过滤条件中可能只涉及被驱动表的部分列,而这些列都是某个索引的一部分,这种状况下即便不能使用eq_ref、ref、ref_or_null或者range这些访问方法执行对被驱动表的查询的话,也可使用索引扫描,也就是index的访问方法来查询被驱动表。因此咱们建议在真实工做中最好不要使用*做为查询列表,最好把真实用到的列做为查询列表。
这里为何将 t1 做为驱动表?由于表 t1 中的记录少于表 t2,这样联接须要匹配的次数就少了,因此SQL优化器选择表 t1 做为驱动表。
若咱们执行的SQL带有WHERE条件时呢?看看不同的执行计划。若是条件为表 t1 的主键,执行计划以下:
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.m1 = t2.m2 WHERE t1.m1 = 2;
能够看到执行计划算是极优,同时 t1 表仍是驱动表,由于通过WHERE条件过滤后的数据只有一条(咱们知道在单表中使用主键值或者惟一二级索引列的值进行等值查找的方式称之为const,因此咱们能够看到 t1 的type为const;若是这里条件为 t1.m1 > 1,那么天然 type 就为 range),同时 t2.m2 也是主键,天然只有一条数据,type也为const。
若是WHERE条件是一个没有索引的字段呢?执行计划以下:
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.m1 = t2.m2 WHERE t1.n1='a';
从执行计划上看跟不加WHERE条件几乎差很少,可是能够看到filtered为33%了,而不是100%,说明须要返回的数据量变少了。另外Extra字段中标识使用了WHERE条件过滤。
若是WHERE条件是一个有索引的字段呢(好比给 t2.n2 添加一个非惟一二级索引)?这里就不得不提MySQL一个很是重要的特性了,pushed-down conditions(条件下推)优化。就是把索引条件下推到存储引擎层进行数据的过滤并返回过滤后的数据。那么此时的执行计划就以下:
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.m1 = t2.m2 WHERE t2.n2='a';
能够看到 t2 表成为了驱动表(通过二级索引过滤后数据只有1条,因此这里使用到ref的访问方法)。
若是咱们把 t2.n2 换为范围查询呢?看执行计划以下:
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.m1 = t2.m2 WHERE t2.n2>'a';
能够看到虽然WHERE条件有索引,但因为t2.n2>’a’ 过滤后的数据仍是比 t1 表多,因此优化器就选择了 t1 表做为驱动表。而此时 t2 表的查询条件相似以下:
SELECT * FROM t2 WHERE t2.m2 = 1 AND t2.n2 > 'a';
因为 t2.m2 是主键,t2.n2 有二级索引,优化器平衡了一下,可能以为 t2.n2 过滤后的数据占全表比例太大,回表的成本比直接访问主键成本要高,因此就直接使用了主键。若是说 t2.n2 过滤后的数据占全表数据比例较小,是有可能会选择 idx_n2 索引。
最后,咱们使用 t1.n1 与 t2.n2 做为条件,看一下执行计划以下:
mysql> EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.n1 = t2.n2;
一切按照咱们预想的结果在工做,就是因为 t2.n2 不是主键或惟一索引,type类型变成了 ref。
Tips:虽然在INNER JOIN中可使用pushed-down conditions的优化方式,可是不能直接在OUTER JOIN中使用该方式,由于有些不知足联接条件的记录会经过外部表行的方式再次添加到结果中,所以须要有条件地使用pushed-down conditions的优化。在优化器内部对于联接查询会设置一个标志来表示是否启用pushed-down conditions的过滤。
扫描一个表的过程实际上是先把这个表从磁盘上加载到内存中,而后从内存中比较匹配条件是否知足。但内存里可能并不能彻底存放的下表中全部的记录,因此在扫描表前边记录的时候后边的记录可能还在磁盘上,等扫描到后边记录的时候可能内存不足,因此须要把前边的记录从内存中释放掉。咱们前边又说过,采用Simple Nested-Loop Join算法的两表联接过程当中,被驱动表但是要被访问好屡次的,若是这个被驱动表中的数据特别多并且不能使用索引进行访问,那就至关于要从磁盘上读好几回这个表,这个I/O代价就很是大了,因此咱们得想办法:尽可能减小访问被驱动表的次数。
当被驱动表中的数据很是多时,每次访问被驱动表,被驱动表的记录会被加载到内存中,在内存中的每一条记录只会和驱动表结果集的一条记录作匹配,以后就会被从内存中清除掉。而后再从驱动表结果集中拿出另外一条记录,再一次把被驱动表的记录加载到内存中一遍,周而复始,驱动表结果集中有多少条记录,就得把被驱动表从磁盘上加载到内存中多少次。因此咱们可不能够在把被驱动表的记录加载到内存的时候,一次性和多条驱动表中的记录作匹配,这样就能够大大减小重复从磁盘上加载被驱动表的代价了。这也就是Block Nested-Loop Join算法的思想。
也就是说在有索引的状况下,MySQL会尝试去使用Index Nested-Loop Join算法,在有些状况下,可能Join的列就是没有索引,那么这时MySQL的选择绝对不会是最早介绍的Simple Nested-Loop Join算法,由于那个算法太粗暴,不忍直视。数据量大些的复杂SQL估计几年均可能跑不出结果。而Block Nested-Loop Join算法较Simple Nested-Loop Join的改进就在于能够减小内表的扫描次数,甚至能够和Hash Join算法同样,仅需扫描内表一次。其使用Join Buffer(联接缓冲)来减小内部循环读取表的次数。
For each tuple r in R do -- 扫描外表R store used columns as p from R in Join Buffer -- 将部分或者所有R的记录保存到Join Buffer中,记为p For each tuple s in S do -- 扫描内表S If p and s satisfy the join condition -- p与s知足join条件 Then output the tuple -- 返回为结果集
能够看到相比Simple Nested-Loop Join算法,Block Nested-LoopJoin算法仅多了一个所谓的Join Buffer,为何这样就能减小内表的扫描次数呢?下图相比更好地解释了Block Nested-Loop Join算法的运行过程:
能够看到Join Buffer用以缓存联接须要的列(因此再次提醒咱们,最好不要把*做为查询列表,只须要把咱们关心的列放到查询列表就行了,这样还能够在join buffer中放置更多的记录呢),而后以Join Buffer批量的形式和内表中的数据进行联接比较。就上图来看,记录r1,r2 … rT的联接仅需扫内表一次,若是join buffer能够缓存全部的外表列,那么联接仅需扫描内外表各一次,从而大幅提高Join的性能。
MySQL数据库使用Join Buffer的原则以下:
* 系统变量Join_buffer_size决定了Join Buffer的大小。
* Join Buffer可被用于联接是ALL、index、和range的类型。
* 每次联接使用一个Join Buffer,所以多表的联接可使用多个Join Buffer。
* Join Buffer在联接发生以前进行分配,在SQL语句执行完后进行释放。
* Join Buffer只存储要进行查询操做的相关列数据,而不是整行的记录。
因此,Join Buffer并非那么好用的。首先变量join_buffer_size用来控制Join Buffer的大小,调大后能够避免屡次的内表扫描,从而提升性能。也就是说,当MySQL的Join有使用到Block Nested-Loop Join,那么调大变量join_buffer_size才是有意义的。而前面的Index Nested-Loop Join若是仅使用索引进行Join,那么调大这个变量则毫无心义。
变量join_buffer_size的默认值是256K,显然对于稍复杂的SQL是不够用的。好在这个是会话级别的变量,能够在执行前进行扩展。建议在会话级别进行设置,而不是全局设置,由于很难给一个通用值去衡量。另外,这个内存是会话级别分配的,若是设置很差容易致使因没法分配内存而致使的宕机问题。
另外,Join Buffer缓存的对象是什么,这个问题至关关键和重要。然在MySQL的官方手册中是这样记录的:Only columns of interest to the join are stored in the join buffer, not whole rows.
能够发现Join Buffer不是缓存外表的整行记录,而是缓存“columns of interest”,具体指全部参与查询的列都会保存到Join Buffer,而不是只有Join的列。好比下面的SQL语句,假设没有索引,须要使用到Join Buffer进行连接:
SELECT a.col3 FROM a, b WHERE a.col1 = b.col2 AND a.col2 > …. AND b.col2 = …
假设上述SQL语句的外表是a,内表是b,那么存放在Join Buffer中的列是全部参与查询的列,在这里就是(a.col1,a.col2,a.col3)。
经过上面的介绍,咱们如今能够获得内表的扫描次数为:
Scaninner_table = (RN * used_column_size) / join_buffer_size + 1
对于有经验的DBA就能够预估须要分配的Join Buffer大小,而后尽可能使得内表的扫描次数尽量的少,最优的状况是只扫描内表一次。
须要牢记的是,Join Buffer是在Join以前就进行分配,而且每次Join就须要分配一次Join Buffer,因此假设有N张表参与Join,每张表之间经过Block Nested-Loop Join,那么总共须要分配N-1个Join Buffer,这个内存容量是须要DBA进行考量的。
在MySQL 5.6(包括MariaDB 5.3)中,优化了Join Buffer在多张表之间联接的内存使用效率。MySQL 5.6将Join Buffer分为Regular join buffer和Incremental join buffer。假设B1是表t1和t2联接使用的Join Buffer,B2是t1和t2联接产生的结果和表t3进行联接使用的join buffer,那么:
* 若是B2是Regular join buffer,那么B2就会包含B1的Join Buffer中r1相关的列,以及表t2中相关的列。
* 若是B2是Incremental join buffer,那么B2包含表t2中的数据及一个指针,该指针指向B1中r1相对应的数据。
所以,对于第一次联接的表,使用的都是Regular join buffer,以后再联接,则使用Incremental join buffer。又由于Incremental join buffer只包含指向以前Join Buffer中数据的指针,因此Join Buffer的内存使用效率获得了大幅的提升。
此外,对于NULL类型的列,其实不须要存放在Join Buffer中,而对于VARCHAR类型的列,也是仅需最小的内存便可,而不是以CHAR类型在Join Buffer中保存。最后,在MySQL 5.5版本中,Join Buffer只能在INNER JOIN中使用,在OUTER JOIN中则不能使用,即Block Nested Loop算法不支持OUTER JOIN。从MySQL 5.6及MariaDB 5.3开始,Join Buffer的使用获得了进一步扩展,在OUTER JOIN中使join buffer获得支持。
Block Nested-Loop Join极大的避免了内表的扫描次数,若是Join Buffer能够缓存外表的数据,那么内表的扫描仅需一次,这和Hash Join很是相似。可是Block Nested-Loop Join依然没有解决的是Join比较的次数,其仍然经过Join判断式进行比较。综上所述,到目前为止各Join算法的成本比较以下所示:
这个算法很好测试,咱们能够随便构建两张没有索引的字段进行联接,而后查看一下执行计划。下面是我在`MySQL 5.7版本上的执行计划。(这里把表的主键删除掉)
EXPLAIN SELECT * FROM t1 INNER JOIN t2 on t1.m1 = t2.m2 WHERE t2.n2>'c';
能够看到,SQL 执行计划的 Extra 列中提示 Using join buffer (Block Nested Loop),很明显使用了BNL算法。
另外,能够看出这条 SQL 先根据索引进行了条件过滤,而后拿过滤后的结果集做为驱动表,也是为了减小被驱动表扫描次数。若是 t2.n2 没有索引呢?使用 BNL 算法来 join 的话,这个语句的执行流程是这样的,假设表 t1 是驱动表,表 t2 是被驱动表:
1. 把表 t1 的全部字段取出来,存入 join_buffer 中。
2. 扫描表 t2,取出每一行数据跟 join_buffer 中的数据进行对比;若是不知足 t1.m1=t2.m2,则跳过; 若是知足 t1.m1=t2.m2,再判断其余条件,也就是是否知足 t2.n2>’c’ 的条件,若是是,就做为结果集的一部分返回,不然跳过。
对于表 t2 的每一行,判断 join 是否知足的时候,都须要遍历 join_buffer 中的全部行。所以判断等值条件的次数是 t1表行数*t2表行数,数据量稍微大点时,这个判断的次数都是上亿次。若是不想在表 t2 的字段 n2 上建立索引,又想减小比较次数。那么,有没有一箭双鵰的办法呢?这时候,咱们能够考虑使用临时表。使用临时表的大体思路是:
1. 把表 t2 中知足条件的数据放在临时表 tmp_t 中;
2. 为了让 join 使用 BKA 算法,给临时表 tmp_t 的字段 n2 加上索引;
3. 让表 t1 和 tmp_t 作 join 操做。
在使用 Block Nested-Loop Join(BNL) 算法时,可能会对被驱动表作屡次扫描。若是这个被驱动表是一个大的冷数据表,除了会致使 IO 压力大之外,还会对 buffer poll 产生严重的影响。
若是了解 InnoDB 的 LRU 算法就会知道,因为 InnoDB 对 Bufffer Pool 的 LRU 算法作了优化,即:第一次从磁盘读入内存的数据页,会先放在 old 区域。若是 1 秒以后这个数据页再也不被访问了,就不会被移动到 LRU 链表头部,这样对 Buffer Pool 的命中率影响就不大。
可是,若是一个使用 BNLe 算法的 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 算法对系统的影响主要包括三个方面: 可能会屡次扫描被驱动表,占用磁盘 IO 资源; 判断 join 条件须要执行 M*N 次对比(M、N 分别是两张表的行数),若是是大表就会占用很是多的 CPU 资源; 可能会致使 Buffer Pool 的热数据被淘汰,影响内存命中率。
Tips:思考这么一个问题,假设被驱动表全在内存中,这个时候 SNLJ 和 BNL 算法还有性能差异吗?固然是有的,因为 SNLJ 这个算法自然会对被驱动表的数据作屡次访问,因此更容易将这些数据页放到 Buffer Pool 的头部,从而污染 Buffer Pool。另外,即便被驱动表数据都在内存中,但每次查找“下一个记录的操做”,都是相似指针操做。而 BNL 算法中的 join_buffer 是数组,遍历的成本更低,从被驱动表读取一条数据去 join_buffer 中遍历。
这部分我测试的效果有问题,虽然我也安装了mysql employee数据库
Index Nested-Loop Join虽好,可是经过辅助索引进行联接后须要回表,这里须要大量的随机I/O操做。若能优化随机I/O,那么就能极大的提高Join的性能。为此,MySQL 5.6(MariaDB 5.3)开始支持Batched Key Access Join算法(简称BKA),该算法经过常见的空间换时间,随机I/O转顺序I/O,以此来极大的提高Join的性能。
在说明Batched Key Access Join前,首先介绍下MySQL 5.6的新特性mrr——multi range read。由于这个特性也是BKA的重要支柱。MRR优化的目的就是为了减小磁盘的随机访问,InnoDB因为索引组织表的特性,若是你的查询是使用辅助索引,而且有用到表中非索引列(投影非索引字段,及条件有非索引字段),所以须要回表读取数据作后续处理,过于随机的回表会伴随着大量的随机I/O。这个过程以下图所示:
而mrr的优化在于,并非每次经过辅助索引读取到数据就回表去取记录,范围扫描(range access)中MySQL将扫描到的数据存入由read_rnd_buffer_size 变量定义的内存大小中,默认256K。而后对其按照Primary Key(RowID)排序,而后使用排序好的数据进行顺序回表,由于咱们知道InnoDB中叶子节点数据是按照PRIMARY KEY(ROWID)进行顺序排列的,因此咱们能够认为,若是按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,可以提高读性能。这对于IO-bound类型的SQL查询语句带来性能极大的提高。
MRR 可以提高性能的核心在于,这条查询语句在索引上作的是一个范围查询(也就是说,这是一个多值查询),能够获得足够多的主键id。这样经过排序之后,再去主键索引查数据,才能体现出“顺序性”的优点。因此MRR优化可用于range,ref,eq_ref类型的查询,工做方式以下图:
要开启mrr还有一个比较重的参数是在变量optimizer_switch中的mrr和mrr_cost_based选项。mrr选项默认为on,mrr_cost_based选项默认为off。mrr_cost_based选项表示经过基于成本的算法来肯定是否须要开启mrr特性。然而,在MySQL当前版本中,基于成本的算法过于保守,致使大部分状况下优化器都不会选择mrr特性。为了确保优化器使用mrr特性,请执行下面的SQL语句:
set optimizer_switch='mrr=on,mrr_cost_based=off';
但若是强制开启MRR,那在某些SQL语句下,性能可能会变差;由于MRR须要排序,假如排序的时间超过直接扫描的时间,那性能就会下降。optimizer_switch能够是全局的,也能够是会话级的。
固然,除了调整参数外,数据库也提供了语句级别的开启或关闭MRR,使用方法以下:
mysql> explain select /*+ MRR(employees)*/ * from employees where birth_date >= '1996-01-01'\G
理解了 MRR 性能提高的原理,咱们就能理解 MySQL 在 5.6 版本后开始引入的 Batched Key Acess(BKA) 算法了。这个 BKA 算法,其实就是对 INLJ 算法的优化。
咱们知道 INLJ 算法执行的逻辑是:从驱动表一行行地取出 join 条件值,再到被驱动表去作 join。也就是说,对于被驱动表来讲,每次都是匹配一个值。这时,MRR 的优点就用不上了。那怎么才能一次性地多传些值给被驱动表呢?方法就是,从驱动表里一次性地多拿些行出来,一块儿传给被驱动表。既然如此,咱们就把驱动表的数据取出来一部分,先放到一个临时内存。这个临时内存不是别人,就是 join_buffer。
咱们知道 join_buffer 在 BNL 算法里的做用,是暂存驱动表的数据。可是在 NLJ 算法里并无用。那么,咱们恰好就能够复用 join_buffer 到 BKA 算法中。NLJ 算法优化后的 BKA 算法的流程,整个过程以下所示:
对于多表join语句,当MySQL使用索引访问第二个join表的时候,使用一个join buffer来收集第一个操做对象生成的相关列值。BKA构建好key后,批量传给引擎层作索引查找。key是经过MRR接口提交给引擎的,这样,MRR使得查询更有效率。
若是外部表扫描的是主键,那么表中的记录访问都是比较有序的,可是若是联接的列是非主键索引,那么对于表中记录的访问可能就是很是离散的。所以对于非主键索引的联接,Batched Key Access Join算法将能极大提升SQL的执行效率。BKA算法支持内联接,外联接和半联接操做,包括嵌套外联接。
Batched Key Access Join算法的工做步骤以下:
1. 将外部表中相关的列放入Join Buffer中。
2. 批量的将Key(索引键值)发送到Multi-Range Read(MRR)接口。
3. Multi-Range Read(MRR)经过收到的Key,根据其对应的ROWID进行排序,而后再进行数据的读取操做。
4. 返回结果集给客户端。
Batched Key Access Join算法的本质上来讲仍是Simple Nested-Loops Join算法,其发生的条件为内部表上有索引,而且该索引为非主键,而且联接须要访问内部表主键上的索引。这时Batched Key Access Join算法会调用Multi-Range Read(MRR)接口,批量的进行索引键的匹配和主键索引上获取数据的操做,以此来提升联接的执行效率,由于读取数据是以顺序磁盘IO而不是随机磁盘IO进行的。
在MySQL 5.6中默认关闭BKA(MySQL 5.7默认打开),必须将optimizer_switch系统变量的batched_key_access标志设置为on。BKA使用MRR,所以mrr标志也必须打开。目前,MRR的成本估算过于悲观。所以,mrr_cost_based也必须关闭才能使用BKA。如下设置启用BKA:
SET optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on';
由于BKA算法的本质是经过MRR接口将非主键索引对于记录的访问,转化为根据ROWID排序的较为有序的记录获取,因此要想经过BKA算法来提升性能,不但须要确保联接的列参与match的操做(联接的列能够是惟一索引或者普通索引,但不能是主键),还要有对非主键列的search操做。例以下列SQL语句:
mysql> explain select a.gender, b.dept_no from employees a, dept_emp b where a.birth_date=b.from_date
列a.gender是表employees的数据,但不是经过搜索idx_birth_date索引就能获得数据,还须要回表访问主键来获取数据。所以这时可使用BKA算法。可是若是联接不涉及针对主键进一步获取数据,内部表只参与联接判断,那么就不会启用BKA算法,由于没有必要去调用MRR接口。好比search的主键(a.emp_no),那么确定就不须要BKA算法了,直接覆盖索引就能够返回数据了(二级索引有主键值)。
mysql> explain select a.emp_no, b.dept_no from employees a, dept_emp b where a.birth_date=b.from_date;
在EXPLAIN输出中,当Extra值包含Using join buffer(Batched Key Access)且类型值为ref或eq_ref时,表示使用BKA。
MySQL数据库虽然提供了BKA Join来优化传统的JOIN算法,的确在必定程度上能够提高JOIN的速度。但不能否认的是,仍然有许多用户对于Hash Join算法有着强烈的需求。Hash Join不须要任何的索引,经过扫描表就能快速地进行JOIN查询,经过利用磁盘的带宽带最大程度的解决大数据量下的JOIN问题。
MariaDB支持Classic Hash Join算法,该算法不一样于Oracle的Grace Hash Join,可是也是经过Hash来进行联接,不须要索引,可充分利用磁盘的带宽。
其实MariaDB的Classic Hash Join和Block Nested Loop Join算法很是相似(Classic Hash Join也称为Block Nested Loop Hash Join),但并非直接经过进行JOIN的键值进行比较,而是根据Join Buffer中的对象建立哈希表,内表经过哈希算法进行查找,从而在Block Nested Loop Join算法的基础上,又进一步减小了内表的比较次数,从而提高JOIN的查询性能。过程以下图所示:
Classic Hash Join算法先将外部表中数据放入Join Buffer中,而后根据键值产生一张散列表,这是第一个阶段,称为build阶段。随后读取内部表中的一条记录,对其应用散列函数,将其和散列表中的数据进行比较,这是第二个阶段,称为probe阶段。
若是将Hash查找应用于Simple Nested-Loops Join中,则执行计划的Extra列会显示BNLH。若是将Hash查找应用于Batched Key Access Join中,则执行计划的Extra列会显示BKAH。
一样地,若是Join Buffer可以缓存全部驱动表(外表)的查询列,那么驱动表和内表的扫描次数都将只有1次,而且比较的次数也只是内表记录数(假设哈希算法冲突为0)。反之,须要扫描屡次内部表。为了使Classic Hash Join更有效果,应该更好地规划Join Buffer的大小。
要使用Classic Hash Join算法,须要将join_cache_level设置为大于等于4的值,并显示地打开优化器的选项,设置过程以下:
set join_cache_join=4;
set optimizer_switch='join_cache_hashed=on';
最后,各JOIN算法成本之间的比较以下表所示:
Hash Join算法虽好,可是仅能用于等值联接,非等值联接的JOIN查询,其就显得无能为力了。另外,建立哈希表也是费时的工做,可是一旦创建完成后,其就能大幅提高JOIN的速度。因此一般状况下,大表之间的JOIN,Hash Join算法会比较有优点。小表经过索引查询,利用BKA Join就已经能很好的完成查询。目前MySQL 8.0已经出了,但目前尚未看到Hash Join的身影,不知将来会不会加入。
通过上面的学习,咱们能发现联接查询成本占大头的就是“驱动表记录数乘以单次访问被驱动表的成本”,因此咱们的优化重点其实就是下面这两个部分:
1 尽可能减小驱动表的记录数
2 对被驱动表的访问成本尽量下降
这两点对于咱们实际书写联接查询语句时十分有用,咱们须要尽可能在被驱动表的联接列上创建索引(主键或惟一索引最优,其次是非惟一二级索引),这样就可使用 eq_ref 或 ref 访问方法来下降访问被驱动表的成本了。
<摘录>
InnoDB存储引擎 – 姜
MySQL Join算法与调优白皮书 – 姜
https://dev.mysql.com/doc/refman/5.7/en/bnl-bka-optimization.html