在(7)使用索引排序过程,我介绍了几种排序的机制,以及使用排序的部分SQL语句,其中包括group by和order by rand()。
下面,我们可以看一下如何优化这两种SQL语句的效率
(7)使用索引排序过程中介绍了order by rand()语句,在第一个步骤中使用了内存临时表, (因为是memory引擎优先使用row id 排序)使用了 row id 排序方法,把内存临时表的数据放入sort_buffer进行排序
若一开始建立内存临时表时总数据量过大,则会转成磁盘临时表
tmp_table_size 这个配置限制了内存临时表的大小,默认值是 16M。如果临时表大小超过了 tmp_table_size ,那么内存临时表就会转成磁盘临时表。
磁盘临时表使用的引擎默认是 InnoDB,当使用磁盘临时表的时候,对应的就是一个没有显式索引的 InnoDB 表的排序过程(回顾orderby的排序机制)
我把 tmp_table_size 设置成 1024 ,把 sort_buffer_size 设置成 32768, 把max_length_for_sort_data 设置成 16 ,复现以下这个过程,还是执行这个语句
select word from words order by rand() limit 3;
查看 OPTIMIZER_TRACE 输出:
从这个结果可以看出:
这个 SQL 语句的排序确实没有用到临时文件,即没有用到归并排序算法,采用是 MySQL 5.6 版本引入的一个新的排序算法,即:优先队列排序算法。
如果使用归并排序算法的话,只需要取 R 值最小的 3 个 rowid,虽然最终也能得到前 3 个值,但是这个算法结束后,已经将 10000 行数据都排好序了,后面9997排序浪费了
而优先队列算法,就可以精确地只得到三个最小值
执行流程如下:
1. 对于这 10000 个准备排序的 (R,rowid) ,先取前三行,构造成一个堆;
2. 取下一个行 (R’,rowid’) ,跟当前堆里面最大的 R 比较,如果 R’ 小于 R ,把这个 (R,rowid) 从堆中去掉,换成 (R’,rowid’) ;
3. 重复第 2 步,直到第 10000 个 (R’,rowid’) 完成比较。
整个排序过程中,为了最快地拿到当前堆的最大值,总是保持最大值在堆顶,因此这是一个最大堆。
图中的 OPTIMIZER_TRACE 结果中, filesort_priority_queue_optimization 这个部分的chosen=true ,就表示使用了优先队列排序算法,这个过程不需要临时文件,因此对应的number_of_tmp_files 是 0 。
这个流程结束后,我们构造的堆里面,就是这个 10000 行里面 R 值最小的三行(rand函数生成的随机小数的最小的三行)。然后,依次把它们的 rowid 取出来,去内存(或磁盘)临时表里面拿到 word 字段,这个过程就跟上一篇文章的 rowid 排序的过程一样了。
如果我们的SQL语句limit 1000 ,如果使用优先队列算法的话,需要维护的堆的大小就是 1000 行的 (name,rowid) ,超
过了我设置的 sort_buffer_size 大小,所以只能使用归并排序算法。
小结:
从(7)order by与随机排序和上面的内容来看。不论是使用哪种类型的临时表, order by rand() 这种写法都会让计算过程非常复杂
我们提出了一种优化的方法,如果只随机选择一个word值,可以由如下的流程:
1. 取得整个表的行数,并记为 C 。
2. 取得 Y = floor(C * rand()) 。 floor 函数在这里的作用,就是取整数部分。
3. 再用 limit Y,1 取得一行。 其中Y作为步长,1作为返回记录行的数目
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;
由于 limit 后面的参数不能直接跟变量,所以我在上面的代码中使用了 prepare+execute 的方法
MySQL 处理 limit Y,1 的做法就是按顺序一个一个地读出来,丢掉前 Y 个,然后把下一个记录作为返回结果,因此这一步需要扫描 Y+1 行,再加上,第一步扫描的 C 行,总共需要扫描 C+Y+1 行。
代价比order by rand代价小很多:进行 limit 获取数据的时候是根据主键排序获取的,主键天然索引排序。获取到第9999 条的数据也远比 order by rand() 方法的组成临时表 R 字段排序再获取 rowid 代价小的多
模仿上面算法的思路,如果要随机取得三个值呢?
1. 取得整个表的行数,记为 C ;
2. 根据相同的随机方法得到 Y1 、 Y2 、 Y3 ; (Y = floor(C * rand()))
3. 再执行三个 limit Y, 1 语句得到三行数据。
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 ;
执行下面的语句
select id%10 as m, count(*) as c from t1 group by m;
这个语句的逻辑是把表 t1 里的数据,按照 id%10 进行分组统计,并按照 m 的结果排序后输出。它的 explain 结果如下
在 Extra 字段里面,我们可以看到三个信息:
这个语句的执行流程是这样的:
1. 创建内存临时表,表里有两个字段 m 和 c ,主键是 m ;
2. 扫描表 t1 的索引 a ,依次取出叶子节点上的 id 值,计算 id%10 的结果,记为 x ;
3. 遍历完成后,再根据字段 m 做排序(group by默认会执行排序),得到结果集返回给客户端。
图中最后一步,对内存临时表的排序,我们之前介绍过:order by rand() 使用了内存临时表,内存临时表排序的时候使用了 rowid 排序方法(图中内存临时表中的R部分,对于有主键的 InnoDB 表来说,这个 rowid 就是主键 ID,对于没有主键的 InnoDB 表来说,这个 rowid 就是由系统生成的)。
其中,临时表的排序过程就是虚线框中的过程。
如果你的需求并不需要对结果进行排序,那你可以在 SQL 语句末尾增加 order by null(抑制groupby排序) ,也就是改成:
select id%10 as m, count(*) as c from t1 group by m order by null;
这样就跳过了最后排序的阶段,直接从临时表中取数据返回。
这个例子里由于临时表只有 10 行,内存可以放得下,因此全程只使用了内存临时表。但是,内存临时表的大小是有限制的,超过则会把把内存临时表转成磁盘临时表,磁盘临时表默认使用的引擎是 InnoDB
不论是使用内存临时表还是磁盘临时表, group by 逻辑都需要构造一个带唯一索引的表,执行代价都是比较高的
执行 group by 语句为什么需要临时表?group by 的语义逻辑,是统计不同的值出现的个数。但是,由于每一行的 id%100 的结果是无序
的,所以我们就需要有一个临时表,来记录并统计结果。
那么,如果扫描过程中可以保证出现的数据是有序的,输入的数据是有序的:
计算 group by 的时候,就只需要从左到右,顺序扫描,依次累加。也就是下面这个过程:
按照这个逻辑执行的话,扫描到整个输入的数据结束,就可以拿到 group by 的结果,不需要临时表,也不需要再额外排序。 InnoDB 的索引,就可以满足这个输入有序的条件
在 MySQL 5.7 版本支持了 generated column 机制,用来实现列数据的关联更新。你可以用下面的方法创建一个列 z ,然后在 z 列上创建一个索引(如果是 MySQL 5.6 及之前的版本,你也可以创建普通列和索引,来解决这个问题)。
alter table t1 add column z int generated always as(id % 100), add index(z);
索引 z 上的数据就是类似 有序的了。上面的 group by 语句就可以改成:这样就不再需要临时表,也不需要排序了
select z, count(*) as c from t1 group by z;
如果碰上不适合创建索引的场景,我们还是要老老实实做排序的:一个 group by 语句中需要放到临时表上的数据量特别大,却还是要按照 “ 先放到内存临时表,插入一部分数据后,发现内存临时表不够用了再转成磁盘临时表 ” ,
可以由方法让我们直接走磁盘临时表的方法
在 group by 语句中加入 SQL_BIG_RESULT 这个提示( hint ),就可以告诉优化器:这个语句涉及的数据量很大,请直接用磁盘临时表。磁盘临时表是 B+ 树存储,存储效率不如数组来得高,那从磁盘空间考虑,还是直接用数组来存吧
下面这个语句的执行流程是这样的:
select SQL_BIG_RESULT id%100 as m, count(*) as c from t1 group by m;
1. 初始化 sort_buffer ,确定放入一个整型字段,记为 m ;
2. 扫描表 t1 的索引 a ,依次取出里面的 id 值 , 将 id%100 的值存入 sort_buffer 中;
3. 扫描完成后,对 sort_buffer 的字段 m 做排序(如果 sort_buffer 内存不够用,就会利用磁盘临时文件辅助排序);
4. 排序完成后,就得到了一个有序数组。根据有序数组,得到数组里面的不同值,以及每个值的出现次数
从 Extra 字段可以看到,这个语句的执行没有再使用临时表,而是直接用了排序算法
MySQL 什么时候会使用内部临时表?
1. 如果语句执行过程可以一边读数据,一边直接得到结果,是不需要额外内存的,否则就需要额外的内存,来保存中间结果;
2. join_buffer 是无序数组, sort_buffer 是有序数组,临时表是二维表结构;
3. 如果执行逻辑需要用到二维表特性,就会优先考虑使用临时表。比如我们的例子中, union需要用到唯一索引约束, group by 还需要用到另外一个字段来存累积计数。
1. 如果对 group by 语句的结果没有排序要求,要在语句后面加 order by null ;
2. 尽量让 group by 过程用上表的索引,确认方法是 explain 结果里没有 Using temporary 和 Usingfilesort ;即可以通过索引来保证有序
3. 如果 group by 需要统计的数据量不大,尽量只使用内存临时表;也可以通过适当调大tmp_table_size 参数,来避免用到磁盘临时表;
4. 如果数据量实在太大,使用 SQL_BIG_RESULT 这个提示,来告诉优化器直接使用排序算法得到 group by 的结果。