17 - 如何正确显示随机消息

关键字

随机、排序机制

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 语句的执行流程如下:

  1. 创建一个临时表。这个临时表使用的是 memory 引擎,表里有两个字段,第一个字段是 double 类型,为了后面描述方便,记为字段 R,第二个字段是 varchar(64) 类型,记为字段 W。并且,这个表没有建索引。

  2. 从 words 表中,按主键顺序取出所有的 word 值。对于每一个 word 值,调用 rand() 函数生成一个大于 0 小于 1 的随机小数,并把这个随机小数和 word 分别存入临时表的 R 和 W 字段中,到此,扫描行数是 10000。

  3. 现在临时表有 10000 行数据了,接下来你要在这个没有索引的内存临时表上,按照字段 R 排序。

  4. 初始化 sort_buffer。sort_buffer 中有两个字段,一个是 double 类型,另一个是整型。

  5. 从内存临时表中一行一行地取出 R 值和位置信息(我后面会和你解释这里为什么是“位置信息”),分别存入 sort_buffer 中的两个字段里。这个过程要对内存临时表做全表扫描,此时扫描行数增加 10000,变成了 20000。

  6. 在 sort_buffer 中根据 R 的值进行排序。注意,这个过程没有涉及到表操作,所以不会增加扫描行数。

  7. 排序完成后,取出前三个结果的位置信息,依次到内存临时表中取出 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 个数据。(具体行为可以参看堆的应用)

其具体执行流程如下:

  1. 对于这 10000 个准备排序的 (R,rowid),先取前三行,构造成一个堆;(对数据结构印象模糊的同学,可以先设想成这是一个由三个元素组成的数组)

  2. 取下一个行 (R’,rowid’),跟当前堆里面最大的 R 比较,如果 R’小于 R,把这个 (R,rowid) 从堆中去掉,换成 (R’,rowid’);

  3. 重复第 2 步,直到第 10000 个 (R’,rowid’) 完成比较。

流程图如下:
17-优先队列排序算法示例.png

这样,我们只需要一小部分内存就可以完成这个语句了。

你可能会问,为什么上一节的的排序不使用优先队列排序呢?

  • 一方面,上一节是对所有数据排序,此时堆排序的效率是不如归并(或快排)的。
  • 另一面,即使你用堆排序,要维护一个全部数据的堆,内存并没有节省多少。如果超过 sort_buffer_size ,依然需要使用外部排序。

在这里,我们不妨小结一下:

  • 如果 sort_buffer 足够,MySQL 会在内存中使用快排进行排序。
  • 如果不够,会使用外部排序,排序算法为归并排序。
  • 如果我们要求 top k 这种问题,MySQL 会选择维护优先队列。

4.随机获取

不知道你有没有这样觉得,上面的方法对于随机获取一个值来说是非常繁琐的,你可以用下面的几种方法,它会更加快速:

方法一

  1. 取得这个表的主键 id 的最大值 M 和最小值 N;
  2. 用随机函数生成一个最大值到最小值之间的数 X = (M-N)*rand() + N;
  3. 取不小于 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 空洞,选取的概率会很不平衡

方法二

  1. 取得整个表的行数,并记为 C。
  2. 取得 Y = floor(C * rand())。 floor 函数在这里的作用,就是取整数部分。
  3. 再用 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) 联合索引的特性,把这一条语句拆成两条语句,执行流程如下:

  1. 执行 select * from t where city=“杭州” order by name limit 100; 这个语句是不需要排序的,客户端用一个长度为 100 的内存数组 A 保存结果。
  2. 执行 select * from t where city=“苏州” order by name limit 100; 用相同的方法,假设结果被存进了内存数组 B。
  3. 现在 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讲》专栏的总结,我使用了大量的原文、代码和截图,如果想要了解具体内容,可以前往极客时间

你可能感兴趣的:(17 - 如何正确显示随机消息)