,单表查询

不会走之前不要跑,在学SQL优化之前,我们先来分析下SQL是怎么执行的。

前面说过,MySQL Server有一个称为查询优化器的模块,一条查询语句进行语法解析之后就会被交给查询优化器来进行优化,优化的结果就是生成一个所谓的执行计划,这个执行计划表明了应该使用哪些索引进行查询,表之间的连接顺序是啥样的,最后会按照执行计划中的步骤调用存储引擎提供的方法来真正的执行查询,并将查询结果返回给用户。

如果觉得我这篇博客讲的看不懂,回头看看我前面的几篇,MySQL是一个很复杂的东西,尽量不要跳着学,要静下心系统的来学习,之前我都是四处看帖子看博客,一直觉得自己MySQL迷迷糊糊,甚至成了痛点,所以决心写个MySQL专栏,系统的学习下。

我们前面创建过一张表,现在拿来复用下。

  1. CREATE TABLE single_table (
  2. id INT NOT NULL AUTO_INCREMENT,
  3. key1 VARCHAR(100),
  4. key2 INT,
  5. key3 VARCHAR(100),
  6. key_part1 VARCHAR(100),
  7. key_part2 VARCHAR(100),
  8. key_part3 VARCHAR(100),
  9. common_field VARCHAR(100),
  10. PRIMARY KEY (id),
  11. KEY idx_key1 (key1),
  12. UNIQUE KEY idx_key2 (key2),
  13. KEY idx_key3 (key3),
  14. KEY idx_key_part(key_part1, key_part2, key_part3)
  15. ) Engine=InnoDB CHARSET=utf8;

我们为这个single_table表建立了1个聚簇索引和4个二级索引,分别是:

  • id列建立的聚簇索引。
  • key1列建立的idx_key1二级索引。
  • key2列建立的idx_key2二级索引,而且该索引是唯一二级索引。
  • key3列建立的idx_key3二级索引。
  • key_part1key_part2key_part3列建立的idx_key_part二级索引,这也是一个联合索引。

这张表我插入了一百万数据,用来做实验。

1.访问方法

对于单个表的查询来说,MySQL把查询的执行方式大致分为下边两种:

  • 使用全表扫描进行查询
    这种执行方式很好理解,就是把表的每一行记录都扫一遍嘛,把符合搜索条件的记录加入到结果集就完了。不管是啥查询都可以使用这种方式执行,当然,这种也是最笨的执行方式。
  • 使用索引进行查询
    因为直接使用全表扫描的方式执行查询要遍历好多记录,所以代价可能太大了。如果查询语句中的搜索条件可以使用到某个索引,那直接使用索引来执行查询可能会加快查询执行的时间。使用索引来执行查询的方式五花八门,又可以细分为许多种类:
    • 针对主键或唯一二级索引的等值查询
    • 针对普通二级索引的等值查询
    • 针对索引列的范围查询
    • 直接扫描整个索引

MySQLMySQL执行查询语句的方式称之为访问方法或者访问类型。同一个查询语句可能可以使用多种不同的访问方法来执行,虽然最后的查询结果都是一样的,但是执行的时间可能相差很多。

2.const

  1. SELECT * FROM single_table WHERE id = 1438;

MySQL会直接利用主键值在聚簇索引中定位对应的用户记录。

1.png

**B+**树叶子节点中的记录是按照索引列排序的,对于的聚簇索引来说,它对应的**B+**树叶子节点中的记录就是按照**id**列排序的。所以这样根据主键值定位一条记录的速度贼快。类似的,我们根据唯一二级索引列来定位一条记录的速度也是贼快的,比如下边这个查询:

  1. SELECT * FROM single_table WHERE key2 = 3841;

这个查询的执行过程的示意图就是这样:

2.png

这个查询的执行分两步:

  1. 先从idx_key2对应的B+树索引中根据key2列与常数的等值比较条件定位到一条二级索引记录
  2. 再根据该记录的id值到聚簇索引中获取到完整的用户记录

MySQL认为通过主键或者唯一二级索引列与常数的等值比较来定位一条记录非常快,所以他们把这种通过主键或者唯一二级索引列来定位一条记录的访问方法定义为:const,意思是常数级别的,代价是可以忽略不计的。不过这种const访问方法只能在主键列或者唯一二级索引列和一个常数进行等值比较时才有效,如果主键或者唯一二级索引是由多个列构成的话,索引中的每一个列都需要与常数进行等值比较,这个const访问方法才有效(这是因为只有该索引中全部列都采用等值比较才可以定位唯一的一条记录)。

对于唯一二级索引来说,查询该列为NULL值的情况比较特殊,比如这样:

  1. SELECT * FROM single_table WHERE key2 IS NULL;

因为唯一二级索引列并不限制 NULL 值的数量,所以上述语句可能访问到多条记录,也就是说 上边这个语句不可以使用const访问方法来执行。

3.ref

有时候我们对某个普通的二级索引列与常数进行等值比较,比如这样:

  1. SELECT * FROM single_table WHERE key1 = 'abc';

对于这个查询,我们当然可以选择全表扫描来逐一对比搜索条件是否满足要求,我们也可以先使用二级索引找到对应记录的id值,然后再回表到聚簇索引中查找完整的用户记录。由于普通二级索引并不限制索引列值的唯一性,所以可能找到多条对应的记录,也就是说使用二级索引来执行查询的代价取决于等值匹配到的二级索引记录条数。如果匹配的记录较少,则回表的代价还是比较低的,所以MySQL可能选择使用索引而不是全表扫描的方式来执行查询。MySQL把这种搜索条件为二级索引列与常数等值比较,采用二级索引来执行查询的访问方法称为:ref。我们看一下采用ref访问方法执行查询的图示:

3.png

对于普通的二级索引来说,通过索引列进行等值比较后可能匹配到多条连续的记录,而不是像主键或者唯一二级索引那样最多只能匹配1条记录,所以这种ref访问方法比const差了那么一点,但是在二级索引等值比较时匹配的记录数较少时的效率还是很高的(如果匹配的二级索引记录太多那么回表的成本就太大了)。

有两种特殊情况:

  • 二级索引列值为NULL的情况
    不论是普通的二级索引,还是唯一二级索引,它们的索引列对包含NULL值的数量并不限制,所以我们采用key IS NULL这种形式的搜索条件最多只能使用ref的访问方法,而不是const的访问方法。
  • 对于某个包含多个索引列的二级索引来说,只要是最左边的连续索引列是与常数的等值比较就可能采用ref的访问方法,比方说下边这几个查询:
  1. SELECT * FROM single_table WHERE key_part1 = 'god like';
  2. SELECT * FROM single_table WHERE key_part1 = 'god like' AND key_part2 = 'legendary';
  3. SELECT * FROM single_table WHERE key_part1 = 'god like' AND key_part2 = 'legendary' AND key_part3 = 'penta kill';

但是如果最左边的连续索引列并不全部是等值比较的话,它的访问方法就不能称为ref了,比方说这样:

  1. SELECT * FROM single_table WHERE key_part1 = 'god like' AND key_part2 > 'legendary';

4.ref_or_null

有时候我们不仅想找出某个二级索引列的值等于某个常数的记录,还想把该列的值为NULL的记录也找出来,就像下边这个查询:

  1. SELECT * FROM single_table WHERE key1 = 'abc' OR key1 IS NULL;

当使用二级索引而不是全表扫描的方式执行该查询时,这种类型的查询使用的访问方法就称为ref_or_null,这个ref_or_null访问方法的执行过程如下:

4.png

上边的查询相当于先分别从idx_key1索引对应的B+树中找出key1 IS NULLkey1 = 'abc'的两个连续的记录范围,然后根据这些二级索引记录中的id值再回表查找完整的用户记录。

5.range

  1. SELECT * FROM single_table WHERE key2 IN (1438, 6328) OR (key2 >= 38 AND key2 <= 79);

如果采用二级索引 + 回表的方式来执行的话,那么此时的搜索条件就不只是要求索引列与常数的等值匹配了,而是索引列需要匹配某个或某些范围的值,在本查询中key2列的值只要匹配下列3个范围中的任何一个就算是匹配成功了:

  • key2的值是1438
  • key2的值是6328
  • key2的值在3879之间。

MySQL把这种利用索引进行范围匹配的访问方法称之为:range

此处所说的使用索引进行范围匹配中的 索引 可以是聚簇索引,也可以是二级索引。

我们可以把那种索引列等值匹配的情况称之为单点区间,上边所说的范围1范围2都可以被称为单点区间,像范围3这种的我们可以称为连续范围区间。

6.index

  1. SELECT key_part1, key_part2, key_part3 FROM single_table WHERE key_part2 = 'abc';

由于key_part2并不是联合索引idx_key_part最左索引列,所以我们无法使用ref或者range访问方法来执行这个语句。但是这个查询符合下边这两个条件:

  • 它的查询列表只有3个列:key_part1, key_part2, key_part3,而索引idx_key_part又包含这三个列。
  • 搜索条件中只有key_part2列。这个列也包含在索引idx_key_part中。

也就是说我们可以直接通过遍历idx_key_part索引的叶子节点的记录来比较key_part2 = 'abc'这个条件是否成立,把匹配成功的二级索引记录的key_part1, key_part2, key_part3列的值直接加到结果集中就行了。由于二级索引记录比聚簇索记录小的多(聚簇索引记录要存储所有用户定义的列以及所谓的隐藏列,而二级索引记录只需要存放索引列和主键),而且这个过程也不用进行回表操作,所以直接遍历二级索引比直接遍历聚簇索引的成本要小很多,MySQL就把这种采用遍历二级索引记录的执行方式称之为:index

7.all

全表扫描

8.注意

8.1 二级索引 + 回表

一般情况下只能利用单个二级索引执行查询,比方说下边的这个查询:

  1. SELECT * FROM single_table WHERE key1 = 'abc' AND key2 > 1000;

查询优化器会识别到这个查询中的两个搜索条件:

  • key1 = 'abc'
  • key2 > 1000

优化器一般会根据single_table表的统计数据来判断到底使用哪个条件到对应的二级索引中查询扫描的行数会更少,选择那个扫描行数较少的条件到对应的二级索引中查询。然后将从该二级索引中查询到的结果经过回表得到完整的用户记录后再根据其余的WHERE条件过滤记录。一般来说,等值查找比范围查找需要扫描的行数更少(也就是ref的访问方法一般比range好,但这也不总是一定的,也可能采用ref访问方法的那个索引列的值为特定值的行数特别多),所以这里假设优化器决定使用idx_key1索引进行查询,那么整个查询过程可以分为两个步骤:

  1. 使用二级索引定位记录的阶段,也就是根据条件key1 = 'abc'idx_key1索引代表的B+树中找到对应的二级索引记录。
  2. 回表阶段,也就是根据上一步骤中找到的记录的主键值进行回表操作,也就是到聚簇索引中找到对应的完整的用户记录,再根据条件key2 > 1000到完整的用户记录继续过滤。将最终符合过滤条件的记录返回给用户。

注意,因为二级索引的节点中的记录只包含索引列和主键,所以在步骤1中使用idx_key1索引进行查询时只会用到与key1列有关的搜索条件,其余条件,比如key2 > 1000这个条件在步骤1中是用不到的,只有在步骤2完成回表操作后才能继续针对完整的用户记录中继续过滤。

一般情况下执行一个查询只会用到单个二级索引,不过还是有特殊情况的。

从上文可以看出,每次从二级索引中读取到一条记录后,就会根据该记录的主键值执行回表操作。而在某个扫描区间中的二级索引记录的主键值是无序的,也就是说这些二级索引记录对应的聚簇索引记录所在的页面的页号是无序的。每次执行回表操作时都相当于要随机读取一个聚簇索引页面,而这些随机I/O带来的性能开销比较大。于是MySQL提出了一个名为Disk-S weep Multi-Range Read(MRR,多范围读取)的优化措施,即先读取一部分二级索引记录,将它们的主键值排好序之后再统一执行回表操作。相对于每读取一条二级索引记录 就立即执行回表操作,这样会节省一些I/0开销。当然使用这个MRR优化措施的条件比较苛刻,我们之前的讨论中没有涉及MRR 之后的讨论中也将忽略这项优化措施,直接认为每读取一条二级索引记录就立即执行回表操作。

8.2 range访问方法使用的范围区间

其实对于B+树索引来说,只要索引列和常数使用=<=>INNOT INIS NULLIS NOT NULL><>=<=BETWEEN!=(不等于也可以写成<>)或者LIKE操作符连接起来,就可以产生一个所谓的区间

LIKE操作符比较特殊,只有在匹配完整字符串或者匹配字符串前缀时才可以利用索引。 IN操作符的效果和若干个等值匹配操作符=之间用OR连接起来是一样的,也就是说会产生多个单点区间,比如下边这两个语句的效果是一样的:

  1. SELECT * FROM single_table WHERE key2 IN (1438, 6328);
  2. SELECT * FROM single_table WHERE key2 = 1438 OR key2 = 6328;

在日常的工作中,一个查询的WHERE子句可能有很多个小的搜索条件,这些搜索条件需要使用AND或者OR操作符连接起来。当我们想使用range访问方法来执行一个查询语句时,重点就是找出该查询可用的索引以及这些索引对应的范围区间。

9.索引合并

MySQL在一般情况下执行一个查询时最多只会用到单个二级索引,但是还有特殊情况,在这些特殊情况下也可能在一个查询中使用到多个二级索引,MySQL把这种使用到多个索引来完成一次查询的执行方法称之为:index merge,具体的索引合并算法有下边三种。

9.1 Intersection合并

Intersection翻译过来的意思是交集。这里是说某个查询可以使用多个二级索引,将从多个二级索引中查询到的结果取交集,比方说下边这个查询:

  1. SELECT * FROM single_table WHERE key1 = 'a' AND key3 = 'b';

假设这个查询使用Intersection合并的方式执行的话,那这个过程就是这样的:

  • idx_key1二级索引对应的B+树中取出key1 = 'a'的相关记录。
  • idx_key3二级索引对应的B+树中取出key3 = 'b'的相关记录。
  • 二级索引的记录都是由索引列 + 主键构成的,所以我们可以计算出这两个结果集中id值的交集。
  • 按照上一步生成的id值列表进行回表操作,也就是从聚簇索引中把指定id值的完整用户记录取出来,返回给用户。

为啥不直接使用idx_key1或者idx_key3只根据某个搜索条件去读取一个二级索引,然后回表后再过滤另外一个搜索条件呢?这里要分析一下两种查询执行方式之间需要的成本代价。

  1. 只读取一个二级索引的成本:
  • 按照某个搜索条件读取一个二级索引
  • 根据从该二级索引得到的主键值进行回表操作,然后再过滤其他的搜索条件
  1. 读取多个二级索引之后取交集成本:
  • 按照不同的搜索条件分别读取不同的二级索引
  • 将从多个二级索引得到的主键值取交集,然后进行回表操作

虽然读取多个二级索引比读取一个二级索引消耗性能,但是读取二级索引的操作是顺序I/O,而回表操作是随机I/O,所以如果只读取一个二级索引时需要回表的记录数特别多,而读取多个二级索引之后取交集的记录数非常少,当节省的因为回表而造成的性能损耗比访问多个二级索引带来的性能损耗更高时,读取多个二级索引后取交集比只读取一个二级索引的成本更低。

MySQL在某些特定的情况下才可能会使用到Intersection索引合并:

  • 情况一:二级索引列是等值匹配的情况,对于联合索引来说,在联合索引中的每个列都必须等值匹配,不能出现只匹配部分列的情况。
    比方说下边这个查询可能用到idx_key1idx_key_part这两个二级索引进行Intersection索引合并的操作:
  1. SELECT * FROM single_table WHERE key1 = 'a' AND key_part1 = 'a' AND key_part2 = 'b' AND key_part3 = 'c';

而下边这两个查询就不能进行Intersection索引合并:

  1. SELECT * FROM single_table WHERE key1 > 'a' AND key_part1 = 'a' AND key_part2 = 'b' AND key_part3 = 'c';
  2. SELECT * FROM single_table WHERE key1 = 'a' AND key_part1 = 'a';

第一个查询是因为对key1进行了范围匹配,第二个查询是因为联合索引idx_key_part中的key_part2key_part3列并没有出现在搜索条件中,所以这两个查询不能进行Intersection索引合并。

  • 情况二:主键列可以是范围匹配
    比方说下边这个查询可能用到主键和idx_key1进行Intersection索引合并的操作:
  1. SELECT * FROM single_table WHERE id > 100 AND key1 = 'a';

对于InnoDB的二级索引来说,记录先是按照索引列进行排序,如果该二级索引是一个联合索引,那么会按照联合索引中的各个列依次排序。而二级索引的用户记录是由索引列 + 主键构成的,二级索引列的值相同的记录可能会有好多条,这些索引列的值相同的记录又是按照主键的值进行排序的。所以在二级索引列都是等值匹配的情况下才可能使用Intersection索引合并,是因为只有在这种情况下根据二级索引查询出的结果集是按照主键值排序的。

根据二级索引查询出的结果集是按照主键值排序的对使用**Intersection**索引合并的好处?Intersection索引合并会把从多个二级索引中查询出的主键值求交集,如果从各个二级索引中查询的到的结果集本身就是已经按照主键排好序的,那么求交集的过程就很简单。假设某个查询使用Intersection索引合并的方式从idx_key1idx_key2这两个二级索引中获取到的主键值分别是:

  • idx_key1中获取到已经排好序的主键值:1、3、5
  • idx_key2中获取到已经排好序的主键值:2、3、4

那么求交集的过程就是这样:逐个取出这两个结果集中最小的主键值,如果两个值相等,则加入最后的交集结果中,否则丢弃当前较小的主键值,再取该丢弃的主键值所在结果集的后一个主键值来比较,直到某个结果集中的主键值用完了:

  • 先取出这两个结果集中较小的主键值做比较,因为1 < 2,所以把idx_key1的结果集的主键值1丢弃,取出后边的3来比较。
  • 因为3 > 2,所以把idx_key2的结果集的主键值2丢弃,取出后边的3来比较。
  • 因为3 = 3,所以把3加入到最后的交集结果中,继续两个结果集后边的主键值来比较。
  • 后边的主键值也不相等,所以最后的交集结果中只包含主键值3

这个过程其实很快,时间复杂度是O(n),但是如果从各个二级索引中查询出的结果集并不是按照主键排序的话,那就要先把结果集中的主键值排序完再来做上边的那个过程,就比较耗时了。

按照有序的主键值去回表取记录有个专有名词儿,叫:Rowid Ordered Retrieval,简称ROR。

另外,不仅是多个二级索引之间可以采用Intersection索引合并,索引合并也可以有聚簇索引参加,也就是我们上边写的情况二:在搜索条件中有主键的范围匹配的情况下也可以使用Intersection索引合并索引合并。

  1. SELECT * FROM single_table WHERE key1 = 'a' AND id > 100;

假设这个查询可以采用Intersection索引合并,我们理所当然的以为这个查询会分别按照id > 100这个条件从聚簇索引中获取一些记录,在通过key1 = 'a'这个条件从idx_key1二级索引中获取一些记录,然后再求交集,其实这样就把问题复杂化了,没必要从聚簇索引中获取一次记录。二级索引的记录中都带有主键值的,所以可以在从idx_key1中获取到的主键值上直接运用条件id > 100过滤就行了。所以涉及主键的搜索条件只不过是为了从别的二级索引得到的结果集中过滤记录罢了,是不是等值匹配不重要。

当然,上边说的情况一情况二只是发生Intersection索引合并的必要条件,不是充分条件。也就是说即使情况一、情况二成立,也不一定发生Intersection索引合并,这得看优化器的心情。优化器只有在单独根据搜索条件从某个二级索引中获取的记录数太多,导致回表开销太大,而通过Intersection索引合并后需要回表的记录数大大减少时才会使用Intersection索引合并。

9.2 Union合并

有时候OR关系的不同搜索条件会使用到不同的索引。

  1. SELECT * FROM single_table WHERE key1 = 'a' OR key3 = 'b'

Intersection是交集的意思,这适用于使用不同索引的搜索条件之间使用AND连接起来的情况;Union是并集的意思,适用于使用不同索引的搜索条件之间使用OR连接起来的情况。与Intersection索引合并类似,MySQL在某些特定的情况下才可能会使用到Union索引合并:

  • 情况一:二级索引列是等值匹配的情况,对于联合索引来说,在联合索引中的每个列都必须等值匹配,不能出现只出现匹配部分列的情况。
    比方说下边这个查询可能用到idx_key1idx_key_part这两个二级索引进行Union索引合并的操作:
  1. SELECT * FROM single_table WHERE key1 = 'a' OR ( key_part1 = 'a' AND key_part2 = 'b' AND key_part3 = 'c');

而下边这两个查询就不能进行Union索引合并:

  1. SELECT * FROM single_table WHERE key1 > 'a' OR (key_part1 = 'a' AND key_part2 = 'b' AND key_part3 = 'c');
  2. SELECT * FROM single_table WHERE key1 = 'a' OR key_part1 = 'a';

第一个查询是因为对key1进行了范围匹配,第二个查询是因为联合索引idx_key_part中的key_part2key_part3列并没有出现在搜索条件中,所以这两个查询不能进行Union索引合并。

  • 情况二:主键列可以是范围匹配
  • 情况三:使用Intersection索引合并的搜索条件

这种情况其实就是搜索条件的某些部分使用Intersection索引合并的方式得到的主键集合和其他方式得到的主键集合取交集,比方说这个查询:

  1. SELECT * FROM single_table WHERE key_part1 = 'a' AND key_part2 = 'b' AND key_part3 = 'c' OR (key1 = 'a' AND key3 = 'b');

优化器可能采用这样的方式来执行这个查询:

  • 先按照搜索条件key1 = 'a' AND key3 = 'b'从索引idx_key1idx_key3中使用Intersection索引合并的方式得到一个主键集合。
  • 再按照搜索条件key_part1 = 'a' AND key_part2 = 'b' AND key_part3 = 'c'从联合索引idx_key_part中得到另一个主键集合。
  • 采用Union索引合并的方式把上述两个主键集合取并集,然后进行回表操作,将结果返回给用户。

当然,查询条件符合了这些情况也不一定就会采用Union索引合并,也得看优化器的心情。优化器只有在单独根据搜索条件从某个二级索引中获取的记录数比较少,通过Union索引合并后进行访问的代价比全表扫描更小时才会使用Union索引合并。

9.3 Sort-Union合并

Union索引合并的使用条件太苛刻,必须保证各个二级索引列在进行等值匹配的条件下才可能被用到,比方说下边这个查询就无法使用到Union索引合并:

  1. SELECT * FROM single_table WHERE key1 < 'a' OR key3 > 'z'

这是因为根据key1 < 'a'idx_key1索引中获取的二级索引记录的主键值不是排好序的,根据key3 > 'z'idx_key3索引中获取的二级索引记录的主键值也不是排好序的,但是key1 < 'a'key3 > 'z'这两个条件又特别让我们动心,所以我们可以这样:

  • 先根据key1 < 'a'条件从idx_key1二级索引中获取记录,并按照记录的主键值进行排序
  • 再根据key3 > 'z'条件从idx_key3二级索引中获取记录,并按照记录的主键值进行排序
  • 因为上述的两个二级索引主键值都是排好序的,剩下的操作和Union索引合并方式就一样了。

我们把上述这种先按照二级索引记录的主键值进行排序,之后按照Union索引合并方式执行的方式称之为Sort-Union索引合并,很显然,这种Sort-Union索引合并比单纯的Union索引合并多了一步对二级索引记录的主键值排序的过程。

为啥有Sort-Union索引合并,就没有Sort-Intersection索引合并么?是的,的确没有Sort-Intersection索引合并这么一说, Sort-Union的适用场景是单独根据搜索条件从某个二级索引中获取的记录数比较少,这样即使对这些二级索引记录按照主键值进行排序的成本也不会太高 而Intersection索引合并的适用场景是单独根据搜索条件从某个二级索引中获取的记录数太多,导致回表开销太大,合并后可以明显降低回表开销,但是如果加入Sort-Intersection后,就需要为大量的二级索引记录按照主键值进行排序,这个成本可能比回表查询都高了,所以也就没有引入Sort-Intersection。

9.4 索引合并注意事项

联合索引替代Intersection索引合并

  1. SELECT * FROM single_table WHERE key1 = 'a' AND key3 = 'b';

这个查询之所以可能使用Intersection索引合并的方式执行,还不是因为idx_key1idx_key3是两个单独的B+树索引,要是把这两个列搞一个联合索引,那直接使用这个联合索引就可以了:

  1. ALTER TABLE single_table drop index idx_key1, idx_key3, add index idx_key1_key3(key1, key3);

这样我们把没用的idx_key1idx_key3都干掉,再添加一个联合索引idx_key1_key3,使用这个联合索引进行查询效果更好,既不用多读一棵B+树,也不用合并结果。

不过如果有单独对key3列进行查询的业务场景,这样子不得不再把key3列的单独索引给加上。具体还得以业务为准。

二,连接查询原理

1. 连接简介

1.1 连接的本质

我们先建立两个简单的表并给它们填充一点数据:

  1. CREATE TABLE t1 (m1 int, n1 char(1));
  2. CREATE TABLE t2 (m2 int, n2 char(1));
  3. INSERT INTO t1 VALUES(1, 'a'), (2, 'b'), (3, 'c');
  4. INSERT INTO t2 VALUES(2, 'b'), (3, 'c'), (4, 'd');

我们成功建立了t1t2两个表,这两个表都有两个列,一个是INT类型的,一个是CHAR(1)类型的。

  1. mysql> SELECT * FROM t1;
  2. +------+------+
  3. | m1 | n1 |
  4. +------+------+
  5. | 1 | a |
  6. | 2 | b |
  7. | 3 | c |
  8. +------+------+
  9. 3 rows in set (0.00 sec)
  10. mysql> SELECT * FROM t2;
  11. +------+------+
  12. | m2 | n2 |
  13. +------+------+
  14. | 2 | b |
  15. | 3 | c |
  16. | 4 | d |
  17. +------+------+
  18. 3 rows in set (0.00 sec)

连接的本质就是把各个连接表中的记录都取出来依次匹配的组合加入结果集并返回给用户。所以我们把t1t2两个表连接起来的过程如下图所示:

5.png

这个过程看起来就是把t1表的记录和t2的记录连起来组成新的更大的记录,所以这个查询过程称之为连接查询。连接查询的结果集中包含一个表中的每一条记录与另一个表中的每一条记录相互匹配的组合,像这样的结果集就可以称之为笛卡尔积。因为表t1中有3条记录,表t2中也有3条记录,所以这两个表连接之后的笛卡尔积就有3×3=9行记录。在MySQL中,连接查询的语法很简单,只要在FROM语句后边跟多个表名就好了,比如我们把t1表和t2表连接起来的查询语句可以写成这样:

  1. SELECT * FROM t1, t2;

1.2 连接过程简介

在连接查询中的过滤条件可以分成两种:

  • 涉及单表的条件
    这种只涉及单表的过滤条件我们之前都提到过一万遍了,我们之前也一直称为搜索条件,比如t1.m1 > 1是只针对t1表的过滤条件,t2.n2 < 'd'是只针对t2表的过滤条件。
  • 涉及两表的条件
    这种过滤条件我们之前没见过,比如t1.m1 = t2.m2t1.n1 > t2.n2等,这些条件中涉及到了两个表。

我们看一下携带过滤条件的连接查询的大致执行过程:

  1. SELECT * FROM t1, t2 WHERE t1.m1 > 1 AND t1.m1 = t2.m2 AND t2.n2 < 'd';

在这个查询中我们指明了这三个过滤条件:

  • t1.m1 > 1
  • t1.m1 = t2.m2
  • t2.n2 < 'd'

这个连接查询的大致执行过程如下:

  1. 首先确定第一个需要查询的表,这个表称之为驱动表。此处假设使用t1作为驱动表,那么就需要到t1表中找满足t1.m1 > 1的记录,因为表中的数据太少,我们也没在表上建立二级索引,所以此处查询t1表的访问方法就设定为all吧,也就是采用全表扫描的方式执行单表查询,所以查询过程就如下图所示:
    6.png
    我们可以看到,t1表中符合t1.m1 > 1的记录有两条。
  2. 针对上一步骤中从驱动表产生的结果集中的每一条记录,分别需要到t2表中查找匹配的记录,所谓匹配的记录,指的是符合过滤条件的记录。因为是根据t1表中的记录去找t2表中的记录,所以t2表也可以被称之为被驱动表。上一步骤从驱动表中得到了2条记录,所以需要查询2次t2表。此时涉及两个表的列的过滤条件t1.m1 = t2.m2就派上用场了:
    • t1.m1 = 2时,过滤条件t1.m1 = t2.m2就相当于t2.m2 = 2,所以此时t2表相当于有了t2.m2 = 2t2.n2 < 'd'这两个过滤条件,然后到t2表中执行单表查询。
    • t1.m1 = 3时,过滤条件t1.m1 = t2.m2就相当于t2.m2 = 3,所以此时t2表相当于有了t2.m2 = 3t2.n2 < 'd'这两个过滤条件,然后到t2表中执行单表查询。

所以整个连接查询的执行过程就如下图所示:

7.png

也就是说整个连接查询最后的结果只有两条符合过滤条件的记录:

  1. +------+------+------+------+
  2. | m1 | n1 | m2 | n2 |
  3. +------+------+------+------+
  4. | 2 | b | 2 | b |
  5. | 3 | c | 3 | c |
  6. +------+------+------+------+

这个两表连接查询共需要查询1次t1表,2次t2表。当然这是在特定的过滤条件下的结果,如果我们把t1.m1 > 1这个条件去掉,那么从t1表中查出的记录就有3条,就需要查询3次t2表了。也就是说在两表连接查询中,驱动表只需要访问一次,被驱动表可能被访问多次。

1.3 内连接和外连接

我们先创建两个有现实意义的表。

  1. CREATE TABLE student (
  2. number INT NOT NULL AUTO_INCREMENT COMMENT '学号',
  3. name VARCHAR(5) COMMENT '姓名',
  4. major VARCHAR(30) COMMENT '专业',
  5. PRIMARY KEY (number)
  6. ) Engine=InnoDB CHARSET=utf8 COMMENT '学生信息表';
  7. CREATE TABLE score (
  8. number INT COMMENT '学号',
  9. subject VARCHAR(30) COMMENT '科目',
  10. score TINYINT COMMENT '成绩',
  11. PRIMARY KEY (number, subject)
  12. ) Engine=InnoDB CHARSET=utf8 COMMENT '学生成绩表';

我们新建了一个学生信息表,一个学生成绩表,然后我们向上述两个表中插入一些数据:

  1. mysql> SELECT * FROM student;
  2. +----------+-----------+--------------------------+
  3. | number | name | major |
  4. +----------+-----------+--------------------------+
  5. | 20180101 | 杜子腾 | 软件学院 |
  6. | 20180102 | 范统 | 计算机科学与工程 |
  7. | 20180103 | 史珍香 | 计算机科学与工程 |
  8. +----------+-----------+--------------------------+
  9. 3 rows in set (0.00 sec)
  10. mysql> SELECT * FROM score;
  11. +----------+-----------------------------+-------+
  12. | number | subject | score |
  13. +----------+-----------------------------+-------+
  14. | 20180101 | 母猪的产后护理 | 78 |
  15. | 20180101 | 论萨达姆的战争准备 | 88 |
  16. | 20180102 | 论萨达姆的战争准备 | 98 |
  17. | 20180102 | 母猪的产后护理 | 100 |
  18. +----------+-----------------------------+-------+
  19. 4 rows in set (0.00 sec)

现在我们想把每个学生的考试成绩都查询出来就需要进行两表连接了(因为score中没有姓名信息,所以不能单纯只查询score表)。连接过程就是从student表中取出记录,在score表中查找number相同的成绩记录,所以过滤条件就是student.number = socre.number,整个查询语句就是这样:

  1. SELECT * FROM student, score WHERE student.number = score.number;

从上述查询结果中我们可以看到,各个同学对应的各科成绩就都被查出来了,可是有个问题,史珍香同学,也就是学号为20180103的同学因为某些原因没有参加考试,所以在score表中没有对应的成绩记录。那如果老师想查看所有同学的考试成绩,即使是缺考的同学也应该展示出来,但是到目前为止我们介绍的连接查询是无法完成这样的需求的。

这个需求的本质是:驱动表中的记录即使在被驱动表中没有匹配的记录,也仍然需要加入到结果集。为了解决这个问题,就有了内连接外连接的概念。

  • 对于内连接的两个表,驱动表中的记录在被驱动表中找不到匹配的记录,该记录不会加入到最后的结果集,我们上边提到的连接都是所谓的内连接
  • 对于外连接的两个表,驱动表中的记录即使在被驱动表中没有匹配的记录,也仍然需要加入到结果集。
    MySQL中,根据选取驱动表的不同,外连接仍然可以细分为2种:
    • 左外连接:选取左侧的表为驱动表。
    • 右外连接:选取右侧的表为驱动表。

对于外连接来说,有时候我们也并不想把驱动表的全部记录都加入到最后的结果集。把过滤条件分为两种就可以了,所以放在不同地方的过滤条件是有不同语义的:

  • WHERE子句中的过滤条件
    WHERE子句中的过滤条件就是我们平时见的那种,不论是内连接还是外连接,凡是不符合WHERE子句中的过滤条件的记录都不会被加入最后的结果集。
  • ON子句中的过滤条件
    对于外连接的驱动表的记录来说,如果无法在被驱动表中找到匹配ON子句中的过滤条件的记录,那么该记录仍然会被加入到结果集中,对应的被驱动表记录的各个字段使用NULL值填充。

需要注意的是,这个ON子句是专门为外连接驱动表中的记录在被驱动表找不到匹配记录时应不应该把该记录加入结果集这个场景下提出的,所以如果把ON子句放到内连接中,MySQL会把它和WHERE子句一样对待,也就是说:内连接中的WHERE子句和ON子句是等价的。

一般情况下,我们都把只涉及单表的过滤条件放到WHERE子句中,把涉及两表的过滤条件都放到ON子句中,我们也一般把放到ON子句中的过滤条件也称之为连接条件

1.3.1 左(外)连接的语法

比如我们要把t1表和t2表进行左外连接查询可以这么写:

  1. SELECT * FROM t1 LEFT [OUTER] JOIN t2 ON 连接条件 [WHERE 普通过滤条件];

其中中括号里的OUTER单词是可以省略的。对于LEFT JOIN类型的连接来说,我们把放在左边的表称之为外表或者驱动表,右边的表称之为内表或者被驱动表。所以上述例子中t1就是外表或者驱动表,t2就是内表或者被驱动表。需要注意的是,对于左(外)连接和右(外)连接来说,必须使用ON子句来指出连接条件。

回到我们上边那个现实问题中来,看看怎样写查询语句才能把所有的学生的成绩信息都查询出来,即使是缺考的考生也应该被放到结果集中:

  1. SELECT s1.number, s1.name, s2.subject, s2.score FROM student AS s1 LEFT JOIN score AS s2 ON s1.number = s2.number;

从结果集中可以看出来,虽然史珍香并没有对应的成绩记录,但是由于采用的是连接类型为左(外)连接,所以仍然把她放到了结果集中,只不过在对应的成绩记录的各列使用NULL值填充而已。

1.3.2 右(外)连接的语法

右(外)连接和左(外)连接的原理是一样一样的,语法也只是把LEFT换成RIGHT而已:

  1. SELECT * FROM t1 RIGHT [OUTER] JOIN t2 ON 连接条件 [WHERE 普通过滤条件];

只不过驱动表是右边的表,被驱动表是左边的表。

1.3.3 内连接的语法

内连接和外连接的根本区别就是在驱动表中的记录不符合ON子句中的连接条件时不会把该记录加入到最后的结果集。

一种最简单的内连接语法,就是直接把需要连接的多个表都放到FROM子句后边。其实针对内连接,MySQL提供了好多不同的语法,我们以t1t2表为例:

  1. SELECT * FROM t1 [INNER | CROSS] JOIN t2 [ON 连接条件] [WHERE 普通过滤条件];

也就是说在MySQL中,下边这几种内连接的写法都是等价的:

  • SELECT * FROM t1 JOIN t2;
  • SELECT * FROM t1 INNER JOIN t2;
  • SELECT * FROM t1 CROSS JOIN t2;

上边的这些写法和直接把需要连接的表名放到FROM语句之后,用逗号,分隔开的写法是等价的:

  1. SELECT * FROM t1, t2;

在内连接中ON子句和WHERE子句是等价的,所以内连接中不要求强制写明ON子句。

前边说过,连接的本质就是把各个连接表中的记录都取出来依次匹配的组合加入结果集并返回给用户。不论哪个表作为驱动表,两表连接产生的笛卡尔积肯定是一样的。而对于内连接来说,由于凡是不符合ON子句或WHERE子句中的条件的记录都会被过滤掉,其实也就相当于从两表连接的笛卡尔积中把不符合过滤条件的记录给踢出去,所以对于内连接来说,驱动表和被驱动表是可以互换的,并不会影响最后的查询结果。但是对于外连接来说,由于驱动表中的记录即使在被驱动表中找不到符合ON子句条件的记录时也要将其加入到结果集,所以此时驱动表和被驱动表的关系就很重要了,也就是说左外连接和右外连接的驱动表和被驱动表不能轻易互换。

2.连接的原理

接下来看一下MySQL采用了什么样的算法来进行表与表之间的连接。

2.1 嵌套循环连接

对于两表连接来说,驱动表只会被访问一遍,但被驱动表却要被访问到好多遍,具体访问几遍取决于对驱动表执行单表查询后的结果集中的记录条数。对于内连接来说,选取哪个表为驱动表都没关系,而外连接的驱动表是固定的,也就是说左(外)连接的驱动表就是左边的那个表,右(外)连接的驱动表就是右边的那个表。

再来看一下t1表和t2表执行内连接查询的大致过程:

  • 步骤1:选取驱动表,使用与驱动表相关的过滤条件,选取代价最低的单表访问方法来执行对驱动表的单表查询。
  • 步骤2:对上一步骤中查询驱动表得到的结果集中每一条记录,都分别到被驱动表中查找匹配的记录。

通用的两表连接过程如下图所示:

8.png

如果有3个表进行连接的话,那么步骤2中得到的结果集就像是新的驱动表,然后第三个表就成为了被驱动表,重复上边过程,也就是步骤2中得到的结果集中的每一条记录都需要到t3表中找一找有没有匹配的记录,用伪代码表示一下这个过程就是这样:

  1. for each row in t1 { #此处表示遍历满足对t1单表查询结果集中的每一条记录
  2. for each row in t2 { #此处表示对于某条t1表的记录来说,遍历满足对t2单表查询结果集中的每一条记录
  3. for each row in t3 { #此处表示对于某条t1和t2表的记录组合来说,对t3表进行单表查询
  4. if row satisfies join conditions, send to client
  5. }
  6. }
  7. }

这个过程就像是一个嵌套的循环,所以这种驱动表只访问一次,但被驱动表却可能被多次访问,访问次数取决于对驱动表执行单表查询后的结果集中的记录条数的连接执行方式称之为嵌套循环连接Nested-Loop Join),这是最简单,也是最笨拙的一种连接查询算法。

2.2 使用索引加快连接速度

嵌套循环连接步骤2中可能需要访问多次被驱动表,如果访问被驱动表的方式都是全表扫描的话,要查很多次。但是查询t2表其实就相当于一次单表扫描,我们可以利用索引来加快查询速度。回到最开始的t1表和t2表进行内连接的例子:

  1. SELECT * FROM t1, t2 WHERE t1.m1 > 1 AND t1.m1 = t2.m2 AND t2.n2 < 'd';

通用两张表连接过程.jpg

查询驱动表t1后的结果集中有两条记录,嵌套循环连接算法需要对被驱动表查询2次:

  • t1.m1 = 2时,去查询一遍t2表,对t2表的查询语句相当于:

    1. SELECT * FROM t2 WHERE t2.m2 = 2 AND t2.n2 < 'd';
  • t1.m1 = 3时,再去查询一遍t2表,此时对t2表的查询语句相当于:

    1. SELECT * FROM t2 WHERE t2.m2 = 3 AND t2.n2 < 'd';

可以看到,原来的t1.m1 = t2.m2这个涉及两个表的过滤条件在针对t2表做查询时关于t1表的条件就已经确定了,所以我们只需要单单优化对t2表的查询了,上述两个对t2表的查询语句中利用到的列是m2n2列,我们可以:

  • m2列上建立索引,因为对m2列的条件是等值查找,比如t2.m2 = 2t2.m2 = 3等,所以可能使用到ref的访问方法,假设使用ref的访问方法去执行对t2表的查询的话,需要回表之后再判断t2.n2 < d这个条件是否成立。
    这里有一个比较特殊的情况,就是假设m2列是t2表的主键或者唯一二级索引列,那么使用t2.m2 = 常数值这样的条件从t2表中查找记录的过程的代价就是常数级别的。我们知道在单表中使用主键值或者唯一二级索引列的值进行等值查找的方式称之为const,而MySQL把在连接查询中对被驱动表使用主键值或者唯一二级索引列的值进行等值查找的查询执行方式称之为:eq_ref
  • n2列上建立索引,涉及到的条件是t2.n2 < 'd',可能用到range的访问方法,假设使用range的访问方法对t2表的查询的话,需要回表之后再判断在m2列上的条件是否成立。

假设m2n2列上都存在索引的话,那么就需要从这两个里边儿挑一个代价更低的去执行对t2表的查询。当然,建立了索引不一定使用索引,只有在二级索引 + 回表的代价比全表扫描的代价更低时才会使用索引。

另外,有时候连接查询的查询列表和过滤条件中可能只涉及被驱动表的部分列,而这些列都是某个索引的一部分,这种情况下即使不能使用eq_refrefref_or_null或者range这些访问方法执行对被驱动表的查询的话,也可以使用索引扫描,也就是index的访问方法来查询被驱动表。所以我们建议在真实工作中最好不要使用*作为查询列表,最好把真实用到的列作为查询列表。

2.3 基于块的嵌套循环连接

扫描一个表的过程其实是先把这个表从磁盘上加载到内存中,然后从内存中比较匹配条件是否满足。现实生活中的表可不像t1t2这种只有3条记录,成千上万条记录都是少的,几百万、几千万甚至几亿条记录的表到处都是。内存里可能并不能完全存放的下表中所有的记录,所以在扫描表前边记录的时候后边的记录可能还在磁盘上,等扫描到后边记录的时候可能内存不足,所以需要把前边的记录从内存中释放掉。我们前边又说过,采用嵌套循环连接算法的两表连接过程中,被驱动表可是要被访问好多次的,如果这个被驱动表中的数据特别多而且不能使用索引进行访问,那就相当于要从磁盘上读好几次这个表,这个I/O代价就非常大了,所以我们得想办法:尽量减少访问被驱动表的次数。

当被驱动表中的数据非常多时,每次访问被驱动表,被驱动表的记录会被加载到内存中,在内存中的每一条记录只会和驱动表结果集的一条记录做匹配,之后就会被从内存中清除掉。然后再从驱动表结果集中拿出另一条记录,再一次把被驱动表的记录加载到内存中一遍,周而复始,驱动表结果集中有多少条记录,就得把被驱动表从磁盘上加载到内存中多少次。

如果在把被驱动表的记录加载到内存的时候,一次性和多条驱动表中的记录做匹配,这样就可以大大减少重复从磁盘上加载被驱动表的代价。

MySQL提出了一个join buffer的概念,join buffer就是执行连接查询前申请的一块固定大小的内存,先把若干条驱动表结果集中的记录装在这个join buffer中,然后开始扫描被驱动表,每一条被驱动表的记录一次性和join buffer中的多条驱动表记录做匹配,因为匹配的过程都是在内存中完成的,所以这样可以显著减少被驱动表的I/O代价。使用join buffer的过程如下图所示:

通用两张表连接过程&JOIN BUFFER.jpg

最好的情况是join buffer足够大,能容纳驱动表结果集中的所有记录,这样只需要访问一次被驱动表就可以完成连接操作了。MySQL把这种加入了join buffer的嵌套循环连接算法称之为基于块的嵌套连接(Block Nested-Loop Join)算法。

这个join buffer的大小是可以通过启动参数或者系统变量join_buffer_size进行配置,默认大小为262144字节(也就是256KB),最小可以设置为128字节。当然,对于优化被驱动表的查询来说,最好是为被驱动表加上效率高的索引,如果实在不能使用索引,并且自己的机器的内存也比较大可以尝试调大join_buffer_size的值来对连接查询进行优化。

另外需要注意的是,驱动表的记录并不是所有列都会被放到join buffer中,只有查询列表中的列和过滤条件中的列才会被放到join buffer中,所以再次提醒我们,最好不要把*作为查询列表,只需要把我们关心的列放到查询列表就好了,这样还可以在join buffer中放置更多的记录。