面试官:order by 怎么优化?
01 前言
刚换了新工作,用了两周时间准备,在 3 天之内拿了 5 个 offer,最后选择了广州某互联网行业独角兽 offer,昨天刚入职。这几天刚好整理下在面试中被问到有意思的问题,也借此机会跟大家分享下。
这家企业的面试官有点意思,一面是个同龄小哥,一起聊了两个小时(聊到我嘴都干了)。二面是个从阿里出来的架构师,视频面试,我做完自我介绍之后,他一开场就问我:
对 MySQL 熟悉吗?
我一愣,随之意识到这是个坑。他肯定想问我某方面的原理了,恰好我研究过索引。就回答:
对索引比较熟悉。
他:
order by 是怎么实现排序的?
还好我又复习,基本上排序缓冲区、怎么优化之类的都答到点子上。今天也跟大家盘一盘 order by,我将从原理讲到最终优化,给大家聊聊 order by,希望对你有所帮助。
国际惯例,先上思维导图。 PS:文末有福利
1.1 往期精彩
1.2 先举个栗子
现在有一张订单表,结构是这样的:
CREATE TABLE `order` (
id INT ( 11 ) NOT NULL AUTO_INCREMENT COMMENT '主键',
user_code VARCHAR ( 16 ) NOT NULL COMMENT '用户编号',
goods_name VARCHAR ( 64 ) NOT NULL COMMENT '商品名称',
order_date TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',
city VARCHAR ( 16 ) DEFAULT NULL COMMENT '下单城市',
order_num INT ( 10 ) NOT NULL COMMENT '订单号数量',
PRIMARY KEY ( `id` )
) ENGINE = INNODB AUTO_INCREMENT = 100 DEFAULT CHARSET = utf8 COMMENT = '商品订单表';
造点数据:
// 第一步:创建函数
delimiter //
DROP PROCEDURE
EXISTS proc_buildata;
CREATE PROCEDURE proc_buildata ( IN loop_times INT ) BEGIN
DECLARE var INT DEFAULT 0;
WHILE
var < loop_times DO
SET var = var + 1;
INSERT INTO `order` ( `id`, `user_code`, `goods_name`, `order_date`, `city` , `order_num`)
VALUES
( var, var + 1, '有线耳机', '2021-06-20 16:46:00', '杭州', 1 );
END WHILE;
END // delimiter;
// 第二步:调用上面生成的函数,即可插入数据,建议大家造点随机的数据。比如改改城市和订单数量
CALL proc_buildata(4000);
我生成的数据是这样的:
现有需求: 查出 618 期间,广州的小伙伴的订单数量和用户编号,并按照订单数量升序,只要 1000 条 。
根据需求可以得出以下 SQL,相信小伙伴都很熟悉了。
select city, order_num, user_code from `order` where city='广州' order by order_num limit 1000;
那这个语句是怎么执行的呢?有什么参数可以影响它的行为吗?
02 全字段排序
得到这个需求,我第一反应是先给 city 字段加上索引,避免全表扫描:
ALTER TABLE `order` ADD INDEX city_index ( `city` );
用 explain 看看执行情况
注意到最后一个 extra 字段的结果是: Using filesort ,表示需要排序。 其实 MySQL 会给每个线程分配一块内存用于排序,称为 sort_buffer 。
为了更直观了解排序的执行流程,我粗略画了个 city 索引的图示:
可见,现在满足 sql 条件的就是 ID-3 到 ID-X 这一段数据。sql 的整个流程是这样的:
- 1、初始化 sort_buffer,放入 city、order_num、user_code 这三个字段;
- 2、从索引 city 找到第一个满足 city=' 广州’条件的主键 id,也就是图中的 ID_3;
- 3、到主键 id 索引取出整行,取 city、order_num、user_code 三个字段的值,存入 sort_buffer 中;
- 4、从索引 city 取下一个记录的主键 id;
- 5、重复步骤 3、4 直到 city 的值不满足查询条件为止,对应的主键 id 也就是图中的 ID_X;
- 6、对 sort_buffer 中的数据按照字段 order_num 做快速排序;
- 7、按照排序结果取前 1000 行返回给客户端。
这个过程称之为全字段排序,画个图,长这样:
其中, 按 order_num 排序 这个步骤,可能在内存中完成,也可能需要使用外部排序,这取决于排序所需的内存和参数 sort_buffer_size 。
也就是 MySQL 为排序开辟的内存(sort_buffer)的大小。如果要排序的数据量小于 sort_buffer_size,排序就在内存中完成。但如果排序数据量太大,内存顶不住,就得磁盘临时文件辅助排序。
当然,在 MySQL5.7 以上版本可以用下面介绍的 检测方法(后面都有用到) ,来查看一个排序语句是否使用了临时文件。PS: 这里的语句直接复制到 navicat 执行即可,要一起执行(都复制进去,点下执行)
/* 打开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, order_num, user_code from `order` where city='广州' order by order_num limit 1000;
/* 查看 OPTIMIZER_TRACE 输出 */
SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`;
/* @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 表的 TRACE 字段得到以下结果:
其中 examined_rows 表示需要排序的行数 6883;sort_buffer_size 就是排序缓冲区的大小;sort_buffer_size 就是我 MySQL 的排序缓冲区大小 256 KB。
另外,sort_mode 的值是 packed_additional_fields,它表示排序过程对数据做了优化,也就是数据占用多少就算多少内存。举个栗子:不存在数据定义长度 16,就按这个长度算,如果数据只占 2,只会按长度 2 分配内存。
number_of_tmp_files 代表的是用了几个外部文件来辅助排序。我这里是用了两个,内存放不下时,就使用外部排序,外部排序一般使用 归并排序 算法。可以这么简单理解, MySQL 将需要排序的数据分成 2 份,每一份单独排序后存在这些临时文件中。然后把这 2 个有序文件再合并成一个有序的大文件 。
最后一个查询语句,select @b-@a 的值是 6884,表示整个过程只扫描了 6883 行,为啥显示 6884?
因为查询 OPTIMIZER_TRACE 表时,需用到临时表;而 InnDB 引擎把数据从临时表取出时,Inndb_rows_read 值会加 1。
所以,把 internal_tmp_disk_storage_engine 设置为 MyISAM 可解决此问题。
03 rowid 排序
上面的全字段排序其实会有很大的问题,你可能发现了。 我们需要查询的字段都要放到 sort_buffer 中,如果查询的字段多了起来,内存占用升高,就会很容易打满 sort_buffer 。
这时,就要用很多的临时文件辅助排序,导致性能降低。
那问题来了:
我们思考的方向应该是降低排序的单行长度,哪有没有方法能做到呢?
肯定是有的,MySQL 之所以走全字段排序是由 max_length_for_sort_data 控制的,它的 默认值是 1024。
show variables like 'max_length_for_sort_data';
因为本文示例中 city,order_num,user_code 长度 = 16+4+16 =36 < 1024, 所以走的是全字段排序。我们来改下这个参数,改小一点,
SET max_length_for_sort_data = 16;
当单行的长度超过这个值,MySQL 就认为单行太大,要换一个算法。原来 city、user_code、order_num 占用的长度是 36,显然放不下全部查询字段了。这时就要换算法: sort_buffer 只存 order_num 和 id 字段 。
这时的流程应该是这样的:
- 1、初始化 sort_buffer,确定放入两个字段,即 order_num 和 id;
- 2、从索引 city 找到第一个满足 city=' 广州’条件的主键 id,也就是图中的 ID_3;
- 3、回表,取 order_num、id 这两个字段,存入 sort_buffer 中;
- 4、从索引 city 取下一个记录的主键 id;
- 5、重复步骤 3、4 直到不满足 city=' 广州’条件为止,也就是图中的 ID_X;
- 6、对 sort_buffer 中的数据按照字段 order_num 进行排序;
- 7、遍历排序结果,取前 1000 行,再次回表取出 city、order_num 和 user_code 三个字段返回给客户端。
图示:由图可见,这种方式其实多了一次回表操作、但 sort_buffer_size 占用却变小了。
此时,执行上面的检测方法,可以发现 OPTIMIZER_TRACE 表中的信息变了。
- sort_mode 变成了 <sort_key, rowid>, 表示参与排序的只有 order_num 和 id 这两个字段 。
- number_of_tmp_files 变成 0 了,是因为这时参与排序的行数虽然仍然是 6883 行,但是每一行都变小了,因此需要排序的总数据量就变小了,sort_buffer_size 能满足排序用的内存,所以临时文件就不需要了。
examined_rows 的值还是 6883,表示用于排序的数据是 6883 行。但是 select @b-@a 这个语句的值变成 7884 了。因为这时候除了排序过程外,在排序完成后,还要回表一次。由于语句是 limit 1000,所以会多读 1000 行。
3.1 做个小结
rowid 排序中, 排序过程一次可以排序更多行,但是需要回表取数据 。
如果内存足够大,MySQL 会优先选择全字段排序,把需要的字段都放到 sort_buffer 中,这样排序后就会直接从内存返回查询结果了,不用回表。
这也就体现了 MySQL 的一个设计思想: 如果内存够,就要多利用内存,尽量减少磁盘访问 。
对于 InnoDB 表来说,rowid 排序会要求回表多造成磁盘读,因此不会被优先选择 。
这两种都是因为数据本身是无序的,才要放到 sort_buffer 并生成临时文件才能做排序。
哪有没有办法,让数据本身就有序呢?回想下,我们学过的索引就是有序的。
04 索引优化
这时,要是我把 city、order_num 建一个组合索引,得出的数据是不是就是天然有序的了?比如:
alter table `order` add index city_order_num_index(city, order_num);
此时,order 表的索引长这样:
文章开头的 sql 执行语句。执行流程长这样:
- 1、从索引 (city,order_num) 找到第一个满足 city=' 广州’条件的主键 id;
- 2、回表,取 city、order_num、user_code 三个字段的值,作为结果集的一部分直接返回;
- 3、从索引 (city,order_num) 取下一个记录主键 id;
- 4、重复步骤 2、3,直到查到第 1000 条记录,或者是不满足 city=' 广州’条件时循环结束。
用 explain 看下,这个过程不需要排序,更不需要临时表。 只需要一次回表 :
从图中可以看到, Extra 字段中没有 Using filesort 了,也就是不需要排序了 。而且由于 (city,order_num) 这个联合索引本身有序,只要找到满足条件的前 1000 条记录就可以退出了,再回表一次。也就是说,只需要扫描 2000 次。
问题来了,还有没有更优解呢?
05 终极优化
上面的方法,还是有一次回表,主要是因为索引中不包括 user_code。回顾下我们之前学过的 sql 优化,是怎么避免回表的?
查询字段,加到组合索引中呀 ,对应到这张表,就是把 user_code 也加到组合索引中:
alter table `order` add index city_order_num_user_code_index(city, order_num, user_code);
此时的流程长这样,直接取数据就完事了:
explain 看下执行情况:
从图中可知, Extra 字段中多了 Using index 了,也就是使用了索引覆盖 。连回表都不需要了,只需扫描 1000 次。
完美~
5.1 参数调优
除此以外,还可以通过调整参数优化 order by 的执行。 比如调整 sort_buffer_size 尽量大点 ,因为 sort_buffer 太小,排序数据量大的话,会借助磁盘临时文件排序。如果 MySQL 服务器配置高的话,可以稍微调大点。
再 比如把 max_length_for_sort_data 的值调大点 。如果该值过小,则会增加回表次数、降低查询性能。
06 order by 常见面试题
1、查询语句有 in 多个属性时,SQL 执行是否有排序过程?
假设现在有联合索引 (city,order_num,user_code),执行以下 SQL 语句:
select city, order_num, user_code from `order` where city in ('广州') order by order_num limit 1000
in 单个条件,毫无疑问是不需要排序的。explain 一下:
但是,in 多个条件时;就会有排序过程,比如执行以下语句
select city, order_num, user_code from `order` where city in ('广州','深圳') order by order_num limit 1000
explain 以下,看到最后有 Using filesort 就说明有排序过程。这是为啥呢?
因为 order_num 本来就是组合索引,满足 "city = 广州" 只有一个条件时,它是有序的。满足 "city = 深圳" 时,它也是有序的。但是两者加到一起就不能保证 order_num 还是有序的了。
2、分页 limit 过大,导致大量排序。咋办?
select * from `user` order by age limit 100000,10
- 可以记录上一页最后的 id,下一页查询时,查询条件带上 id,如:where id > 上一页最后 id limit 10。
- 也可以在业务允许的情况下,限制页数。
3、索引存储顺序与 order by 不一致,如何优化?
假设有联合索引 (age,name), 我们需求修改为这样: 查询前 10 个学生的姓名、年龄,并且按照年龄小到大排序,如果年龄相同,则按姓名降序排 。对应的 SQL 语句应该是:
select name, age from student order by age, name desc limit 10;
explain 一下, extra 的值是 Using filesort ,走了排序过程:
这是因为,(age,name) 索引树中,age 从小到大排序, 如果 age 相同,再按 name 从小到大排序 。而 order by 中,是按 age 从小到大排序,如果 age 相同,再按 name 从大到小排序。也就是说,索引存储顺序与 order by 不一致。
我们怎么优化呢?如果 MySQL 是 8.0 版本,支持 Descending Indexes ,可以这样修改索引:
CREATE TABLE `student` (
`id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
`student_id` varchar(20) NOT NULL COMMENT '学号',
`name` varchar(64) NOT NULL COMMENT '姓名',
`age` int(4) NOT NULL COMMENT '年龄',
`city` varchar(64) NOT NULL COMMENT '城市',