【实战篇】“order by”是怎么工作的?

示例

我们考虑一个表:

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;

全字段排序

为避免全表扫描,我们需要在 city 字段加上索引:
在这里插入图片描述
Extra 这个字段中的“Using filesort”表示的就是需要排序,MySQL 会给每个线程分配一块内存用于排序,称为 sort_buffer。
在这里插入图片描述
从图中可以看到,满足 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 中;
  4. 从索引 city 取下一个记录的主键 id;
  5. 重复步骤 3、4 直到 city 的值不满足查询条件为止,对应的主键 id 也就是图中的 ID_Y;
  6. 对 sort_buffer 中的数据按照字段 name 做快速排序;
  7. 按照排序结果取前 1000 行返回给客户端。

我们暂且把这个排序过程,称为全字段排序,执行流程的示意图如下所示,下一篇文章中我们还会用到这个排序。
在这里插入图片描述
图中“按 name 排序”这个动作,可能在内存中完成,也可能需要使用外部排序,这取决于排序所需的内存和参数 sort_buffer_size。

sort_buffer_size,就是 MySQL 为排序开辟的内存(sort_buffer)的大小。如果要排序的数据量小于 sort_buffer_size,排序就在内存中完成。但如果排序数据量太大,内存放不下,则不得不利用磁盘临时文件辅助排序。

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

/* 打开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 中看到是否使用了临时文件。
在这里插入图片描述
number_of_tmp_files 表示的是,排序过程中使用的临时文件数。内存放不下时,就需要使用外部排序,外部排序一般使用归并排序算法。可以这么简单理解,** MySQL 将需要排序的数据分成 12 份,每一份单独排序后存在这些临时文件中。然后把这 12 个有序文件再合并成一个有序的大文件。**

如果 sort_buffer_size 超过了需要排序的数据量的大小,number_of_tmp_files 就是 0,表示排序可以直接在内存中完成。

否则就需要放在临时文件中排序。sort_buffer_size 越小,需要分成的份数越多,number_of_tmp_files 的值就越大。

我们的示例表中有 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 认为排序的单行长度太大会怎么做呢?

SET max_length_for_sort_data = 16;

新的算法放入 sort_buffer 的字段,只有要排序的列(即 name 字段)和主键 id。

但这时,排序的结果就因为少了 city 和 age 字段的值,不能直接返回了,整个执行流程就变成如下所示的样子:

  1. 初始化 sort_buffer,确定放入两个字段,即 name 和 id;
  2. 从索引 city 找到第一个满足 city='杭州’条件的主键 id,也就是图中的 ID_X;
  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 排序。
在这里插入图片描述
对比全字段排序流程图你会发现,rowid 排序多访问了一次表 t 的主键索引,就是步骤 7。
在这里插入图片描述
首先,图中的 examined_rows 的值还是 4000,表示用于排序的数据是 4000 行。但是 select @b-@a 这个语句的值变成 5000 了。

因为这时候除了排序过程外,在排序完成后,还要根据 id 去原表取值。由于语句是 limit 1000,因此会多读 1000 行。

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

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

全字段排序 VS rowid 排序

如果 MySQL 实在是担心排序内存太小,会影响排序效率,才会采用 rowid 排序算法,这样排序过程中一次可以排序更多行,但是需要再回到原表去取数据。

如果 MySQL 认为内存足够大,会优先选择全字段排序,把需要的字段都放到 sort_buffer 中,这样排序后就会直接从内存里面返回查询结果了,不用再回到原表去取数据。

这也就体现了 MySQL 的一个设计思想:如果内存够,就要多利用内存,尽量减少磁盘访问。

对于 InnoDB 表来说,rowid 排序会要求回表多造成磁盘读,因此不会被优先选择。

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

如果是有序的:
这样整个查询过程的流程就变成了:

  1. 从索引 (city,name) 找到第一个满足 city='杭州’条件的主键 id;
  2. 到主键 id 索引取出整行,取 name、city、age 三个字段的值,作为结果集的一部分直接返回;
  3. 从索引 (city,name) 取下一个记录主键 id;
  4. 重复步骤 2、3,直到查到第 1000 条记录,或者是不满足 city='杭州’条件时循环结束。
    在这里插入图片描述
    可以看到,这个查询过程不需要临时表,也不需要排序。接下来,我们用 explain 的结果来印证一下。
    在这里插入图片描述
    从图中可以看到,Extra 字段中没有 Using filesort 了,也就是不需要排序了。而且由于 (city,name) 这个联合索引本身有序,所以这个查询也不用把 4000 行全都读一遍,只要找到满足条件的前 1000 条记录就可以退出了。也就是说,在我们这个例子里,只需要扫描 1000 次。

进一步考虑的话:如果能够使用覆盖索引的话,就能减少回表操作,直接从索引上获取数据即可了:

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

在这里插入图片描述
在这里插入图片描述
当然,这是一个权衡的决定。

问题

假设你的表里面已经有了 city_name(city, name) 这个联合索引,然后你要查杭州和苏州两个城市中所有的市民的姓名,并且按名字排序,显示前 100 条记录。如果 SQL 查询语句是这么写的 :

select * from t where city in ('杭州',"苏州") order by name limit 100;
  1. 那么,这个语句执行的时候会有排序过程吗,为什么?
  2. 如果业务端代码由你来开发,需要实现一个在数据库端不需要排序的方案,你会怎么实现呢?
  3. 进一步地,如果有分页需求,要显示第 101 页,也就是说语句最后要改成 “limit 10000,100”, 你的实现方法又会是什么呢?

** 回答:**

  1. 会,虽然有 (city,name) 联合索引,对于单个 city 内部,name 是递增的。但是由于这条 SQL 语句不是要单独地查一个 city 的值,而是同时查了"杭州"和" 苏州 "两个城市,因此所有满足条件的 name 就不是递增的了。也就是说,这条 SQL 语句需要排序。
  2. 这里,我们要用到 (city,name) 联合索引的特性,把这一条语句拆成两条语句,再将其结果一起排序,得到 name 最小的前 100 值,就是我们需要的结果了。
  3. 一样可以拆成两条执行,然后将结果排序。也可以进一步简化,先只将 id,name 取出来,排序好之后,再用 id 取出所有记录
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Jiangw557

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值