MySQL 16“order by”是怎么工作的?


假设要查询城市是“杭州”的所有人名字,并且按照姓名排序返回前1000个人的姓名与年龄。那么SQL语句可以写为:

select city,name,age from t where city='杭州' order by name limit 1000;

本文主要想讨论这个语句是如何执行的,以及有什么参数会影响执行的行为。

全字段排序

在上面的查询语句中,为了避免全表扫描,需要在city字段加上索引。用explain命令检查语句的执行情况:

其中,Extra字段的Using filesort表示需要排序,MySQL会给每个线程分配一块内存用于排序,称为sort_buffer。

为了说明该语句的执行过程,先看一下city索引的示意图:

从图中看到,满足city='杭州'条件的行的id是从X到X+N。

通常情况下,这个语句的执行流程为:

  • 初始化sort_buffer,确定放入name、city、age三个字段;

  • 从city索引中找到第一个满足city='杭州'条件的主键id为ID_X;

  • 到主键id的索引中取出整行,取name、city、age三个字段的值,存入sort_buffer;

  • 从city索引取下一个记录的主键id;

  • 重复上面两个步骤直到city值不满足查询条件,即找到了图中的ID_Y;

  • 对sort_buffer中的数据按照字段name做快速排序;

  • 按照排序结果取前1000行返回给客户端。

我们把这个排序过程称为全字段排序,执行流程示意图如下:

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

对于排序语句是否使用了临时文件,可以通过下面的方法确认:

/* 打开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;

该方法通过查看information_schema数据库下的OPTIMIZER_TRACE表查看,用number_of_tmp_files字段查看:

图中结果表示的就是使用了12个临时文件。外部排序一般使用归并排序算法,12个临时文件可以理解为:MySQL将需要排序的数据分成12份,每一份单独排序后放在这些临时文件中,然后把这12个有序文件再合并成一个有序的大文件。

而如果sort_buffer_size超过了需要排序的数据量的大小,number_of_tmp_files就会是0。sort_buffer_size越小,number_of_tmp_files的值会越大。

再解释下上面结果中的其他一些字段:

  • examined_rows=4000,表示参与排序的行数是4000行;

  • sort_mode里的packed_additional_fields意思是排序过程对字符串做了“紧凑”处理,即使name字段定义为varchar(16),在实际排序过程中是按照实际长度来分配空间。

同时,查询语句select @b-@a的返回结果是4000,表示整个执行过程只扫描了4000 行。

rowid排序

在全字段排序过程中,只对原表的数据读了一遍,剩下的操作都是在sort_buffer和临时文件中执行的。如果查询要返回的字段很多,那么sort_buffer里能存的行数会变得很少,可能会需要很多临时文件,排序的性能变得很差。

这里介绍一个参数:

set max_length_for_sort_data = 16;

这是MySQL中专门控制用于排序的行数据的长度的一个参数。当单行的长度超过这个值,MySQL会认为排序的单行长度太大,需要换一个算法。

假设在t表中,city字段和name为varchar(16),主键id和age字段为int(11)。那么city、name、age三个字段的定义总长度为36,大于了设置的参数值16,此时计算过程会发生改变:

新算法放入sort_buffer的字段只有要排序的列name和主键id,由于排序结果缺少部分字段,不能直接返回,整个执行流程变为:

  • 初始化sort_buffer,确定放入两个字段name和id;

  • 从city索引找到第一个满足city='杭州'条件的主键id为ID_X;

  • 到主键id的索引中取出整行,取name、city、age三个字段的值,存入sort_buffer;

  • 从city索引取下一个记录的主键id;

  • 重复上面两个步骤直到city值不满足查询条件,即找到了图中的ID_Y;

  • 对sort_buffer中的数据按照字段name进行排序;

  • 遍历排序结果,取前1000行并按照id值回到原表取city、name和age三个字段返回给客户端。

我们把这个排序过程称为rowid排序,执行流程示意图如下:

可以发现,rowid排序多访问了一次表的主键索引。

另外,图里的“结果集”是一个逻辑概念,实际上MySQL服务端获得结果后是直接返回给客户端的,而不是还在服务端耗费内存存储结果。

如果对上述过程查看OPTIMIZER_TRACE表,得到的结果如下:

其中:

  • examined_rows=4000,表示用于排序的数据是4000行;

  • number_of_tmp_files=10,是因为每一行都变小了,需要排序的总数据量就变小,需要的临时文件也减少了;

  • sort_mode里变为rowid,表示参与排序的只有name和id两个字段。

此时,查询语句select @b-@a的返回结果是5000,因为在根据id去原表取值的过程需要多扫描1000行。

全字段排序 VS rowid排序

从上面两种排序方法,可以看出,如果MySQL认为内存足够大,会优先选择全字段排序;如果MySQL认为内存太小,会采用rowid排序。这体现了MySQL的一个设计思想:如果内存够,就多利用内存,尽量减少磁盘访问

对于InnoDB表,rowid排序回表会增加磁盘读,因此不会被优先选择

那么是不是所有的order by都需要排序操作呢?不是的,就像在本文的例子中,如果从city索引上取出的行天热按照name递增排序,就可以不用再排序。所以可以对city和name创建联合索引,对应的示意图为:

整个查询流程变为:

  • 从索引(city,name)找到第一个满足city='杭州'的主键id;

  • 到主键id索引取出整行,取name、city、age字段的值作为结果集的一部分直接返回;

  • 从索引(city,name)取下一个满足条件的主键id;

  • 重复以上两步,直到查到第1000条记录或不满足条件city='杭州'

该过程不需要临时表,也不需要排序,用explain进行验证:

可以看到,Extra里没有Using filesort了。

那么这个语句能否进一步简化呢?是能的,由于最后要返回三个字段,可以考虑覆盖索引,对三个字段建立联合索引。此时整个查询流程变为:

  • 从索引(city,name,age)找到第一个满足city='杭州'的记录,取name、city、age字段的值作为结果集的一部分直接返回;

  • 从索引(city,name,age)取下一个记录,同样取出三个字段并返回;

  • 重复上面一步,直到查到第1000条记录或不满足条件city='杭州'

其流程和验证如下:


不过并不是说为了查询索引能用上覆盖索引就需要把涉及的字段都建立联合索引,索引有一定代价,这需要权衡。