[翻译]如何在mysql中查询每个分组的前几名

http://my.oschina.net/u/1032146/blog/149300

问题

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

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

翻译

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

这篇文章会用到行数(row number),我在原来的文章 MySQL-specific 和 generic 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频道和邮件列表中出现的越来越频繁。我使用水果问题来作为示例,在示例中我们要选出每类水果中最便宜的一个,我们期望的结果如下

+--------+----------+-------+
| 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的最小值

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 | 
+--------+----------+

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

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之外,这样它就可以使用自己的索引。因为这个查询是一个从分组中查询非分组字段的查询,它只能在 ONLYFULLGROUP_BY 模式关闭(链接)的情况下才能起作用。但是在没有特殊原因的情况下,我不建议你这么做。

其他方法

可以在评论中看到其他的方法,里面有的确有一些非常梦幻的方法。我一直在你们的评论获取知识,感谢你们。

总结

 

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

<think> 我们讨论的是查询每个分组中name字段的最大值,注意这里的“最大值”应该指的是name字段的字符长度最大值(因为上一次的问题是关于查询所有name的最大值,即最长name)。但根据用户当的问题描述,可能是指每个分组中name字段的字符长度最大值对应的记录。不过,用户的问题描述是“查询每个分组里name字段最大值的方法”,结合引用内容,用户可能是想获取每个分组中name字段字符长度最大的记录(整行)。 由于用户没有明确说明是取name字段的字符长度最大值,还是按name字段的字典序最大值,但根据引用[1][2][3]的内容,引用中都是按照某个字段(如createDate或DATE)的最大值来获取整条记录。而用户上一次的问题是关于name字段的最大长度,因此我们这里假设用户想按分组获取name字段的最大长度(即字符数最多)的记录。 但是,用户的问题描述中并没有指定分组字段,也没有指定要获取整条记录还是只需要name字段的最大值。因此,我们需要分情况讨论: 1. 如果只需要每个分组中name字段的最大长度值(一个值),那么直接按分组求MAX(LENGTH(name))即可。 2. 如果需要获取每个分组中name字段长度最大的整条记录,那么就需要使用窗口函数或者子查询。 根据引用[1][2][3]中提供的方法,我们可以采用以下两种方式(假设分组字段为group_col): 方法一:使用窗口函数(推荐,MySQL 8.0+) 为每个分组内的记录按name的长度降序排名,然后取每个分组的第一名(即最长name的记录)。 方法二:使用子查询 先按分组求出每个分组中name的最大长度,然后与原表连接,找出那些在分组内长度等于该最大长度的记录。注意:一个分组内可能有多个记录具有相同的最大长度,所以这种方法会返回所有并列的记录。 由于用户没有明确说明分组字段,我们假设分组字段为`group_col`。 具体SQL如下: 方法一(窗口函数): ```sql SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY group_col ORDER BY LENGTH(name) DESC) AS rn FROM your_table ) AS ranked WHERE rn = 1; ``` 注意:ROW_NUMBER()会给每个分组内的记录按长度从大到小排序并赋予唯一的序号(1,2,3...),取rn=1即每个分组的第一条(最长的一条)。如果有多个相同长度的记录,这种方法只会返回一条(按排序规则,如果有其他排序需求可以在ORDER BY里添加)。 如果希望返回所有并列的记录,可以使用DENSE_RANK()或RANK(),但是注意它们的行为: - RANK(): 如果有并列,则并列的记录会得到相同的排名,并且会跳过后续的排名(例如,两个并列第一,则下一个是第三) - DENSE_RANK(): 如果有并列,则并列的记录会得到相同的排名,但后续排名连续(例如,两个并列第一,下一个是第二) 所以,如果我们希望返回所有并列第一的记录,可以这样: ```sql SELECT * FROM ( SELECT *, DENSE_RANK() OVER (PARTITION BY group_col ORDER BY LENGTH(name) DESC) AS dr FROM your_table ) AS ranked WHERE dr = 1; ``` 方法二(子查询): ```sql SELECT t1.* FROM your_table t1 INNER JOIN ( SELECT group_col, MAX(LENGTH(name)) AS max_len FROM your_table GROUP BY group_col ) t2 ON t1.group_col = t2.group_col AND LENGTH(t1.name) = t2.max_len; ``` 这种方法会返回每个分组内所有name长度等于最大长度的记录。 注意:如果分组字段group_col和name字段都可能为NULL,那么需要根据实际情况处理NULL值(例如,使用COALESCE或者添加条件过滤NULL)。 另外,如果用户的需求是取每个分组中name字段的字典序最大值(即按字母顺序最大的),那么只需要将上述方法中的LENGTH(name)替换为name即可(注意排序方向,字典序最大用DESC,最小用ASC)。 因此,我们需要澄清用户的需求:是按长度还是按字典序?以及是否需要整条记录? 但是,根据用户上一次的问题(查询所有name的最大值)以及引用的内容,我们推测用户的需求是按长度。所以以下我们按每个分组中name长度最大来提供答案。 示例: 假设表`test`有如下数据: | id | group_col | name | |----|-----------|-----------| | 1 | A | Alice | | 2 | A | Bob | | 3 | A | Alexandra | -- 长度9 | 4 | B | Chris | | 5 | B | David | | 6 | B | Edward | -- 长度6(与David相同) | 7 | B | Daniel | 对于分组A,最大长度是9(Alexandra);分组B,最大长度是6(David和Edward)。 使用方法一(ROW_NUMBER())将返回: 分组A: Alexandra (id=3) 分组B: 只返回一个,可能是David或Edward(取决于数据库内部的排序,如果没有指定额外的排序,顺序是不确定的) 如果使用方法一(DENSE_RANK())并取dr=1: 分组A: Alexandra (id=3) 分组B: David和Edward (id=5,6) 使用方法二(子查询)同样会返回: 分组A: Alexandra (id=3) 分组B: David和Edward (id=5,6) 因此,根据用户是否要所有并列记录,选择合适的方法。 最后,考虑到用户可能使用不同版本的MySQL,如果用户使用的是MySQL 8.0以下,则无法使用窗口函数,只能使用子查询方法。 总结: 问题:查询每个分组里name字段最大值(这里指最大长度)的方法 回答: 在MySQL中,要查询每个分组中name字段的最大值(这里假设是最大长度)及其对应的记录,可以使用以下两种方法: 1. **使用窗口函数(MySQL 8.0及以上)**: ```sql -- 返回每个分组中name长度最大的所有记录(并列时返回多条) SELECT * FROM ( SELECT *, DENSE_RANK() OVER (PARTITION BY group_col ORDER BY LENGTH(name) DESC) AS dr FROM your_table ) AS ranked WHERE dr = 1; ``` 2. **使用子查询(适用于所有MySQL版本)**: ```sql SELECT t1.* FROM your_table t1 INNER JOIN ( SELECT group_col, MAX(LENGTH(name)) AS max_len FROM your_table GROUP BY group_col ) t2 ON t1.group_col = t2.group_col AND LENGTH(t1.name) = t2.max_len; ``` 注意:如果分组字段`group_col`或`name`字段可能为NULL,需要根据业务需求调整。 另外,如果用户希望返回的是每个分组中name字段的字典序最大值,只需将上述语句中的`LENGTH(name)`替换为`name`,并将排序方向改为`DESC`(字典序降序即为最大值)即可。 根据用户的具体需求选择合适的方法。 生成的相关问题: 1. 如何查询每个分组中name字段的最小值(最短长度)? 2. MySQL 5.7中如何实现窗口函数相同的分组排序取最大值? 3. 分组查询时,如何同时获取最大值和最小值? </think>在 MySQL查询每个分组里 `name` 字段的最大值(指字符长度最大值),主要有以下几种方法,根据 MySQL 版本和需求选择合适方案: ### 方法1:使用窗口函数(推荐 MySQL 8.0+) ```sql SELECT * FROM ( SELECT *, DENSE_RANK() OVER ( PARTITION BY group_col ORDER BY LENGTH(name) DESC ) AS dr FROM your_table ) AS ranked WHERE dr = 1; ``` **说明**: 1. `DENSE_RANK()` 为每个分组内的记录按 `name` 长度降序排名 2. `PARTITION BY group_col` 指定分组字段(如部门、类别等) 3. `WHERE dr = 1` 筛选每组中长度最大的记录 4. **优点**:返回所有并列最长的记录[^1][^2] ### 方法2:使用子查询(兼容所有版本) ```sql SELECT t1.* FROM your_table t1 INNER JOIN ( SELECT group_col, MAX(LENGTH(name)) AS max_len FROM your_table GROUP BY group_col ) t2 ON t1.group_col = t2.group_col AND LENGTH(t1.name) = t2.max_len; ``` **说明**: 1. 子查询先计算每组中 `name` 的最大长度 2. 主表通过 `LENGTH(name) = max_len` 关联结果 3. **优点**:兼容 MySQL 5.x 等旧版本[^3] ### 方法3:分组后取第一条(需唯一排序) ```sql SELECT * FROM ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY group_col ORDER BY LENGTH(name) DESC, id DESC ) AS rn FROM your_table ) AS ordered WHERE rn = 1; ``` **说明**: 1. 当存在并列最大值时,通过额外排序字段(如 `id`)确保唯一性 2. 只返回每组一条记录(即使有多个并列最长值)[^1] ### 示例数据与结果 **测试数据**: | id | group_col | name | |---|-----------|------------| | 1 | A | Alice | | 2 | A | Alexandra | ← A组最长 (9字符) | 3 | B | Bob | | 4 | B | Benjamin | ← B组最长 (9字符) | 5 | B | Brian | ← B组并列最长 (5字符) **查询结果**: | id | group_col | name | |---|-----------|------------| | 2 | A | Alexandra | | 4 | B | Benjamin | | 5 | B | Brian | ### 注意事项 1. **中文字符处理**:用 `CHAR_LENGTH()` 替代 `LENGTH()` ```sql MAX(CHAR_LENGTH(name)) -- 正确统计中文 ``` 2. **性能优化**:为分组字段和长度计算创建复合索引 ```sql ALTER TABLE your_table ADD INDEX idx_group_name ((group_col), (LENGTH(name))); ``` 3. **NULL 值**:`LENGTH(NULL)` 返回 NULL,可用 `COALESCE` 处理 ```sql MAX(LENGTH(COALESCE(name, ''))) ``` ### 完整用例 ```sql -- 创建测试表 CREATE TABLE employees ( id INT PRIMARY KEY, dept VARCHAR(10), name VARCHAR(50) ); -- 插入数据 INSERT INTO employees VALUES (1, 'HR', 'Alice'), (2, 'HR', 'Alexandra'), (3, 'IT', 'Bob'), (4, 'IT', 'Benjamin'), (5, 'IT', 'Brian'); -- 查询每个部门最长的名字 SELECT dept, name FROM ( SELECT *, DENSE_RANK() OVER ( PARTITION BY dept ORDER BY CHAR_LENGTH(name) DESC ) AS dr FROM employees ) ranked WHERE dr = 1; ```
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值