关键字
随机、排序机制
0.引
在上一章节,我们了解了 order by 的排序原理,也知道了优化排序的方法。今天这一节,我们通过一个随机排序的例子,更加深入地了解 MySQL 内部的排序机制。
假设有这么一个需求:你需要在有很多数据的英文单词表中随机选择三个单词,你会怎么写呢?为了方便理解,这个表的建表语句和初始数据放在下面:
mysql> CREATE TABLE `words` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`word` varchar(64) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=0;
while i<10000 do
insert into words(word) values(concat(char(97+(i div 1000)), char(97+(i % 1000 div 100)), char(97+(i % 100 div 10)), char(97+(i % 10))));
set i=i+1;
end while;
end;;
delimiter ;
call idata();
在这个表中,有 10000 行记录。接下来,我们开始探究它的执行过程。
1.内存临时表
首先,你很自然地想到用下面的语句实现这个逻辑:
mysql> select word from words order by rand() limit 3;
我们用 explain 看一下这个语句的执行情况:
17-使用 explain 命令查看语句的执行情况.png
Extra 字段有两个内容:
- Using temporary:代表这个执行过程需要使用内存临时表。
- Using filesort:代表这个语句需要执行排序操作
你可能会意识到,这个语句虽然简单,但是内部的执行流程可能会非常复杂。内存临时表是我们在之前没有提到的。
在讲内存临时表之前,我们回忆一下上一节讲到的两种排序方式:
17-全字段排序.jpg
17-rowid 排序.jpg
上一节我们讲到了 InnoDB 表会默认执行全字段排序(减少对磁盘的访问),而对于内存表来说,并不会访问磁盘,所以会默认使用 rowid 的排序方法。
所以,上面那条 SQL 语句的执行流程如下:
-
创建一个临时表。这个临时表使用的是 memory 引擎,表里有两个字段,第一个字段是 double 类型,为了后面描述方便,记为字段 R,第二个字段是 varchar(64) 类型,记为字段 W。并且,这个表没有建索引。
-
从 words 表中,按主键顺序取出所有的 word 值。对于每一个 word 值,调用 rand() 函数生成一个大于 0 小于 1 的随机小数,并把这个随机小数和 word 分别存入临时表的 R 和 W 字段中,到此,扫描行数是 10000。
-
现在临时表有 10000 行数据了,接下来你要在这个没有索引的内存临时表上,按照字段 R 排序。
-
初始化 sort_buffer。sort_buffer 中有两个字段,一个是 double 类型,另一个是整型。
-
从内存临时表中一行一行地取出 R 值和位置信息(我后面会和你解释这里为什么是“位置信息”),分别存入 sort_buffer 中的两个字段里。这个过程要对内存临时表做全表扫描,此时扫描行数增加 10000,变成了 20000。
-
在 sort_buffer 中根据 R 的值进行排序。注意,这个过程没有涉及到表操作,所以不会增加扫描行数。
-
排序完成后,取出前三个结果的位置信息,依次到内存临时表中取出 word 值,返回给客户端。这个过程中,访问了表的三行数据,总扫描行数变成了 20003。
你可以通过慢查询日志验证一下:
# Query_time: 0.900376 Lock_time: 0.000347 Rows_sent: 3 Rows_examined: 20003
SET timestamp=1541402277;
select word from words order by rand() limit 3;
其中,Rows_examined:20003 代表扫描了 20003 行,说明我们的结论是正确的。
流程图如下:
17-随机排序完整流程图 1.png
注意 sort_buffer 中的 pos,它实际上起到的主键的作用,对此,你要知道下面的概念:
- 如果一个表没有主键,那么 InnoDB 会自动生成一个长度为 6 字节的 rowid 作为主键。
- 在 rowid 排序模式里,rowid 的名字也是这个意思。它指的是:用 rowid 标识数据行。
- 如果 InnoDB 表包含主键,rowid 就是主键 ID;如果不包含,则 rowid 为自动生成。
- MEMORY 引擎不是索引组织表。在这个例子里面,你可以认为它就是一个数组。因此,这个 rowid 其实就是数组的下标。
总之,你需要记住:order by rand() 使用了内存临时表,内存临时表排序的时候使用了 rowid 排序方法。
2.磁盘临时表
- 内存临时表默认为 16M,你可以通过设置 tmp_table_size 设置它的大小。
- 如果临时表大于设置上限,就会使用磁盘临时表,这个表默认引擎为 InnoDB,你可以通过 internal_tmp_disk_storage_engine 控制。
所以,如果临时表超出上限,它的过程就是 InnoDB 的排序过程了。其中这个表是没有显示索引的
为了复现这个过程,把 tmp_table_size 设置成 1024,把 sort_buffer_size 设置成 32768, 把 max_length_for_sort_data 设置成 16。
set tmp_table_size=1024;
set sort_buffer_size=32768;
set max_length_for_sort_data=16;
/* 打开 optimizer_trace,只对本线程有效 */
SET optimizer_trace='enabled=on';
/* 执行语句 */
select word from words order by rand() limit 3;
/* 查看 OPTIMIZER_TRACE 输出 */
SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`\G
17-OPTIMIZER_TRACE 部分结果.png
我们分析一下这个结果:
- 因为将 max_length_for_sort_data 设置成 16,小于 word 字段的长度定义,所以我们看到 sort_mode 里面显示的是 rowid 排序,这没有问题。
- 你可能会计算一下,一行有 14 个字节,一共有 10000 行,大小为 140000 字节,这大于 sort_buffer_size,但是,number_of_tmp_files 为 0,也就是说并没有使用临时文件。这是怎么回事?
3.优先队列(堆)排序
实际上,上面的执行过程没有用到临时文件,这是因为在较新版本(5.6+)的 MySQL 中,引入了优先队列排序算法。
为什么会选择优先队列排序呢?很明显,如果使用优先队列排序,我们只需要维护一个拥有 3 个元素的大顶堆,在遍历完成数据之后,堆中的数据就是最小的 3 个数据。(具体行为可以参看堆的应用)
其具体执行流程如下:
-
对于这 10000 个准备排序的 (R,rowid),先取前三行,构造成一个堆;(对数据结构印象模糊的同学,可以先设想成这是一个由三个元素组成的数组)
-
取下一个行 (R’,rowid’),跟当前堆里面最大的 R 比较,如果 R’小于 R,把这个 (R,rowid) 从堆中去掉,换成 (R’,rowid’);
-
重复第 2 步,直到第 10000 个 (R’,rowid’) 完成比较。
这样,我们只需要一小部分内存就可以完成这个语句了。
你可能会问,为什么上一节的的排序不使用优先队列排序呢?
- 一方面,上一节是对所有数据排序,此时堆排序的效率是不如归并(或快排)的。
- 另一面,即使你用堆排序,要维护一个全部数据的堆,内存并没有节省多少。如果超过 sort_buffer_size ,依然需要使用外部排序。
在这里,我们不妨小结一下:
- 如果 sort_buffer 足够,MySQL 会在内存中使用快排进行排序。
- 如果不够,会使用外部排序,排序算法为归并排序。
- 如果我们要求 top k 这种问题,MySQL 会选择维护优先队列。
4.随机获取
不知道你有没有这样觉得,上面的方法对于随机获取一个值来说是非常繁琐的,你可以用下面的几种方法,它会更加快速:
方法一
- 取得这个表的主键 id 的最大值 M 和最小值 N;
- 用随机函数生成一个最大值到最小值之间的数 X = (M-N)*rand() + N;
- 取不小于 X 的第一个 ID 的行。
代码如下:
mysql> select max(id),min(id) into @M,@N from t ;
set @X= floor((@M-@N+1)*rand() + @N);
select * from t where id >= @X limit 1;
- 优点:效率高
- 缺点:如果出现 ID 空洞,选取的概率会很不平衡
方法二
- 取得整个表的行数,并记为 C。
- 取得 Y = floor(C * rand())。 floor 函数在这里的作用,就是取整数部分。
- 再用 limit Y,1 取得一行。
代码如下:
mysql> select count(*) into @C from t;
set @Y = floor(@C * rand());
set @sql = concat("select * from t limit ", @Y, ",1");
prepare stmt from @sql;
execute stmt;
DEALLOCATE prepare stmt;
- 优点:严格随机
- 缺点:扫描 C 行,limit 会前进到 Y+1 行,所以会扫描 C+Y+1 行,不如第一种性能好。但是比 order by rand( ) 好很多。
按照方法二的思路,我们可以实现开篇的需求:
mysql> select count(*) into @C from t;
set @Y1 = floor(@C * rand());
set @Y2 = floor(@C * rand());
set @Y3 = floor(@C * rand());
select * from t limit @Y1,1; //在应用代码里面取Y1、Y2、Y3值,拼出SQL后执行
select * from t limit @Y2,1;
select * from t limit @Y3,1;
小结
- 如果使用 order by rand( ),会涉及 Using temporary 和 Using filesort,代价比较大。
今天的例子里面,我们不是仅仅在数据库内部解决问题,还会让应用代码配合拼接 SQL 语句。在实际应用的过程中,比较规范的用法就是:尽量将业务逻辑写在业务代码中,让数据库只做“读写数据”的事情。因此,这类方法的应用还是比较广泛的。
上期问题
上一篇文章最后留给你的问题是,select * from t where city in (“杭州”," 苏州 ") order by name limit 100; 这个 SQL 语句是否需要排序?有什么方案可以避免排序?
解答:
虽然有 (city,name) 联合索引,对于单个 city 内部,name 是递增的。但是由于这条 SQL 语句不是要单独地查一个 city 的值,而是同时查了"杭州"和" 苏州 "两个城市,因此所有满足条件的 name 就不是递增的了。也就是说,这条 SQL 语句需要排序。
那怎么避免排序呢?
这里,我们要用到 (city,name) 联合索引的特性,把这一条语句拆成两条语句,执行流程如下:
- 执行 select * from t where city=“杭州” order by name limit 100; 这个语句是不需要排序的,客户端用一个长度为 100 的内存数组 A 保存结果。
- 执行 select * from t where city=“苏州” order by name limit 100; 用相同的方法,假设结果被存进了内存数组 B。
- 现在 A 和 B 是两个有序数组,然后你可以用归并排序的思想,得到 name 最小的前 100 值,就是我们需要的结果了。
如果把这条 SQL 语句里“limit 100”改成“limit 10000,100”的话,处理方式其实也差不多,即:要把上面的两条语句改成写:
select * from t where city="杭州" order by name limit 10100;
和
select * from t where city="苏州" order by name limit 10100;
这时候数据量较大,可以同时起两个连接一行行读结果,用归并排序算法拿到这两个结果集里,按顺序取第 10001~10100 的 name 值,就是需要的结果了。
当然这个方案有一个明显的损失,就是从数据库返回给客户端的数据量变大了。
所以,如果数据的单行比较大的话,可以考虑把这两条 SQL 语句改成下面这种写法:
select id,name from t where city="杭州" order by name limit 10100;
和
select id,name from t where city="苏州" order by name limit 10100。
然后,再用归并排序的方法取得按 name 顺序第 10001~10100 的 name、id 的值,然后拿着这 100 个 id 到数据库中去查出所有记录。
上面这些方法,需要你根据性能需求和开发的复杂度做出权衡。
本期思考
上面的随机算法 3 的总扫描行数是 C+(Y1+1)+(Y2+1)+(Y3+1),实际上它还是可以继续优化,来进一步减少扫描行数的。
我的问题是,如果你是这个需求的开发人员,你会怎么做,来减少扫描行数呢?说说你的方案,并说明你的方案需要的扫描行数。
以上就是本节的全部内容,希望对你有所帮助。
注:本文章的主要内容来自我对极客时间app的《MySQL实战45讲》专栏的总结,我使用了大量的原文、代码和截图,如果想要了解具体内容,可以前往极客时间
网友评论