1 单表访问之索引合并
MySQL在一般情况下执行一个查询时最多
只会用到单个二级索引
,但存在有特殊情况,也可能在一个查询中使用到多个二级索引,MySQL中这种使用到多个索引来完成一次查询的执行方法称之为:索引合并/index merg
。e
1.1 Intersection 合并
这里是说某个查询可以使用多个二级索引
,将从多个二级索引中查询到的结果取交集
,比方说下边这个查询:
SELECT * FROM order_exp WHERE order_no = 'a' AND expire_time = 'b';
假设这个查询使用 Intersection 合并的方式执行的话,那这个过程就是这样的:
- 从
idx_order_no
二级索引对应的B+树
中取出order_no= 'a'
的相关记录。 - 从
idx_expire_time
二级索引对应的B+树
中取出expire_time= 'b'
的相关记录。 - 二级索引的记录都是由
索引列 + 主键
构成的,所以我们可以计算出这两个结果集中id
值的交集
。 - 按照上一步生成的
id
值列表进行回表
操作,也就是从聚簇索引
中把指定 id 值
的完整用户记录取出来,返回给用户。
为啥不直接使用
idx_order_no
或者idx_expire_time
只根据某个搜索条件去读取一个二级索引
,然后回表
后再过滤
另外一个搜索条件呢?
这里要分析一下两种查询执行方式之间需要的成本代价
。
- 只读取一个二级索引的成本:按照某个搜索条件读取一个二级索引,根据该二级索引得到的主键值进行
回表
操作,然后再过滤
其他的搜索条件 - 读取多个二级索引之后取交集成本:按照不同的搜索条件
分别读取
不同的二级索引,将从多个二级索引得到的主键值
取交集
,然后进行回表
操作。
虽然读取多个二级索引比读取一个二级索引消耗性能,但是大部分情况下读取二级索引的操作是顺序 I/O
,而回表
操作是随机 I/O
,所以如果只读取一个二级索引时需要回表的记录数特别多,而读取多个二级索引之后取交集的记录数非常少,当节省的因为回表而造成的性能损耗比访问多个二级索引带来的性能损耗更高时,读取多个二级索引后取交集比只读取一个二级索引的成本更低
。
MySQL 在某些特定的情况下才可能会使用到
Intersection 索引合并
,哪些情况呢?
- 情况一:
等值匹配
:二级索引列是等值匹配的情况,对于联合索引
来说,在联合索引中的每个列都必须等值匹配
,不能出现只匹配部分列的情况。- 情况二:主键列可以是范围匹配。
之所以在二级索引列都是
等值匹配
的情况下才可能使用Intersection 索引合并
,是因为只有在这种情况下根据二级索引查询出的结果集是按照主键值排序的
。Intersection 索引合并会把从多个二级索引中查询出的主键值求交集,如果从各个二级索引中查询的到的结果集本身就是已经按照主键排好序的,那么求交集的过程就很容易。
1.2 Union 合并
Union 是并集
的意思,适用于使用不同索引的搜索条件之间使用 OR
连接起来的情况。
比如:
SELECT * FROM order_exp WHERE order_no = 'a' OR expire_time = 'b'
与 Intersection 索引合并
类似,MySQL 在某些特定的情况下才可能会使用到 Union 索引合并
:
- 情况一:等值匹配分析同 Intersection 合并
- 情况二:主键列可以是范围匹配 分析同 Intersection 合并
- 情况三:使用 Intersection 索引合并的搜索条件。就是搜索条件的
某些部分
使用Intersection 索引合并
的方式得到的主键集合和其他方式
得到的主键集合取交集
,比方说这个查询:
优化器SELECT * FROM order_exp WHERE insert_time = 'a' AND order_status = 'b' AND expire_time = 'c' OR (order_no = 'a' AND expire_time = 'b');
可能
采用这样的方式来执行这个查询:
- 先按照搜索条件
order_no = 'a' AND expire_time = 'b'
从索引idx_order_no
和idx_expire_time
中使用Intersection 索引合并
的方式得到一个主键集合。 - 再按照搜索条件
insert_time = 'a' AND order_status = 'b' AND expire_time = 'c'
从联合索引u_idx_day_status
中得到另一个主键集合。 - 采用
Union 索引合并
的方式把上述两个主键集合取并集
,然后进行回表
操作,将结果返回给用户。
当然,查询条件符合了这些情况也不一定
就会采用 Union 索引合并,也得看优化器的心情。优化器只有在单独根据搜索条件从某个二级索引中获取的记录数比较少,通过 Union 索引合并后进行访问的代价
比全表扫描更小
时才会使用 Union 索引合并。
1.3 Sort_Union 合并
先按照二级索引记录的主键值进行排序
,之后按照 Union 索引合并
方式执行的方式称之为 Sort-Union 索引合并
.
1.4 联合索引替代Intersection 索引合并
SELECT * FROM order_exp WHERE order_no= 'a' And expire_time= 'z';
这个查询之所以可能使用 Intersection 索引合并的方式执行,还不是因为 idx_order_no
和 idx_expire_time
是两个单独的 B+树索引,要是把这两个列搞一个联合索引
,那直接使用这个联合索引就把事情搞定了,何必用啥索引合并呢,就像这样:
ALTER TABLE order_exp drop index idx_order_no, idx_expire_time,
add index idx_order_no_expire_time(order_no, expire_time);
这样我们把 idx_order_no, idx_expire_time 都干掉,再添加一个联合索引 idx_order_no_expire_time
,使用这个联合索引进行查询简直是又快又好,既不用多读一棵 B+树,也不用合并结果。
2 连接查询
连接的
本质
就是把各个连接表中的记录都取出来依次匹配的组合
加入结果集并返回给用户。
3 MySQL 的查询成本
3.1 成本组成
MySQL 执行一个查询可以有不同的执行方案,它会选择其中成本最低
,或者说代价最低
的那种方案去真正地执行查询。
在 MySQL 中一条查询语句
的执行成本
是由下边这两个方面
组成的:
- I/O成本:表、索引等数据是存储在磁盘上的,当用户查询表数据时,MySQL需要先把数据或者索引加载到
内存
中然后再操作。这个从磁盘到内存
这个加载的过程损耗的时间
称之为I/O 成本
。 - CPU成本:
读取以及检测记录是否满足对应的搜索条件
、对结果集进行排序
等这些操作损耗的时间
称之为CPU 成本
。 - 成本常数:对于
InnoDB
存储引擎来说,页
是磁盘
和内存
之间交互的基本单位
。常用的成本常数有(当然还有其他的成本常数):- 读取一个页面花费的成本默认是
1.0
- 读取以及检测一条记录是否符合搜索条件的成本默认是
0.2
。注意,不管读取记录时需不需要检测是否满足搜索条件,其成本都算是 0.2。
- 读取一个页面花费的成本默认是
3.2 单表查询的成本
3.2.1 基于成本的优化步骤实战
在一条单表查询语句真正执行之前,MySQL 的查询优化器
会找出执行该语句所有可能
使用的方案,对比之后找出成本最低的方案
,这个成本最低的方案就是所谓的执行计划
,之后才会调用存储引擎提供的接口真正地执行查询,这个过程总结一下就是这样:
- 根据搜索条件,找出所有可能使用的索引
- 计算全表扫描的代价
- 计算使用不同索引执行查询的代价
- 对比各种执行方案的代价,找出成本最低的那一个
3.2.2 举例
现有以下表结构
CREATE TABLE order_exp
(
`id` bigint(22) NOT NULL AUTO_INCREMENT COMMENT '订单的主键',
`order_no` varchar(50) NOT NULL COMMENT '订单的编号',
`order_note` varchar(100) NOT NULL COMMENT '订单说明',
`insert time` datetime(0) NOT NULL COMMENT '插入订单的时间',
`expire_duration` bigint(22) NOT NULL COMMENT '订单的过期时长, 单位秒',
`expire_time` datetime(0) NOT NULL COMMENT '订单的过期时间',
`order_status` smallint(6) NOT NULL DEFAULT 0 COMMENT '订单状态:0:未支付,1:已支付,-1: 已过期、关闭',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX 'idx day status' (`insert time`, `order_status`) USING BTREE
) ENGINE = InnoDB
AUTO_INCREMENT = 16
CHARACTER SET utf8mb4
COLLATE utf8_general_ci COMMENT ='订单表';
查询语句如下:
SELECT *
FROM order_exp
WHERE order_no IN ('DD00_6S', 'DD00_9S', 'DD00_10S')
AND expire_time > '2021-03-22 18:28:28'
AND expire_time <= '2021-03-22 18:35:09'
AND insert_time > expire_time
AND order_note LIKE '%hello%'
AND order_status = 0;
3.2.2.1 根据搜索条件,找出所有可能使用的索引
我们分析一下上边查询中涉及到的几个搜索条件:
order_no IN ('DD00_6S', 'DD00_9S', 'DD00_10S')
,这个搜索条件可以使用二级索引idx_order_no
。expire_time> '2021-03-22 18:28:28' AND expire_time<= '2021-03-22 18:35:09'
,这个搜索条件可以使用二级索引idx_expire_time
。insert_time> expire_time
,这个搜索条件的索引列由于没有和常数比较,所以并不能使用到索引。order_note LIKE '%hello%'
,order_note
即使有索引,但是通过LIKE
操作符和以通配符开头
的字符串做比较,不可以适用索引。order_status = 0
,由于该列上只有联合索引,而且不符合最左前缀原则
,所以不会用到索引。
综上所述,上边的查询语句可能用到的索引,也就是 possible keys
只有 idx_order_no
,idx_expire_time
。
Tips:MySQL把一个查询中
可能
使用到的索引
称之为possible keys
。
3.2.2.2 计算全表扫描的代价
对于InnoDB存储引擎
来说,全表扫描
的意思就是把聚簇索引
中的记录都依次和给定的搜索条件做一下比较,把符合搜索条件的记录加入到结果集
,所以需要将聚簇索引对应的页面加载到内存中,然后再检测记录是否符合搜索条件。
由于查询成本 = I/O 成本 + CPU 成本
,所以计算全表扫描的代价需要两个信息:
- 聚簇索引占用的页面数
- 该表中的记录数
MySQL给我们提供了 SHOW TABLE STATUS
语句来查看表的统计信息,以上表order_exp
为例,查询该表的统计信息语句如下:
SHOW TABLE STATUS LIKE 'order_exp' \G
结果如下:
mysql> SHOW TABLE STATUS LIKE 'order_exp' \G
****************************1.row***************************
Name: order_exp
Engine: InnoDB
Version: 10
Row_format: Dynamic
Rows: 10350
Avg_row_length: 153
Data_length: 1589248
Max_data_length: 0
Index_length: 1130496
Data_free: 4194304
Auto_increment: 10819
Create_time: 2021-03-31 15:39:43
Update_time: 2021-04-05 18:43:17
Check_time: NULL
Collation: utf8_general_ci
Checksum: NULL
Create_options:
Comment:
1 row in set (0.00 sec)
我们只需要关心这两个参数:
- Rows:表示表中的记录条数。对于使用
MyISAM
存储引擎的表来说,该值是准确的
,对于使用InnoDB
存储引擎的表来说,该值是一个估计值
。 - Data_length:表示表占用的存储空间字节数。使用
MyISAM
存储引擎的表来说,该值就是数据文件的大小
,对于使用InnoDB
存储引擎的表来说,该值就相当于聚簇索引占用的存储空间大小
,也就是说可以这样计算该值的大小:
这里Data_length = 聚簇索引的页面数量 * 每个页面的大小
order_exp
使用默认16KB
的页面大小,而上边查询结果显示Data_length
的值是1589248
,所以我们可以反向来推导出聚簇索引的页面数量:聚簇索引的页面数量 = 1589248 ÷ 16 ÷ 1024 = 97
现在已经得到了聚簇索引占用的页面数量
以及该表记录数的估计值
,所以就可以计算全表扫描成本
了。
- I/O 成本:
97 * 1.0 + 1.1 = 98.1
,97
指的是聚簇索引占用的页面数,1.0
指的是加载一个页面的成本常数,后边的1.1
是一个微调值。TIPS:MySQL 在真实计算成本时会进行一些微调,这些微调的值是直接硬编码到代码里的,没有注释而且这些微调的值十分的小,并不影响我们分析。
- CPU 成本:
10350 * 0.2 + 1.0 = 2071
,10350
指的是统计数据中表的记录数,对于InnoDB存储引擎来说是一个估计值,0.2
指的是访问一条记录所需的成本常数,后边的1.0
是一个微调值。 - 总成本:
98.1 + 2071 = 2169.1
综上所述,对于 order_exp
的全表扫描
所需的总成本
就是2169.1
。
TIPS:表中的记录其实都存储在
聚簇索引
对应B+树
的叶子节点
中,所以只要我们通过根节点获得了最左边的叶子节点,就可以沿着叶子节点组成的双向链表
把所有记录都查看一遍。
也就是说全表扫描这个过程其实有的B+树非叶子节点
是不需要访问的,但是MySQL
在计算全表扫描成本
时直接使用聚簇索引占用的页面数
作为计算I/O成本
的依据,是不区分
非叶子节点和叶子节点的。
3.2.2.3 计算使用不同索引执行查询的代价
MySQL查询优化器
先分析使用唯一二级索引
的成本,再分析使用普通索引
的成本。这里的表order_exp
只有两个普通二级索引,先算哪个都可以。
在计算全表扫描的代价一节中,可能用到的索引有idx_order_no
,idx_expire_time
。因而接下来,我们分析这两个索引的查询成本分别是多少。
3.2.2.3.1 使用 idx_expire_time
执行查询的成本分析
idx_expire_time
对应的搜索条件是:expire_time> '2021-03-22 18:28:28' AND expire_time<= '2021-03-22 18:35:09'
,也就是说对应的范围区间就是: ('2021-03-22 18:28:28' , '2021-03-22 18:35:09' )
。
使用 idx_expire_time
搜索会使用用二级索引 + 回表方式
的查询,MySQL 计算这种查询的成本依赖两个方面
的数据:
范围区间数量:不论某个范围区间的二级索引到底占用了多少页面,查询优化器认为读取
索引的一个范围区间
的I/O 成本
和读取一个页面
是相同的。本例中使用
idx_expire_time
的范围区间只有一个
,所以相当于访问这个范围区间的二级索引付出的I/O 成本
就是:1 * 1.0 = 1.0
- 需要回表的记录数:优化器需要计算二级索引的某个范围区间到底包含多少条记录,对于本例来说就是要计算
idx_expire_time
在('2021-03-22 18:28:28' ,'2021-03-22 18:35:09')
这个范围区间中包含多少二级索引记录,计算过程是这样的:- 先根据
expire_time> '2021-03-22 18:28:28'
这个条件访问一下idx_expire_time
对应的 B+树索引,找到满足expire_time> '2021-03-22 18:28:28'
这个条件的第一条记录
,我们把这条记录称之为区间最左记录
。 - 再根据
expire_time<= '2021-03-22 18:35:09'
这个条件继续从idx_expire_time
对应的 B+树索引中找出最后一条
满足这个条件的记录,我们把这条记录称之为区间最右记录
。 - 如果
区间最左记录
和区间最右记录
相隔不太远(在MySQL 5.7
这个版本里,只要相隔不大于 10 个页面
即可),那就可以精确
统计出满足expire_time> '2021-03-22 18:28:28' AND expire_time<= '2021-03-22 18:35:09'
条件的二级索引记录条数
。否则只沿着区间最左记录向右
读10个页面
,计算平均每个页面中包含多少记录,然后用这个平均值
乘以区间最左记录和区间最右记录之间的页面数量
就可以了。
- 先根据
那么问题又来了,怎么估计
区间最左记录
和区间最右记录
之间有多少个页面呢?解决这个问题还得回到B+树索引的结构中来。我们假设区间最左记录在
页b
中,区间最右记录在页c
中,那么我们想计算区间最左记录和区间最右记录之间的页面数量,就相当于计算页b和页c之间有多少页面,而它们父节点
中记录的每一条目录项记录都对应一个数据页,所以计算页b和页c之间有多少页面就相当于计算它们父节点
(也就是页a)中对应的目录项记录之间隔着几条记录。在一个页面中统计两条记录之间有几条记录的成本就很小了。不过还有问题,如果页 b 和页 c 之间的页面实在太多,以至于页 b 和页 c 对应的目录项记录都不在一个父页面中怎么办?
既然是树,那就继续
递归
,一个B+树有4层
高已经很不得了了,所以这个统计过程也不是很耗费性能。
回到正题,现在计算idx_expire_time
所花费的CPU成本
。
MySQL根据上述算法测得 idx_expire_time
在区间('2021-03-22 18:28:28' , '2021-03-22 18:35:09')
之间大约有 39
条记录。可执行以下语句得到记录数Row:
explain SELECT * FROM order_exp WHERE expire_time> '2021-03-22 18:28:28' AND expire_time<= '2021-03-22 18:35:09';
读取这 39 条二级索引记录需要付出的 CPU 成本
就是:
39 x 0.2 + 0.01 = 7.81
其中
39
是需要读取的二级索引记录条数
,0.2
是读取一条记录成本常数,0.01
是微调。再计算I/O成本
。
在通过二级索引获取到记录之后,还需要干两件事儿
:
1)根据这些记录里的主键值到聚簇索引中做回表
操作
MySQL评估回表操作
的I/O成本
依旧很简单粗暴,其认为每次回表操作都相当于访问一个页面
,也就是说二级索引范围区间有多少记录,就需要进行多少次回表操作,也就是需要进行多少次页面 I/O
。
上面得到了39条记录,需要进行39次的回表,因而得出I/O成本
为:
39 * 1.0 = 39 .0
其中
39
是预计的二级索引记录数
,1.0
是一个页面的 I/O 成本常数
。2)回表操作后得到的完整用户记录,然后再检测其他搜索条件是否成立回表操作
的本质
就是通过二级索引记录的主键值到聚簇索引中找到完整的用户记录,然后再检测除 expire_time> '2021-03-22 18:28:28' AND expire_time< '2021-03-22 18:35:09'
这个搜索条件以外的搜索条件是否成立
。
因为我们通过范围区间获取到二级索引记录共 39 条
,也就对应着聚簇索引中 39 条完整的用户记录,读取并检测这些完整的用户记录是否符合其余的搜索条件的 CPU 成本
如下:
39 * 0.2 =7.8
其中
39
是待检测记录的条数,0.2
是检测一条记录是否符合给定的搜索条件的成本常数。所以本例中使用 idx_expire_time
执行查询的成本就如下所示:
- I/O 成本:
1.0 + 39 * 1.0 = 40 .0
(范围区间的数量 + 预估的二级索引记录条数) - CPU 成本:
39 * 0.2 + 0.01 + 39 * 0.2 = 15.61
(读取二级索引记录的成本 + 读取并检测回表后聚簇索引记录的成本)
综上所述,使用 idx_expire_time
执行查询的总成本就是: 40 .0 + 15.61 = 55.61
3.2.2.3.2 使用 idx_order_no
执行查询的成本分析
idx_order_no
对应的搜索条件是:order_no IN ('DD00_6S', 'DD00_9S', 'DD00_10S')
,也就是说相当于 3 个单点区间
。
与使用 idx_expire_time
的情况类似,我们也需要计算使用 idx_order_no
时需要访问的范围区间数量
以及需要回表的记录数
,计算过程与上面类似,我们不详列所有计算步骤和说明了。
- 范围区间数量
使用idx_order_no
执行查询时很显然有3 个单点区间
,所以访问这 3 个范围区间的二级索引付出的I/O 成本
就是:3 * 1.0 = 3.0
- 需要回表的记录数
由于使用idx_expire_time
时有 3 个单点区间,所以每个单点区间都需要查找一遍对应的二级索引记录数,三个单点区间总共需要回表的记录数是58
。语句如下:
读取这些二级索引记录的explain SELECT * FROM order_exp WHERE order_no IN ('DD00_6S', 'DD00_9S', 'DD00_10S');
CPU 成本
就是:58 * 0.2 + 0.01 = 11.61
再计算I/O成本
。
根据这些记录里的主键值到聚簇索引中做回表操作,所需的 I/O 成本
就是:
58 * 1.0 = 58.0
回表操作后得到的完整用户记录,然后再比较其他搜索条件是否成立。此步骤对应的 CPU 成本
就是:
58 * 0.2 = 11.6
综上,使用 idx_order_no
执行查询的成本就如下所示:
- I/O 成本:
3.0 + 58 x 1.0 = 61.0
(范围区间的数量 + 预估的二级索引记录条数) - CPU 成本:
58 x 0.2 + 58 x 0.2 + 0.01 = 23.21
(读取二级索引记录的成本 + 读取并检测回表后聚簇索引记录的成本)
综上所述,使用 idx_order_no
执行查询的总成本
就是: 61.0 + 23.21 = 84.21
3.2.2.3.3 是否有可能使用索引合并(Index Merge)
本例中有关 order_no
和 expire_time
的搜索条件是使用 AND
连接起来的,而对于 idx_order_no
和 idx_expire_time
都是范围查询
,也就是说查找到的二级索引记录并不是按照主键值
进行排序的,并不满足
使用 Intersection 索引合并
的条件,所以并不会使用索引合并。而且 MySQL 查询优化器计算索引合并成本
的算法也比较麻烦,所以我们也不会细说。
3.2.3 对比各种方案,找出成本最低的那一个
下边把执行本例中的查询的各种可执行方案以及它们对应的成本列出来:
- 全表扫描的成本:
2148.7
- 使用
idx_expire_time
的成本:55.61
- 使用
idx_order_no
的成本:84.21
很显然,使用 idx_expire_time
的成本最低,所以当然选择 idx_expire_time
来执行查询。
Warning: