☰
SQL分组TopN实现:窗口函数与DATE_FORMAT在每月歌曲排行中的应用
2026/10/7 17:03:21 网站建设 项目流程

这道题在牛客网的SQL练习里算是个小经典,我前前后后刷过好几遍,每次重新做都能挖出点新东西。题目本身一句话就能讲完——统计每个月播放量Top3的周杰伦歌曲,但就是这平平无奇的"分组TopN",把日期函数、聚合、JOIN、窗口函数、子查询这些SQL核心知识点全串了起来。上周我用DeepSeek帮我把整道题的解题思路重新梳理了一遍,发现几个平时容易忽略的细节,正好写出来跟同样在刷题、准备面试的朋友们聊聊。无论你是刚学SQL的新手,还是已经工作想回头补基础的老手,这篇文章都会让你对"分组TopN"这个套路有个更立体的认识。

1. 拿到题目先别急着写SQL,把三个关键点拆明白

1.1 题目翻译成人话:每个月的前三名,不是全表前三名

"每个月Top3的周杰伦歌曲",这句话看着简单,落在SQL里是两个动作的组合:先按月份分组,再在每个分组内部按播放量排序,取前3条。注意,这个"前3"是每个月的前3,不是全年总榜的前3。很多人第一反应是直接ORDER BY play_count DESC LIMIT 3,那拿到的只是全表最大的三首歌,跟题目要求差了十万八千里。

这道题我让DeepSeek帮我做的第一件事,就是把它翻译成更"机器友好"的描述:对播放记录按DATE_FORMAT(play_time, '%Y-%m')分组,组内按歌曲总播放量降序排名,保留每组排名小于等于3的记录。翻译完了之后,整个解题路径就基本浮出水面了。

1.2 表结构还原:没有表结构,一切SQL都是空中楼阁

牛客网不同批次的题目表名和字段可能略有差异,我按最常见的双表设计来还原,逻辑是通用的:

CREATE TABLE music_info ( song_id INT PRIMARY KEY COMMENT '歌曲ID', song_name VARCHAR(50) COMMENT '歌曲名', singer VARCHAR(20) COMMENT '歌手' ); CREATE TABLE play_log ( log_id INT PRIMARY KEY COMMENT '播放记录ID', song_id INT COMMENT '歌曲ID', play_time DATETIME COMMENT '播放时间', play_count INT COMMENT '本次播放量' );

有些版本会把两张表合成一张大宽表,字段里直接带上song_name和singer。这都不影响解题逻辑,只要心里有数:要统计的是"周杰伦"这个歌手的歌曲,维度是"月份",度量是"播放量之和"。如果你拿到的表字段名不一样,照着这个语义去替换就成。

我习惯在动手前先往表里塞一批测试数据,确认SQL结果能对上预期。随便造几条:

INSERT INTO music_info VALUES (1, '晴天', '周杰伦'), (2, '七里香', '周杰伦'), (3, '稻香', '周杰伦'), (4, '夜曲', '周杰伦'), (5, '青花瓷', '周杰伦'), (6, '孤勇者', '陈奕迅'); INSERT INTO play_log (log_id, song_id, play_time, play_count) VALUES (1, 1, '2024-01-03 10:00:00', 500), (2, 2, '2024-01-05 11:00:00', 400), (3, 3, '2024-01-08 09:00:00', 600), (4, 4, '2024-01-12 14:00:00', 300), (5, 5, '2024-01-20 20:00:00', 200), (6, 6, '2024-01-21 18:00:00', 9999), (7, 1, '2024-02-02 10:00:00', 300), (8, 4, '2024-02-04 12:00:00', 900), (9, 2, '2024-02-10 16:00:00', 500), (10, 3, '2024-02-15 22:00:00', 400);

可以看到,我故意放了一首陈奕迅的《孤勇者》,播放量还特别高,用来验证"歌手过滤"有没有生效。理想输出应该是:

月份歌曲总播放量排名
2024-01稻香6001
2024-01晴天5002
2024-01七里香4003
2024-02夜曲9001
2024-02七里香5002
2024-02稻香4003

1.3 这题到底在考什么:一张考点地图

这道题的考察点非常集中,我说几个关键词:GROUP BY聚合、日期格式化、窗口函数、子查询、JOIN、排序与过滤顺序。它不像某些难题专考一个冷门函数,而是把日常开发里最常用的一批技能打包在一起。

我用DeepSeek把这道题的考点和对应的MySQL知识点拉了个清单:月份分组要用DATE_FORMAT还是MONTH、分组内排名要用ROW_NUMBER还是RANK、聚合前的过滤该放WHERE还是HAVING。任何一个点理解不到位,出来的结果都会有问题。把这些点逐个吃透,比死记这条SQL本身有价值得多——因为同一套思路换个场景就能复用。

2. 分组TopN的核心思路:为什么不能一把梭

2.1 直接ORDER BY + LIMIT为什么不行

先看错误示范,很多人第一版是这样写的:

SELECT ... FROM play_log WHERE song_id IN (SELECT song_id FROM music_info WHERE singer = '周杰伦') ORDER BY play_count DESC LIMIT 3;

这条SQL做的是"全表周杰伦歌曲播放量Top3",跟题目要求的"每个月Top3"完全是两回事。LIMIT在SQL执行计划里是最后一步生效的,它只作用于整个结果集的尾部,无法感知"分组"这个维度。

我习惯用一个生活化类比来理解:LIMIT 3像是全班选成绩前三,而题目要的是"每个小组选前三"——你得先按小组把人分开,在小组内部排序,再从每个小组各取前三。这就是分组TopN和普通TopN的本质区别。分组TopN的核心矛盾是:排序的粒度是整个数据集,但取数的粒度是每个分组,SQL必须用某种方式让排序"感知"分组边界。

2.2 两层查询框架:先算总量,再组内排名,最后过滤

分组TopN最标准的解题框架是"子查询 + 窗口函数 + 外层过滤"三层结构:

  1. 第一层:按月份 + 歌曲分组,用SUM(play_count)算出每个歌曲每个月的总播放量;
  2. 第二层:在同一层(或紧接的窗口函数)里,用ROW_NUMBER() OVER (PARTITION BY 月份 ORDER BY 播放量 DESC)给每组歌曲打上组内序号;
  3. 第三层:外层WHERE rn <= 3过滤掉排名靠后的记录。

为什么必须套一层子查询?因为WHERE子句的执行顺序在窗口函数之前,你没法直接在WHERE里写rn <= 3——MySQL根本还不认识rn这个别名。所以得先让窗口函数算完、把结果当成一张派生表,再对这张表的列做过滤。这个"先算再滤"的意识是解这类题的关键门槛。

2.3 ROW_NUMBER、RANK、DENSE_RANK三兄弟怎么选

窗口函数里负责排名的有三个常用函数,它们的区别我在实际刷题时吃过亏,这里直接给结论:

函数并列处理排名是否跳跃每组返回行数
ROW_NUMBER并列时随机/按ORDER BY决定先后不跳跃,名次连续固定N行
RANK并列同名次跳跃(如1、1、3)可能超过N行
DENSE_RANK并列同名次不跳跃(如1、1、2)可能超过N行

牛客网这道题如果没特别说明"并列怎么处理",默认用ROW_NUMBER最稳,因为判题通常按严格Top3的行数来比对。如果题目要求"播放量相同的歌曲都算进Top3",那要改用RANK或DENSE_RANK,具体用哪个取决于跳跃是否影响后续行。我在调试时会让DeepSeek帮忙生成一些并列数据的测试用例,用实际输出来确认函数语义,比自己脑内推演快得多。

3. MySQL 8.0标准解法:窗口函数一条SQL拿下

3.1 完整SQL与可复现代码

如果你的MySQL版本是8.0以上(现在大部分云数据库默认都是8.0),直接用窗口函数,这是最简洁也最容易读懂的方案:

SELECT play_month, song_name, play_total, rn FROM ( SELECT DATE_FORMAT(p.play_time, '%Y-%m') AS play_month, m.song_id, m.song_name, SUM(p.play_count) AS play_total, ROW_NUMBER() OVER ( PARTITION BY DATE_FORMAT(p.play_time, '%Y-%m') ORDER BY SUM(p.play_count) DESC, MAX(p.log_id) ASC ) AS rn FROM play_log p INNER JOIN music_info m ON p.song_id = m.song_id WHERE m.singer = '周杰伦' GROUP BY DATE_FORMAT(p.play_time, '%Y-%m'), m.song_id, m.song_name ) t WHERE rn <= 3 ORDER BY play_month ASC, rn ASC;

把前面那段建表和插入数据的代码跑完,再执行上面这条SQL,得到的结果应该和我列出的预期输出完全一致。为了方便自己验证,我还习惯在最后加一行SELECT COUNT(*)对比行数——总行数应该是月份数 × 3。

3.2 窗口函数逐段拆解:PARTITION BY和ORDER BY各管什么

很多人第一次看窗口函数觉得玄乎,其实拆开就两件事。PARTITION BY负责"划组",把数据按月份切分成一块一块的独立区域;ORDER BY负责"组内排序",在每个区域里按播放量从高到低排。ROW_NUMBER()就是按这个排序顺序给每行发一个从1开始的序号。

有一个细节很多人会踩:GROUP BY和窗口函数同时出现时,窗口函数是在分组之后才计算的。所以我在OVER()里可以直接写SUM(p.play_count),这个聚合是在GROUP BY完成后得到的每行汇总值。这里要特别注意的是,若要在排序里加平局打破条件,不能直接引用p.log_id这种非聚合列(ONLY_FULL_GROUP_BY模式下直接报错),必须用MAX(p.log_id)包一层。我用这个trick保证同样播放量的歌曲排名是稳定的,不会因为执行计划不同而飘。

3.3 DATE_FORMAT的边界问题:为什么不能省掉%Y-

统计月份最容易犯的错是用MONTH(play_time)而不是DATE_FORMAT(play_time, '%Y-%m')。MONTH()只返回月数字,2024年1月和2025年1月会被算成同一个分组,跨年数据直接串味。DATE_FORMAT带上%Y才能保证"2024-01"和"2025-01"是不同组。

如果你喜欢用LEFT(play_time, 7)这种字符串截取也行,效果一样。但无论用哪种,原则只有一个:分组键里必须包含年份,除非题目明确只按自然月不分年。这一点我在第5章的踩坑实录里还会细讲,因为它造成的错误非常隐蔽,从单看某个月的结果根本发现不了。

4. MySQL 5.7兼容方案:老版本也得能打

4.1 方案一:关联子查询数"前面有几个人"

如果你的环境是MySQL 5.7(不少公司的老库仍然是5.7,牛客网上也存在老版本判题环境),用不了窗口函数,就得绕路。最稳妥的思路是关联子查询:对每个月的每首歌,去数一数同月里有几首歌的播放量严格大于它。如果这个数字小于3,那它就在Top3里。

SELECT t.play_month, t.song_name, t.play_total FROM ( SELECT DATE_FORMAT(p.play_time, '%Y-%m') AS play_month, m.song_id, m.song_name, SUM(p.play_count) AS play_total FROM play_log p INNER JOIN music_info m ON p.song_id = m.song_id WHERE m.singer = '周杰伦' GROUP BY DATE_FORMAT(p.play_time, '%Y-%m'), m.song_id, m.song_name ) t WHERE ( SELECT COUNT(*) FROM ( SELECT DATE_FORMAT(p2.play_time, '%Y-%m') AS play_month2, p2.song_id, SUM(p2.play_count) AS play_total2 FROM play_log p2 INNER JOIN music_info m2 ON p2.song_id = m2.song_id WHERE m2.singer = '周杰伦' GROUP BY DATE_FORMAT(p2.play_time, '%Y-%m'), p2.song_id ) t2 WHERE t2.play_month2 = t.play_month AND t2.play_total2 > t.play_total ) < 3 ORDER BY t.play_month ASC, t.play_total DESC;

这套写法逻辑上是RANK的语义:并列的歌曲都会被保留,可能在边界上让某个月出现超过3行。它的优点是对MySQL版本没有任何要求,缺点也明显——内层子查询要对每一行重复执行,数据量一大性能就难看。刷题没问题,生产环境慎用。

4.2 方案二:用户变量模拟行号

MySQL 5.7的另一个经典招数是用户变量。思路是:先把数据按月份 + 播放量降序排好序,然后用两个变量模拟"上一行的月份"和"累计序号",逐行扫描时发现月份变了就重置序号。

SELECT play_month, song_name, play_total, rn FROM ( SELECT play_month, song_name, play_total, @rn := IF(@prev_month = play_month, @rn + 1, 1) AS rn, @prev_month := play_month AS dummy_col FROM ( SELECT DATE_FORMAT(p.play_time, '%Y-%m') AS play_month, m.song_name, SUM(p.play_count) AS play_total FROM play_log p INNER JOIN music_info m ON p.song_id = m.song_id WHERE m.singer = '周杰伦' GROUP BY DATE_FORMAT(p.play_time, '%Y-%m'), m.song_id, m.song_name ORDER BY play_month ASC, play_total DESC ) a CROSS JOIN (SELECT @rn := 0, @prev_month := '') b ) c WHERE rn <= 3 ORDER BY play_month ASC, rn ASC;

这里有个非常关键的坑:内层派生表a的ORDER BY不能省,用户变量是按行扫描顺序递增的,顺序乱了排名就是错乱。同时,@rn的赋值表达式必须写在@prev_month赋值之前,保证先判断再更新"上一个月"。我在5.7上实测过这个写法,结果和窗口函数版一致。不过说实话,这种变量写法过于tricky,可读性差,如果是在面试中,我建议优先讲关联子查询的思路,因为更容易证明你理解原理。

4.3 三种方案横向对比

对比维度窗口函数(8.0)关联子查询(5.7)用户变量(5.7)
可读性高中低
性能最好最差较好
版本要求8.0+5.7+均兼容5.7+
并列语义按需选择函数RANK语义ROW_NUMBER语义
推荐场景生产/新项目面试讲思路老库救急

我的建议是:新环境一律用窗口函数;老库能升级尽量升级;实在升不了,数据量小用关联子查询,数据量大才考虑用户变量。刷题阶段把三种都写一遍,对理解SQL执行顺序的帮助是巨大的。

5. 实操踩坑实录:这些问题我真遇到过

5.1 用MONTH()统计导致跨年串月

这事发生在一次我给真实业务写周报SQL的时候,当时要统计"每个月Top3活动页点击歌曲"。我图省事用了MONTH(play_time)分组,跑出来1月、12月的数据总感觉不对劲,后来一排查才发现:前一年的12月和当年12月被合并成了一组,播放量是两年前求和的结果。改用DATE_FORMAT(play_time, '%Y-%m')之后,问题立刻消失。做牛客网这题的时候也是同理,判题数据如果包含跨年月份,用MONTH()必挂。

5.2 并列排名到底取谁:判题环境默认不带并列

我在本地测试时用过RANK(),发现某个月有两首歌播放量一样,结果那个月输出了4行。牛客网这道题的判题逻辑按我的经验是严格按行数比对的,它期望每组固定3行。所以默认答案必须用ROW_NUMBER(),万一并列,靠MAX(p.log_id) ASC兜底排序,保证每组不多不少正好3行。

如果你自己探索时想看看并列场景,可以让DeepSeek生成一段含同分数据的测试SQL,然后分别跑ROW_NUMBER、RANK、DENSE_RANK三个版本,对比输出差异。这种"动手对比"的方式比背文档印象深刻得多。

5.3 GROUP BY漏字段:ONLY_FULL_GROUP_BY教你做人

MySQL 5.7之后默认开了ONLY_FULL_GROUP_BY模式,SELECT里的非聚合列必须全部出现在GROUP BY里。我刚接触这道题时写过:

GROUP BY DATE_FORMAT(p.play_time, '%Y-%m'), m.song_name

结果在本地直接报错,因为SELECT里还带了m.song_id,而它没在GROUP BY中出现。就算不报错,如果两张不同歌曲同名(概率低但不是零),汇总也会串。正确姿势是GROUP BY里带上song_id作为唯一键,song_name只是跟随展示。这个习惯要尽早养成,到了生产环境数据量大、脏数据多的时候,这个细节能救命。

5.4 过滤条件放错位置:WHERE和HAVING差出数量级

有个优化细节值得单讲:过滤歌手周杰伦应该放WHERE,而不是HAVING。WHERE在分组聚合之前执行,意味着只有周杰伦的歌进入聚合计算;HAVING在分组之后执行,所有歌手都先算完一遍再丢弃非周杰伦的行。数据量一大,这两者的性能差距是指数级的。

写完SQL之后我习惯用EXPLAIN看一眼执行计划。一个合格的分组TopN查询,WHERE m.singer = '周杰伦'应该能在music_info表上走索引,然后play_log只JOIN出需要聚合的少量行。如果你看到执行计划里先全表扫了play_log再做过滤,那多半是索引缺失或者JOIN顺序有问题。这个检查习惯,比刷一百道题都值。

6. 从牛客网SQL40延伸出去:一套通用TopN模板

6.1 改需求怎么改:每个歌手Top3、每周Top5都是同一套模板

做透一道题的意义在于沉淀模板。把SQL40的核心骨架抽出来,其实就四个可替换的变量:分组维度、排名维度、过滤条件、TopN的N。

需求变体PARTITION BYWHERE过滤N 值
每个歌手每月Top3DATE_FORMAT(play_time,'%Y-%m')无(不滤歌手)3
周杰伦每周Top5DATE_FORMAT(play_time,'%x-%v')singer='周杰伦'5
每个城市销售额Top10city无10
每门课成绩Top2course_id无2

比如"每个歌手每个月Top3",只需要把WHERE删掉、PARTITION BY改成DATE_FORMAT + singer的复合分组键;"每周Top5"只需要把%Y-%m换成%x-%v(ISO周格式,注意跨年问题)。理解模板之后再刷牛客的其他SQL题,你会发现很多题目都是换汤不换药。

6.2 生产环境下的索引建议

刷题不考虑性能,但真要复用这套SQL到业务上,索引得跟上。我给的建索引建议是:

  • music_info表:(singer, song_id)联合索引。singer用于过滤,song_id用于覆盖JOIN字段。
  • play_log表:(song_id, play_time, play_count)联合索引。song_id用于JOIN,play_time用于日期范围/格式化分组,play_count用于覆盖聚合计算,避免回表。

这样三列全在索引里,查询就是一个覆盖索引扫描+分组排序,压测下来比裸表快一个数量级。实践时先建索引再看执行计划,别凭感觉。

6.3 用AI助手刷SQL题的正确姿势

这个话题放在最后聊,因为我觉得它比单道题更重要。现在很多人用DeepSeek这类AI助手刷题,但用法天差地别。我最推荐的做法有三个:

第一,让AI先讲解思路,而不是直接要答案。比如我会问"分组TopN为什么必须用子查询包一层窗口函数",让它把执行顺序讲清楚,比自己瞎试高效得多。

第二,让AI生成边界测试数据。SQL题的坑往往藏在边角数据里,跨年、并列、空值、重复记录,让AI按你的要求生成一份刁钻的数据集,然后用标准答案去跑,能快速验证自己对题目的理解。

第三,把AI当code reviewer。写完SQL后把自己的版本和标准答案一起丢给它,让它指出逻辑差异和隐患。我在写第4章用户变量版本时,就是靠它帮我排查了变量赋值顺序的坑。

有一点要提醒:AI偶尔会编造不存在的函数或错误语法,尤其是涉及具体MySQL版本特性时。任何SQL都要自己跑一遍验证,AI只能帮你加速思考,不能替代判题环境。把AI当"陪练"而不是"代打",刷题收获会完全不同。

这套分组TopN的思路,我后来在业务里用到了好几个地方:排行榜、热点内容精选、库存预警TopN……每次都能很快套上模板改出来。牛客网SQL40作为入门练习确实是好题,但更值得带走的是它背后的解题框架。下次再看到"每个XX的TopN"类题目,你也能一眼看穿它的结构了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询