MySQL Order By 工作原理探究以及索引优化手段

背景

这篇文章出发的契机,主要是看到京东定时任务优化里面有使用到「游标」这种策略去完成查询优化

https://mp.weixin.qq.com/s/aYJV3Z-1MZ_a6tUjoHa_9g

刚好之前自己在开发业务的时候,也碰到类似的场景,需要在每个季度末将被申诉的卡片更新为删除状态并通知下游团队更新广告主业绩数据。这其中也会有定时任务调度触发,并且也使用了游标的优化手段,核心就是查询 SQL 语句中的如下片段:

复制代码
order by id ASC AND id > {上一次查询出来的最大id}

但是 order by 在 SQL 的运行原理是什么我还不是很清楚,于是就想写下这篇文章进行学习,主要是参考丁奇大佬的《MySQL 实战 45 讲》 的《16 | "order by"是怎么工作的?》,这篇文章的主要框架如下:

环境搭建

手把手按照大佬的实操记录来

sql 复制代码
CREATE TABLE `t` (
                     `id` int(11) NOT NULL,
                     `city` varchar(16) NOT NULL,
                     `name` varchar(16) NOT NULL,
                     `age` int(11) NOT NULL,
                     `addr` varchar(128) DEFAULT NULL,
                     PRIMARY KEY (`id`),
                     KEY `city` (`city`) /*创建普通索引*/
) ENGINE=InnoDB;

查询语句如下所示

sql 复制代码
explain select city,name,age from t where city='杭州' order by name limit 1000  ;

执行计划如下所示:

从 key 字段中看出来是根据字段 city 索引查找的,Extra 中 Using filesort 表明 order by 进行了排序(内存排序或者外部排序)。

全字段排序

MySQL 会给每个线程分配一块内存用于排序,称作 sort_buffer.

为了说明这个 SQL 查询语句的执行过程,我们先来看一下 city 这个索引的示意图。

图 2 city 字段的索引示意图

从图中可以看到,满足 city='杭州'条件的行,是从 ID_X 到 ID_(X+N) 的这些记录。通常情况下,这个语句执行流程如下所示 :

  1. 初始化 sort_buffer,确定放入 name、city、age 这三个字段;
  2. 从索引 city 找到第一个满足 city='杭州'条件的主键 id,也就是图中的 ID_X;
  3. 到主键 id 索引取出整行,取 name、city、age 三个字段的值,存入 sort_buffer 中;(这里是根据非聚簇索引中的主键 ID 回表筛选拿到 name、city、age 字段值的)
  4. 从索引 city 取下一个记录的主键 id;
  5. 重复步骤 3、4 直到 city 的值不满足查询条件为止,对应的主键 id 也就是图中的 ID_Y;
  6. 对 sort_buffer 中的数据按照字段 name 做快速排序;
  7. 按照排序结果取前 1000 行返回给客户端。

我们暂且把这个排序过程,称为全字段排序(因为 select 中的所有字段 city、name、age 都被放入了 sort_buffer 中,虽然只用 name 进行排序),执行流程的示意图如下所示:

图 3 全字段排序

图中"按 name 排序"这个动作,可能在内存中完成,也可能需要使用外部排序,这取决于排序所需的内存和参数 sort_buffer_size。sort_buffer_size,就是 MySQL 为排序开辟的内存(sort_buffer)的大小。如果要排序的数据量小于 sort_buffer_size,排序就在内存中完成。但如果排序数据量太大,内存放不下,则不得不利用磁盘临时文件辅助排序。

你可以用下面介绍的方法,来确定一个排序语句是否使用了临时文件。

sql 复制代码
/* 打开optimizer_trace,只对本线程有效 */
SET optimizer_trace='enabled=on'; 

/* @a保存Innodb_rows_read的初始值 */
select VARIABLE_VALUE into @a from  performance_schema.session_status where variable_name = 'Innodb_rows_read';

/* 执行语句 */
select city, name,age from t where city='杭州' order by name limit 1000; 

/* 查看 OPTIMIZER_TRACE 输出 */
SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`\G

/* @b保存Innodb_rows_read的当前值 */
select VARIABLE_VALUE into @b from performance_schema.session_status where variable_name = 'Innodb_rows_read';

/* 计算Innodb_rows_read差值 */
select @b-@a;

这个方法是通过查看 OPTIMIZER_TRACE 的结果来确认的,你可以从 number_of_tmp_files 中看到是否使用了临时文件。

图 4 全排序的 OPTIMIZER_TRACE 部分结果

number_of_tmp_files 表示的是,排序过程中使用的临时文件数。你一定奇怪,为什么需要 12 个文件?内存放不下时,就需要使用外部排序,外部排序一般使用归并排序算法。可以这么简单理解,MySQL 将需要排序的数据分成 12 份,每一份单独排序后存在这些临时文件中。然后把这 12 个有序文件再合并成一个有序的大文件。如果 sort_buffer_size 超过了需要排序的数据量的大小,number_of_tmp_files 就是 0,表示排序可以直接在内存中完成。否则就需要放在临时文件中排序。sort_buffer_size 越小,需要分成的份数越多,number_of_tmp_files 的值就越大。接下来,我再和你解释一下图 4 中其他两个值的意思。我们的示例表中有 4000 条满足 city='杭州'的记录,所以你可以看到 examined_rows=4000,表示参与排序的行数是 4000 行。sort_mode 里面的 packed_additional_fields 的意思是,排序过程对字符串做了"紧凑"处理。即使 name 字段的定义是 varchar(16),在排序过程中还是要按照实际长度来分配空间的。同时,最后一个查询语句 select @b-@a 的返回结果是 4000,表示整个执行过程只扫描了 4000 行。

这里需要注意的是,为了避免对结论造成干扰,我把 internal_tmp_disk_storage_engine 设置成 MyISAM。否则,select @b-@a 的结果会显示为 4001。这是因为查询 OPTIMIZER_TRACE 这个表时,需要用到临时表,而 internal_tmp_disk_storage_engine 的默认值是 InnoDB。如果使用的是 InnoDB 引擎的话,把数据从临时表取出来的时候,会让 Innodb_rows_read 的值加 1。

rowid 排序

在上面这个算法过程里面,只对原表的数据读了一遍,剩下的操作都是在 sort_buffer 和临时文件中执行的。但这个算法有一个问题,就是如果查询要返回的字段很多的话,那么 sort_buffer 里面要放的字段数太多,这样内存里能够同时放下的行数很少,要分成很多个临时文件,排序的性能会很差。所以如果单行很大,这个方法效率不够好。

那么,如果 MySQL 认为排序的单行长度太大会怎么做呢?接下来,我来修改一个参数,让 MySQL 采用另外一种算法。

sql 复制代码
SET max_length_for_sort_data = 16;

max_length_for_sort_data,是 MySQL 中专门控制用于排序的行数据的长度的一个参数(默认为 4096)。它的意思是,如果单行的长度超过这个值,MySQL 就认为单行太大,要换一个算法。

city、name、age 这三个字段的定义总长度是 36(16+16+4),我把 max_length_for_sort_data 设置为 16,我们再来看看计算过程有什么改变。新的算法放入 sort_buffer 的字段,只有要排序的列(即 name 字段)和主键 id。但这时,排序的结果就因为少了 city 和 age 字段的值,不能直接返回了,整个执行流程就变成如下所示的样子:

  1. 初始化 sort_buffer,确定放入两个字段,即 name 和 id;
  2. 从索引 city 找到第一个满足 city='杭州'条件的主键 id,也就是图中的 ID_X(这里总共有 4000 条符合条件的记录);
  3. 到主键 id 索引取出整行,取 name、id 这两个字段,存入 sort_buffer 中;
  4. 从索引 city 取下一个记录的主键 id;
  5. 重复步骤 3、4 直到不满足 city='杭州'条件为止,也就是图中的 ID_Y;
  6. 对 sort_buffer 中的数据按照字段 name 进行排序;
  7. 遍历排序结果,取前 1000 行,并按照 id 的值回到原表中取出 city、name 和 age 三个字段返回给客户端。

这个执行流程的示意图如下,我把它称为 rowid 排序。

这样子相当于是为了尽量少地使用外部文件排序,将一条排序记录缩小,最终再多一次回表查询将排序后需要的 select 字段返回。

现在,我们就来看看结果有什么不同。首先,图中的 examined_rows 的值还是 4000,表示用于排序的数据是 4000 行。但是 select @b-@a 这个语句的值变成 5000 了。因为这时候除了排序过程外,在排序完成后,还要根据 id 去原表取值。由于语句是 limit 1000,因此会多读 1000 行。

图 6 rowid 排序的 OPTIMIZER_TRACE 部分输出

从 OPTIMIZER_TRACE 的结果中,你还能看到另外两个信息也变了。

  • sort_mode 变成了 ,表示参与排序的只有 name 和 id 这两个字段。
  • number_of_tmp_files 变成 10 了,是因为这时候参与排序的行数虽然仍然是 4000 行,但是每一行都变小了,因此需要排序的总数据量就变小了,需要的临时文件也相应地变少了。

全字段排序 VS rowid 排序

我们来分析一下,从这两个执行流程里,还能得出什么结论。

如果 MySQL 实在是担心排序内存太小,会影响排序效率,才会采用 rowid 排序算法,这样排序过程中一次可以排序更多行,但是需要再回到原表去取数据。如果 MySQL 认为内存足够大,会优先选择全字段排序,把需要的字段都放到 sort_buffer 中,这样排序后就会直接从内存里面返回查询结果了,不用再回到原表去取数据。

这也就体现了 MySQL 的一个设计思想:如果内存够,就要多利用内存,尽量减少磁盘访问。对于 InnoDB 表来说,rowid 排序会要求回表多造成磁盘读,因此不会被优先选择。

Order by 索引优化

其实,并不是所有的 order by 语句,都需要排序操作的。从上面分析的执行过程,我们可以看到,MySQL 之所以需要生成临时表,并且在临时表上做排序操作,其原因是原来的数据都是无序的。

你可以设想下,如果能够保证从 city 这个索引上取出来的行,天然就是按照 name 递增排序的话,是不是就可以不用再排序了呢?

sql 复制代码
alter table t add index city_user(city, name);

作为与 city 索引的对比,我们来看看这个索引的示意图。

图 7 city 和 name 联合索引示意图

在这个索引里面,我们依然可以用树搜索的方式定位到第一个满足 city='杭州'的记录,并且额外确保了,接下来按顺序取"下一条记录"的遍历过程中,只要 city 的值是杭州,name 的值就一定是有序的。

这样整个查询过程的流程就变成了:

  1. 从索引 (city,name) 找到第一个满足 city='杭州'条件的主键 id;(满足条件的有 4000 条记录,但看后面不是全部都要扫描的)
  2. 到主键 id 索引取出整行,取 name、city、age 三个字段的值,作为结果集的一部分直接返回;(不需要临时表,也不需要排序了)
  3. 从索引 (city,name) 取下一个记录主键 id;
  4. 重复步骤 2、3,直到查到第 1000 条记录,或者是不满足 city='杭州'条件时循环结束。

图 8 引入 (city,name) 联合索引后,查询语句的执行计划

可以看到,这个查询过程不需要临时表,也不需要排序。接下来,我们用 explain 的结果来印证一下。

图 9 引入 (city,name) 联合索引后,查询语句的执行计划

从图中可以看到,Extra 字段中没有 Using filesort 了,也就是不需要排序了。而且由于 (city,name) 这个联合索引本身有序,所以这个查询也不用把 4000 行全都读一遍、再排序、再 limit,而是只要找到满足条件的前 1000 条记录就可以退出了。也就是说,在我们这个例子里,只需要扫描 1000 次。

在排序的外部排序文件读取、排序耗时、以及排序的文件扫描次数上都有了提升,前提是建立索引牺牲了一些索引空间消耗。但最后还是有一次回表的查到 age 字段的操作。

覆盖索引优化

覆盖索引是指,索引上的信息足够满足查询请求,不需要再回到主键索引上去取数据。

针对这个查询,我们可以创建一个 city、name 和 age 的联合索引,对应的 SQL 语句就是:

sql 复制代码
alter table t add index city_user_age(city, name, age);

这时,对于 city 字段的值相同的行来说,还是按照 name 字段的值递增排序的,此时的查询语句也就不再需要排序了。这样整个查询语句的执行流程就变成了:

  1. 从索引 (city,name,age) 找到第一个满足 city='杭州'条件的记录,取出其中的 city、name 和 age 这三个字段的值,作为结果集的一部分直接返回;
  2. 从索引 (city,name,age) 取下一个记录,同样取出这三个字段的值,作为结果集的一部分直接返回;
  3. 重复执行步骤 2,直到查到第 1000 条记录,或者是不满足 city='杭州'条件时循环结束。

图 10 引入 (city,name,age) 联合索引后,查询语句的执行流程

在前面的基础上少了回表的操作。

然后,我们再来看看 explain 的结果。

图 11 引入 (city,name,age) 联合索引后,查询语句的执行计划

可以看到,Extra 字段里面多了"Using index",表示的就是使用了覆盖索引,性能上会快很多。当然,这里并不是说要为了每个查询能用上覆盖索引,就要把语句中涉及的字段都建上联合索引,毕竟索引还是有维护代价的。这是一个需要权衡的决定。

相关推荐
怕什么真理无穷3 分钟前
mysql server 9.4 windows安装教程(sqlyog 下载)
数据库
Olrookie9 分钟前
MySQL运维常用SQL
运维·数据库·sql·mysql·dba
数据库生产实战19 分钟前
ORACLE 19C ADG环境 如何快速删除1.8TB的分区表?有哪些注意事项?
数据库·oracle
blackorbird36 分钟前
使用 Overpass Turbo 查找监控摄像头
运维·服务器·数据库·windows
IT永勇40 分钟前
SQLite数据库基本操作
数据库·sqlite·嵌入式开发·增删改查·关系型数据库
洋不写bug42 分钟前
数据库的创建,查看,修改,删除,字符集编码和校验操作
android·数据库·adb
想ai抽1 小时前
吃透大数据算法-算法地图(备用)
大数据·数据库·spark
weixin_307779131 小时前
Clickhouse导出库的表、视图、用户和角色定义的SQL语句
开发语言·数据库·算法·clickhouse·自动化
流星白龙1 小时前
【Qt】7.信号和槽_connect函数用法(1)
开发语言·数据库·qt
码界奇点1 小时前
平替MongoDB金仓多模数据库在电子证照国产化中的实践与优势
数据库·mongodb·社交电子·里氏替代原则