如何在MySQL中查询每一个分组的前几名【转】

问题

在工做中常会遇到将数据分组排序的问题,如在考试成绩中,找出每一个班级的前五名等。 在orcale等数据库中可使用partition语句来解决,但在mysql中就比较麻烦了。此次翻译的文章就是专门解决这个问题的html

原文地址: How to select the first/least/max row per group in SQLmysql

翻译

在使用SQL的过程当中,咱们常常遇到这样一类问题:如何找出每一个程序最近的日志条目?如何找出每一个用户的最高分?在每一个分类中最受欢迎的商品是什么?一般这类“找出每一个分组中最高分的条目”的问题可使用相同的技术来解决。在这篇文章里我将介绍如何解决这类问题,并且会介绍如何找出最高的前几名而不只仅是第一名。sql

这篇文章会用到行数(row number),我在原来的文章 MySQL-specificgeneric techniques 中已经提到过如何为每一个分组设置行数了。在这里我会使用与原来的文章中相同的表格,但会加入新的price 字段数据库

+--------+------------+-------+
| type   | variety    | price |
+--------+------------+-------+
| apple  | gala       |  2.79 | 
| apple  | fuji       |  0.24 | 
| apple  | limbertwig |  2.87 | 
| orange | valencia   |  3.59 | 
| orange | navel      |  9.36 | 
| pear   | bradford   |  6.05 | 
| pear   | bartlett   |  2.14 | 
| cherry | bing       |  2.55 | 
| cherry | chelan     |  6.33 | 
+--------+------------+-------+

选择每一个分组中的最高分

这里咱们要说的是如何找出每一个程序最新的日志记录或审核表中最近的更新或其余相似的排序问题。这类问题在IRC频道和邮件列表中出现的愈来愈频繁。我使用水果问题来做为示例,在示例中咱们要选出每类水果中最便宜的一个,咱们指望的结果以下ubuntu

+--------+----------+-------+
| type   | variety  | price |
+--------+----------+-------+
| apple  | fuji     |  0.24 | 
| orange | valencia |  3.59 | 
| pear   | bartlett |  2.14 | 
| cherry | bing     |  2.55 | 
+--------+----------+-------+

这个问题有几种解法,但基本上就是这两步:找出最低的价格,而后找出和这个价格同一行的其余数据服务器

其中一个经常使用的方法是使用自链接(self-join),第一步根据type(apple, cherry etc)进行分组,并找出每组中price的最小值mysql优化

select type, min(price) as minprice
from fruits
group by type;
+--------+----------+
| type   | minprice |
+--------+----------+
| apple  |     0.24 | 
| cherry |     2.55 | 
| orange |     3.59 | 
| pear   |     2.14 | 
+--------+----------+

第二步是将刚刚结果与原来的表进行链接。既然刚刚给结果已经被分组了,咱们将刚刚的查询语句做为子查询以便于链接没有被分组的原始表格。app

select f.type, f.variety, f.price
from (
   select type, min(price) as minprice
   from fruits group by type
) as x inner join fruits as f on f.type = x.type and f.price = x.minprice;

+--------+----------+-------+
| type   | variety  | price |
+--------+----------+-------+
| apple  | fuji     |  0.24 | 
| cherry | bing     |  2.55 | 
| orange | valencia |  3.59 | 
| pear   | bartlett |  2.14 | 
+--------+----------+-------+

还可使用相关子查询(correlated subquery)的方式来解决。这种方法在不一样的mysql优化系统下,可能性能会有一点点降低,但这种方法会更直观一些。函数

select type, variety, price
from fruits
where price = (select min(price) from fruits as f where f.type = fruits.type);
+--------+----------+-------+
| type   | variety  | price |
+--------+----------+-------+
| apple  | fuji     |  0.24 | 
| orange | valencia |  3.59 | 
| pear   | bartlett |  2.14 | 
| cherry | bing     |  2.55 | 
+--------+----------+-------+

这两种查询在逻辑上是同样的,他们性能也基本相同。性能

找出每组中前N个值

这个问题会稍微复杂一些。咱们可使用汇集函数(MIN(), MAX()等等)来找一行,可是找前几行不能直接使用这些函数,由于它们都只返回一个值。但这个问题仍是能够解决的。

此次咱们找出每一个类型(type)中最便宜的前两种水果,首先咱们尝试

select type, variety, price
from fruits
where price = (select min(price) from fruits as f where f.type = fruits.type)
   or price = (select min(price) from fruits as f where f.type = fruits.type
      and price > (select min(price) from fruits as f2 where f2.type = fruits.type));
+--------+----------+-------+
| type   | variety  | price |
+--------+----------+-------+
| apple  | gala     |  2.79 | 
| apple  | fuji     |  0.24 | 
| orange | valencia |  3.59 | 
| orange | navel    |  9.36 | 
| pear   | bradford |  6.05 | 
| pear   | bartlett |  2.14 | 
| cherry | bing     |  2.55 | 
| cherry | chelan   |  6.33 | 
+--------+----------+-------+

是的,咱们能够写成自链接(self-join)的形式,可是仍不够好(我将这个练习留给读者)。这种方式在N变大(前三名,前4名)的时候性能会愈来愈差。咱们可使用其余的表现形式编写这个查询,可是它们都不够好,它们都至关的笨重和效率低下。(译者注:这种方式获取的结果时,若是第N个排名是重复的时候最后选择的结果会超过N,好比上面例子还有一个apple价格也是0.24,那最后的结果就会有3个apple)

咱们有一种稍好的方式,在每一个种类中选择不超过该种类第二便宜的水果

select type, variety, price
from fruits
where (
   select count(*) from fruits as f
   where f.type = fruits.type and f.price <= fruits.price
) <= 2;

此次的代码要优雅不少,并且在N增长时不须要从新代码(很是棒!)。可是这个查询在功能上和原来的是同样。他们的时间复杂度均为分组中条目数的二次方。并且,不少优化器都不能优化这种查询,使得它的耗时最好为全表行数的二次方(尤为在没有设置正确的索引时),并且数据量大时,可能将服务器会中止响应。那么还有更好的方法吗?有没有办法能够仅仅扫描一次数据,而不是经过子查询进行屡次扫描。(译者注:这种方法有一个问题,就是若是排名并列第一的数字超过N后,这个分组会选不出数据,好比price为2.79的apple有3个,那么结果中就没有apple了)

使用 UNION

若是已经为type, price设置了索引,并且在每一个分组中去除的数据要多于包含的数据,一种很是高效的单次扫描的方法是将查询拆分红多个独立的查询(尤为对mysql,对其余的RDBMSs也有效),再使用UNION将结果拼到一块儿。mysql的写法以下:

(select * from fruits where type = 'apple' order by price limit 2)
union all
(select * from fruits where type = 'orange' order by price limit 2)
union all
(select * from fruits where type = 'pear' order by price limit 2)
union all
(select * from fruits where type = 'cherry' order by price limit 2)

Peter Zaistev写了相关的文章, 我在这里就不赘述了。若是这个方案知足你的要求,那它就是一个很是好的选择.

注意:这里要使用UNION ALL,而不是UNION。后者会在合并的时候会将重复的条目清除掉。在咱们的这个示例中没有去除重复的需求,因此咱们告诉服务器不要清除重复,清除重复在这个问题中是无用的,并且会形成性能的大幅降低。

使用用户自定义变量

但结果是数据表中很小一部分条目而且有索引用来排序的时候,使用UNION的方式是一个很好的选择。而当你要获取数据表中大部分条目时也有一种能达到线性时间的方法,那就是使用用户定义变量。这里我将介绍的仅仅是mysql中的用法。在我原来的博客在mysql中,如何为条目编号(How to number rows in MySQL)里介绍了它是怎么工做的:

set @num := 0, @type := '';
select type, variety, price
from (
   select type, variety, price,
      @num := if(@type = type, @num + 1, 1) as row_number,
      @type := type as dummy
  from fruits
  order by type, price
) as x where x.row_number <= 2;

这个方法并不只仅作单次扫描,子查询在后台建立临时表,而后经过一次扫描将数据填充进去,而后在临时表中选择数据用于主查询的WHERE语句。但即便是两次扫描,它的时间复杂度还是O(n),这里n是表示数据表的行数。它远比上面的相关子查询的结果O(n ^ 2)要好许多, 这里的n表示的是分组中平均条目数 - 即便是中等规模的数据也会形成极差的性能。(假设每种水果中有5 varitey,那么就须要25次扫描)

在MySQL中一次扫描的方法

若是你没法放弃你头脑中优化查询的想法,你能够试试这个方法,它不使用临时表,而且只作一次扫描

set @num := 0, @type := '';

select type, variety, price,
      @num := if(@type = type, @num + 1, 1) as row_number,
      @type := type as dummy
from fruits
group by type, price, variety
having row_number <= 2;

只要MySQL的GROUP BY语句符合标准,这个方式在理论上就是是可行。那么实际上可行吗?下面是我在MySQL 5.0.7的Windows 版上的结果

+--------+----------+-------+------------+--------+
| type   | variety  | price | row_number | dummy  |
+--------+----------+-------+------------+--------+
| apple  | gala     |  2.79 |          1 | apple  |
| apple  | fuji     |  0.24 |          3 | apple  |
| orange | valencia |  3.59 |          1 | orange |
| orange | navel    |  9.36 |          3 | orange |
| pear   | bradford |  6.05 |          1 | pear   |
| pear   | bartlett |  2.14 |          3 | pear   |
| cherry | bing     |  2.55 |          1 | cherry |
| cherry | chelan   |  6.33 |          3 | cherry |
+--------+----------+-------+------------+--------+

能够看到,这已经和结果很接近了。他返回了每一个分组的第一行和第三行,结果并无按照price的升序进行排列。当时HAVING 语句要求row_number不该当大于2。接下来是5.0.24a 在ubuntu上的结果:

+--------+------------+-------+------------+--------+
| type   | variety    | price | row_number | dummy  |
+--------+------------+-------+------------+--------+
| apple  | fuji       |  0.24 |          1 | apple  |
| apple  | gala       |  2.79 |          1 | apple  |
| apple  | limbertwig |  2.87 |          1 | apple  |
| cherry | bing       |  2.55 |          1 | cherry |
| cherry | chelan     |  6.33 |          1 | cherry |
| orange | valencia   |  3.59 |          1 | orange |
| orange | navel      |  9.36 |          1 | orange |
| pear   | bartlett   |  2.14 |          1 | pear   |
| pear   | bradford   |  6.05 |          1 | pear   |
+--------+------------+-------+------------+--------+

此次,全部的row_number都是1,并且好像全部行都返回了。能够参考MySQL手册用户自定义变量

使用这种技术的结果很难肯定,主要是由于这里涉及的技术是你和我都不能直接接触的,例如MySQL在Group的时候使用哪一个索引。若是你仍须要使用它 - 我知道不少人已经用了,由于我告诉了他们 - 你仍是能够用的。咱们正在进入SQL的真正领域,可是上面的结果是在没有设置索引的状况下获得的。咱们如今看看了设置了索引以后group的结果是什么。

alter table fruits add key(type, price);

执行以后会发现没有什么变化,以后使用EXPLAIN查看查询过程,会发现此查询没有使用任何索引。这是为何呢?由于Group使用了3个字段,可是索引只有两个字段。实际上,查询仍使用了临时表,全部咱们并没完成一次扫描的目标。咱们能够强制使用索引:

set @num := 0, @type := '';

select type, variety, price,
      @num := if(@type = type, @num + 1, 1) as row_number,
      @type := type as dummy
from fruits force index(type)
group by type, price, variety
having row_number <= 2;

咱们看一下是否起做用了。

+--------+----------+-------+------------+--------+
| type   | variety  | price | row_number | dummy  |
+--------+----------+-------+------------+--------+
| apple  | fuji     |  0.24 |          1 | apple  | 
| apple  | gala     |  2.79 |          2 | apple  | 
| cherry | bing     |  2.55 |          1 | cherry | 
| cherry | chelan   |  6.33 |          2 | cherry | 
| orange | valencia |  3.59 |          1 | orange | 
| orange | navel    |  9.36 |          2 | orange | 
| pear   | bartlett |  2.14 |          1 | pear   | 
| pear   | bradford |  6.05 |          2 | pear   | 
+--------+----------+-------+------------+--------+

如今咱们获得了咱们想要的结果了,并且没有文件排序(filesort)和临时表。还有一种方法就是将variety提出到GROUP BY以外,这样它就可使用本身的索引。由于这个查询是一个从分组中查询非分组字段的查询,它只能在 ONLY_FULL_GROUP_BY 模式关闭(连接)的状况下才能起做用。可是在没有特殊缘由的状况下,我不建议你这么作。

其余方法

能够在评论中看到其余的方法,里面有的确有一些很是梦幻的方法。我一直在大家的评论获取知识,感谢大家。

总结

咱们这里介绍了集中方法去解决“每一个分组中最大的条目”这类问题已经进一步扩展到查询每组中前N个条目的方法。以后咱们深刻探讨了一些MySQL特定的技术,这些技术看起来有一些傻和笨。可是若是你须要榨干服务器的最后一点性能,你就须要知道何时去打破规则。对于那些认为这是MySQL自己的问题的人,我要说这不是,我曾经看到过使用其余平台的人也在作着一样的事情,如SQL Server。在每一个平台上都会有不少特殊的小技巧和花招,使用他们的人必须去适应它。

本文转自[翻译]如何在mysql中查询每一个分组的前几名

相关文章
相关标签/搜索