【问题标题】:Using LIMIT within GROUP BY to get N results per group?在 GROUP BY 中使用 LIMIT 来获得每组 N 个结果?
【发布时间】:2011-01-08 22:30:42
【问题描述】:

以下查询:

SELECT
year, id, rate
FROM h
WHERE year BETWEEN 2000 AND 2009
AND id IN (SELECT rid FROM table2)
GROUP BY id, year
ORDER BY id, rate DESC

产量:

year    id  rate
2006    p01 8
2003    p01 7.4
2008    p01 6.8
2001    p01 5.9
2007    p01 5.3
2009    p01 4.4
2002    p01 3.9
2004    p01 3.5
2005    p01 2.1
2000    p01 0.8
2001    p02 12.5
2004    p02 12.4
2002    p02 12.2
2003    p02 10.3
2000    p02 8.7
2006    p02 4.6
2007    p02 3.3

我想要的只是每个 id 的前 5 个结果:

2006    p01 8
2003    p01 7.4
2008    p01 6.8
2001    p01 5.9
2007    p01 5.3
2001    p02 12.5
2004    p02 12.4
2002    p02 12.2
2003    p02 10.3
2000    p02 8.7

有没有办法在 GROUP BY 中使用某种类似 LIMIT 的修饰符来做到这一点?

【问题讨论】:

  • 这在 MySQL 中是可以做到的,但并不像添加LIMIT 子句那么简单。这里有一篇文章详细解释了这个问题:How to select the first/least/max row per group in SQL 这是一篇好文章——他介绍了一个优雅但幼稚的解决方案来解决“Top N per group”问题,然后逐步改进。
  • SELECT * FROM (SELECT year, id, rate FROM h WHERE year BETWEEN 2000 AND 2009 AND id IN (SELECT rid FROM table2) GROUP BY id, year ORDER BY id, rate DESC) LIMIT 5

标签: mysql sql group-by greatest-n-per-group ranking


【解决方案1】:

您可以使用GROUP_CONCAT 聚合函数将所有年份放在一个列中,按id 分组并按rate 排序:

SELECT   id, GROUP_CONCAT(year ORDER BY rate DESC) grouped_year
FROM     yourtable
GROUP BY id

结果:

-----------------------------------------------------------
|  ID | GROUPED_YEAR                                      |
-----------------------------------------------------------
| p01 | 2006,2003,2008,2001,2007,2009,2002,2004,2005,2000 |
| p02 | 2001,2004,2002,2003,2000,2006,2007                |
-----------------------------------------------------------

然后你可以使用FIND_IN_SET,它返回第一个参数在第二个参数中的位置,例如。

SELECT FIND_IN_SET('2006', '2006,2003,2008,2001,2007,2009,2002,2004,2005,2000');
1

SELECT FIND_IN_SET('2009', '2006,2003,2008,2001,2007,2009,2002,2004,2005,2000');
6

使用 GROUP_CONCATFIND_IN_SET 的组合,并按 find_in_set 返回的位置进行过滤,然后您可以使用这个查询,它只返回每个 id 的前 5 年:

SELECT
  yourtable.*
FROM
  yourtable INNER JOIN (
    SELECT
      id,
      GROUP_CONCAT(year ORDER BY rate DESC) grouped_year
    FROM
      yourtable
    GROUP BY id) group_max
  ON yourtable.id = group_max.id
     AND FIND_IN_SET(year, grouped_year) BETWEEN 1 AND 5
ORDER BY
  yourtable.id, yourtable.year DESC;

请看小提琴here

请注意,如果多行可以有相同的比率,您应该考虑在比率列而不是年份列上使用 GROUP_CONCAT(DISTINCT rate ORDER BY rate)。

GROUP_CONCAT 返回的字符串的最大长度是有限的,所以如果您需要为每个组选择几条记录,这很有效。

【讨论】:

  • 这是漂亮性能,比较简单,很好的解释;太感谢了。最后一点,如果可以计算合理的最大长度,可以使用SET SESSION group_concat_max_len = <maximum length>; 在 OP 的情况下,这是一个非问题(因为默认值为 1024),但作为示例, group_concat_max_len 应该至少为 25: 4(一年字符串的最大长度)+ 1(分隔符),乘以 5(前 5 年)。字符串被截断而不是引发错误,因此请注意诸如1054 rows in set, 789 warnings (0.31 sec) 之类的警告。
  • 如果我想获取准确的 2 行而不是 1 到 5 行而不是我应该使用 FIND_IN_SET() 的内容。我尝试了FIND_IN_SET() =2,但没有按预期显示结果。
  • 如果大小等于或大于 5,FIND_IN_SET BETWEEN 1 和 5 将占据 GROUP_CONCAT 集的前 5 个位置。因此 FIND_IN_SET = 2 将仅占据 GROUP_CONCAT 中第二个位置的数据。假设 set 有 2 行要给出,您可以在 1 和 2 之间尝试获得 2 行以获得第 1 和第 2 位置。
  • 对于大型数据集,该解决方案的性能比 Salman 的解决方案要好得多。无论如何,我对这两个聪明的解决方案都竖起了大拇指。谢谢!!
【解决方案2】:

original query 使用用户变量,ORDER BY 用于派生表;不能保证这两种怪癖的行为。修改后的答案如下。

在 MySQL 5.x 中,您可以使用穷人对分区的排名来获得所需的结果。只需将表与自身外部连接,对于每一行,计算比它小于的行数。在上述情况下,较少的行是具有较高速率的行:

SELECT t.id, t.rate, t.year, COUNT(l.rate) AS rank
FROM t
LEFT JOIN t AS l ON t.id = l.id AND t.rate < l.rate
GROUP BY t.id, t.rate, t.year
HAVING COUNT(l.rate) < 5
ORDER BY t.id, t.rate DESC, t.year

Demo and Result:

| id  | rate | year | rank |
|-----|------|------|------|
| p01 |  8.0 | 2006 | 0    |
| p01 |  7.4 | 2003 | 1    |
| p01 |  6.8 | 2008 | 2    |
| p01 |  5.9 | 2001 | 3    |
| p01 |  5.3 | 2007 | 4    |
| p02 | 12.5 | 2001 | 0    |
| p02 | 12.4 | 2004 | 1    |
| p02 | 12.2 | 2002 | 2    |
| p02 | 10.3 | 2003 | 3    |
| p02 |  8.7 | 2000 | 4    |

请注意,如果费率有关联,例如:

100, 90, 90, 80, 80, 80, 70, 60, 50, 40, ...

上述查询将返回 6 行:

100, 90, 90, 80, 80, 80

更改为 HAVING COUNT(DISTINCT l.rate) &lt; 5 以获得 8 行:

100, 90, 90, 80, 80, 80, 70, 60

或者改成ON t.id = l.id AND (t.rate &lt; l.rate OR (t.rate = l.rate AND t.pri_key &gt; l.pri_key))得到5行:

 100, 90, 90, 80, 80

在 MySQL 8 或更高版本中,只需使用 RANK, DENSE_RANK or ROW_NUMBER 函数:

SELECT *
FROM (
    SELECT *, RANK() OVER (PARTITION BY id ORDER BY rate DESC) AS rnk
    FROM t
) AS x
WHERE rnk <= 5

【讨论】:

  • 我认为值得一提的是,关键部分是 ORDER BY id,因为 id 值的任何更改都会重新计算排名。
  • 为什么要运行两次才能得到WHERE rank &lt;=5的响应?我第一次没有从每个 id 中得到 5 行,但之后我能够如你所说的那样得到。
  • @BrennoLeal 我认为您忘记了SET 语句(请参阅第一个查询)。这是必要的。
  • 在较新的版本中,派生表中的ORDER BY 可以并且通常会被忽略。这破坏了目标。高效的分组发现here
  • +1 你的答案重写是非常有效的,因为现代 MySQL/MariaDB 版本遵循 ANSI/ISO SQL 1992/1999/2003 标准,在交付时从未真正允许使用ORDER BY /subqueries 这样的..这就是为什么现代 MySQL/MariaDB 版本在不使用 LIMIT 的情况下忽略子查询中的 ORDER BY 的原因,我相信 ANSI/ISO SQL 标准 2008/2011/2016 使 ORDER BY 在交付/子查询中合法与FETCH FIRST n ROWS ONLY结合使用时
【解决方案3】:

对我来说像

SUBSTRING_INDEX(group_concat(col_name order by desired_col_order_name), ',', N) 

完美运行。没有复杂的查询。


例如:为每个组获得前 1 名

SELECT 
    *
FROM
    yourtable
WHERE
    id IN (SELECT 
            SUBSTRING_INDEX(GROUP_CONCAT(id
                            ORDER BY rate DESC),
                        ',',
                        1) id
        FROM
            yourtable
        GROUP BY year)
ORDER BY rate DESC;

【讨论】:

  • 您的解决方案运行良好,但我还想从子查询中检索年份和其他列,我们该怎么做?
【解决方案4】:

不,您不能任意限制子查询(在较新的 MySQL 中您可以在有限的范围内这样做,但每组不能有 5 个结果)。

这是一个 groupwise-maximum 类型的查询,这在 SQL 中并不简单。有various ways 来解决在某些情况下可能更有效的问题,但对于一般情况下的前n 名,您需要查看Bill's answer 来解决类似的先前问题。

与此问题的大多数解决方案一样,如果有多行具有相同的 rate 值,它可以返回超过五行,因此您可能仍需要大量的后处理来检查。

【讨论】:

    【解决方案5】:

    这需要一系列子查询来对值进行排名,限制它们,然后在分组时执行求和

    @Rnk:=0;
    @N:=2;
    select
      c.id,
      sum(c.val)
    from (
    select
      b.id,
      b.bal
    from (
    select   
      if(@last_id=id,@Rnk+1,1) as Rnk,
      a.id,
      a.val,
      @last_id=id,
    from (   
    select 
      id,
      val 
    from list
    order by id,val desc) as a) as b
    where b.rnk < @N) as c
    group by c.id;
    

    【讨论】:

      【解决方案6】:
      SELECT year, id, rate
      FROM (SELECT
        year, id, rate, row_number() over (partition by id order by rate DESC)
        FROM h
        WHERE year BETWEEN 2000 AND 2009
        AND id IN (SELECT rid FROM table2)
        GROUP BY id, year
        ORDER BY id, rate DESC) as subquery
      WHERE row_number <= 5
      

      子查询与您的查询几乎相同。唯一的变化是添加

      row_number() over (partition by id order by rate DESC)
      

      【讨论】:

      • 这很好,但 MySQL 没有窗口函数(如 ROW_NUMBER())。
      • 从 MySQL 8.0 开始,row_number()available
      • 为了让示例按原样工作,只需将别名添加到行号:(row_number() over (partition by user_id order by created_at DESC)) as row_number
      【解决方案7】:

      试试这个:

      SELECT h.year, h.id, h.rate 
      FROM (SELECT h.year, h.id, h.rate, IF(@lastid = (@lastid:=h.id), @index:=@index+1, @index:=0) indx 
            FROM (SELECT h.year, h.id, h.rate 
                  FROM h
                  WHERE h.year BETWEEN 2000 AND 2009 AND id IN (SELECT rid FROM table2)
                  GROUP BY id, h.year
                  ORDER BY id, rate DESC
                  ) h, (SELECT @lastid:='', @index:=0) AS a
          ) h 
      WHERE h.indx <= 5;
      

      【讨论】:

      • 字段列表中的未知列 a.type
      【解决方案8】:

      构建虚拟列(如Oracle中的RowID)

      表格:

      CREATE TABLE `stack` 
      (`year` int(11) DEFAULT NULL,
      `id` varchar(10) DEFAULT NULL,
      `rate` float DEFAULT NULL) 
      ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
      

      数据:

      insert into stack values(2006,'p01',8);
      insert into stack values(2001,'p01',5.9);
      insert into stack values(2007,'p01',5.3);
      insert into stack values(2009,'p01',4.4);
      insert into stack values(2001,'p02',12.5);
      insert into stack values(2004,'p02',12.4);
      insert into stack values(2005,'p01',2.1);
      insert into stack values(2000,'p01',0.8);
      insert into stack values(2002,'p02',12.2);
      insert into stack values(2002,'p01',3.9);
      insert into stack values(2004,'p01',3.5);
      insert into stack values(2003,'p02',10.3);
      insert into stack values(2000,'p02',8.7);
      insert into stack values(2006,'p02',4.6);
      insert into stack values(2007,'p02',3.3);
      insert into stack values(2003,'p01',7.4);
      insert into stack values(2008,'p01',6.8);
      

      这样的 SQL:

      select t3.year,t3.id,t3.rate 
      from (select t1.*, (select count(*) from stack t2 where t1.rate<=t2.rate and t1.id=t2.id) as rownum from stack t1) t3 
      where rownum <=3 order by id,rate DESC;
      

      如果删除t3中的where子句,显示如下:

      GET "TOP N Record" --> 在where 子句中添加rownum &lt;=3(t3 的 where 子句);

      CHOOSE "the year" --> 在where 子句中添加BETWEEN 2000 AND 2009(t3 的 where 子句);

      【讨论】:

      • 如果您有相同 id 重复的费率,那么这将不起作用,因为您的 rowNum 计数会增加得更高;你不会每行得到 3 个,你可以得到 0、1 或 2。你能想到任何解决方案吗?
      • @starvator 将 "t1.rate
      【解决方案9】:

      花了一些时间,但我认为我的解决方案值得分享,因为它看起来既优雅又非常快。

      SELECT h.year, h.id, h.rate 
        FROM (
          SELECT id, 
            SUBSTRING_INDEX(GROUP_CONCAT(CONCAT(id, '-', year) ORDER BY rate DESC), ',' , 5) AS l
            FROM h
            WHERE year BETWEEN 2000 AND 2009
            GROUP BY id
            ORDER BY id
        ) AS h_temp
          LEFT JOIN h ON h.id = h_temp.id 
            AND SUBSTRING_INDEX(h_temp.l, CONCAT(h.id, '-', h.year), 1) != h_temp.l
      

      请注意,此示例是针对问题的目的而指定的,并且可以很容易地修改以用于其他类似目的。

      【讨论】:

        【解决方案10】:

        以下帖子:sql: selcting top N record per group 描述了在没有子查询的情况下实现此目的的复杂方法。

        它改进了以下提供的其他解决方案:

        • 在一个查询中处理所有事情
        • 能够正确利用索引
        • 避免子查询,众所周知,在 MySQL 中会产生错误的执行计划

        然而它并不漂亮。如果在 MySQL 中启用了窗口函数(又名分析函数),则可以实现一个好的解决方案——但事实并非如此。 上述帖子中使用的技巧利用了 GROUP_CONCAT,它有时被描述为“穷人的 MySQL 窗口函数”。

        【讨论】:

          【解决方案11】:

          对于像我这样有查询超时的人。我制作了以下内容以使用特定组的限制和其他任何内容。

          DELIMITER $$
          CREATE PROCEDURE count_limit200()
          BEGIN
              DECLARE a INT Default 0;
              DECLARE stop_loop INT Default 0;
              DECLARE domain_val VARCHAR(250);
              DECLARE domain_list CURSOR FOR SELECT DISTINCT domain FROM db.one;
          
              OPEN domain_list;
          
              SELECT COUNT(DISTINCT(domain)) INTO stop_loop 
              FROM db.one;
              -- BEGIN LOOP
              loop_thru_domains: LOOP
                  FETCH domain_list INTO domain_val;
                  SET a=a+1;
          
                  INSERT INTO db.two(book,artist,title,title_count,last_updated) 
                  SELECT * FROM 
                  (
                      SELECT book,artist,title,COUNT(ObjectKey) AS titleCount, NOW() 
                      FROM db.one 
                      WHERE book = domain_val
                      GROUP BY artist,title
                      ORDER BY book,titleCount DESC
                      LIMIT 200
                  ) a ON DUPLICATE KEY UPDATE title_count = titleCount, last_updated = NOW();
          
                  IF a = stop_loop THEN
                      LEAVE loop_thru_domain;
                  END IF;
              END LOOP loop_thru_domain;
          END $$
          

          它遍历一个域列表,然后每个域只插入 200 个限制

          【讨论】:

            【解决方案12】:

            试试这个:

            SET @num := 0, @type := '';
            SELECT `year`, `id`, `rate`,
                @num := if(@type = `id`, @num + 1, 1) AS `row_number`,
                @type := `id` AS `dummy`
            FROM (
                SELECT *
                FROM `h`
                WHERE (
                    `year` BETWEEN '2000' AND '2009'
                    AND `id` IN (SELECT `rid` FROM `table2`) AS `temp_rid`
                )
                ORDER BY `id`
            ) AS `temph`
            GROUP BY `year`, `id`, `rate`
            HAVING `row_number`<='5'
            ORDER BY `id`, `rate DESC;
            

            【讨论】:

              【解决方案13】:

              请尝试以下存储过程。我已经验证过了。我得到了正确的结果,但没有使用groupby

              CREATE DEFINER=`ks_root`@`%` PROCEDURE `first_five_record_per_id`()
              BEGIN
              DECLARE query_string text;
              DECLARE datasource1 varchar(24);
              DECLARE done INT DEFAULT 0;
              DECLARE tenants varchar(50);
              DECLARE cur1 CURSOR FOR SELECT rid FROM demo1;
              DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
              
                  SET @query_string='';
              
                    OPEN cur1;
                    read_loop: LOOP
              
                    FETCH cur1 INTO tenants ;
              
                    IF done THEN
                      LEAVE read_loop;
                    END IF;
              
                    SET @datasource1 = tenants;
                    SET @query_string = concat(@query_string,'(select * from demo  where `id` = ''',@datasource1,''' order by rate desc LIMIT 5) UNION ALL ');
              
                     END LOOP; 
                    close cur1;
              
                  SET @query_string  = TRIM(TRAILING 'UNION ALL' FROM TRIM(@query_string));  
                select @query_string;
              PREPARE stmt FROM @query_string;
              EXECUTE stmt;
              DEALLOCATE PREPARE stmt;
              
              END
              

              【讨论】:

                猜你喜欢
                • 1970-01-01
                • 2019-04-21
                • 1970-01-01
                • 1970-01-01
                相关资源
                最近更新 更多