1. 问题背景与需求拆解
最近在牛客网刷SQL题时遇到一道非常典型的TopN分组查询问题:从2022年的听歌记录中,找出18-25岁用户每月播放量排名前三的周杰伦歌曲。这道题看似简单,但涉及了SQL中几个关键知识点:多表连接、数据过滤、分组聚合以及窗口函数的使用。
在实际业务场景中,类似的需求非常常见。比如电商平台需要统计每个品类下销量Top10的商品,视频网站要分析每月播放量最高的前5个视频等。掌握这种分组TopN查询技巧,对数据分析工作至关重要。
2. 数据表结构与关系分析
2.1 表结构说明
根据题目描述,我们需要处理三个主要数据表:
-
play_log(播放记录表):
- user_id:用户ID
- song_id:歌曲ID
- fdate:播放日期
-
user_info(用户信息表):
- user_id:用户ID
- age:用户年龄
-
song_info(歌曲信息表):
- song_id:歌曲ID
- song_name:歌曲名称
- singer_name:歌手姓名
2.2 表关系图
虽然题目没有提供ER图,但我们可以推断出表之间的关系:
- play_log.user_id → user_info.user_id
- play_log.song_id → song_info.song_id
这种关系在数据库设计中非常典型,通过外键关联实现数据规范化,避免数据冗余。
3. 完整SQL解决方案
3.1 基础查询构建
首先我们需要构建基础查询,连接三张表并应用必要的过滤条件:
sql复制SELECT
MONTH(pl.fdate) AS month,
si.song_name,
COUNT(*) AS play_pv
FROM
play_log pl
INNER JOIN user_info ui ON pl.user_id = ui.user_id
AND ui.age BETWEEN 18 AND 25
INNER JOIN song_info si ON pl.song_id = si.song_id
AND si.singer_name = '周杰伦'
WHERE
YEAR(pl.fdate) = 2022
GROUP BY
MONTH(pl.fdate), si.song_name, si.song_id
这个查询已经完成了:
- 三表连接
- 年龄过滤(18-25岁)
- 歌手过滤(周杰伦)
- 年份过滤(2022年)
- 按月分组统计播放量
3.2 窗口函数应用
接下来我们需要为每个月内的歌曲按播放量排名:
sql复制SELECT
month,
ROW_NUMBER() OVER (
PARTITION BY month
ORDER BY play_pv DESC, song_id ASC
) AS ranking,
song_name,
play_pv
FROM (
-- 上面的基础查询
) AS monthly_stats
这里使用了ROW_NUMBER()窗口函数,按月份分区,在每个分区内按播放量降序排列。如果播放量相同,则按song_id升序排列。
3.3 最终Top3筛选
最后我们只需要筛选出排名≤3的记录:
sql复制SELECT
month,
ranking,
song_name,
play_pv
FROM (
-- 包含窗口函数的查询
) AS ranked_songs
WHERE ranking <= 3
ORDER BY month ASC, ranking ASC
4. 关键技术点解析
4.1 窗口函数选择
为什么选择ROW_NUMBER()而不是RANK()或DENSE_RANK()?
- ROW_NUMBER():为每一行分配唯一的序号,即使值相同也会分配不同序号
- RANK():相同值会得到相同排名,后续排名会跳过
- DENSE_RANK():相同值会得到相同排名,但后续排名不会跳过
题目要求严格输出每月Top3,使用ROW_NUMBER()能确保每个月正好输出3条记录。如果使用RANK(),当有并列情况时可能会输出超过3条记录。
4.2 GROUP BY注意事项
在GROUP BY子句中,我们同时包含了song_name和song_id。这是因为:
- 虽然song_name理论上可以由song_id唯一确定,但在SQL标准中,SELECT列表中的非聚合列必须出现在GROUP BY中
- 包含song_id可以确保分组准确性,避免可能的song_name重复情况
- 这是SQL最佳实践,确保查询结果的可预测性
4.3 日期处理技巧
我们使用了MONTH()和YEAR()函数从日期中提取月份和年份:
- MONTH(fdate):提取月份(1-12)
- YEAR(fdate):提取年份
这种处理方式比直接使用字符串函数更高效,也更具可读性。在MySQL中,日期函数通常经过优化,性能较好。
5. 性能优化建议
5.1 索引设计
为了提高查询性能,建议在以下列上创建索引:
-
play_log表:
- (user_id, song_id, fdate)复合索引
- fdate单列索引
-
user_info表:
- user_id主键索引
- age单列索引
-
song_info表:
- song_id主键索引
- singer_name单列索引
5.2 查询优化
- 过滤条件前置:在JOIN条件中就进行年龄和歌手的过滤,减少中间结果集大小
- 避免使用函数索引:如果经常需要按月份查询,可以考虑添加一个冗余的month列并建立索引
- 限制结果集:在开发环境中可以先添加LIMIT子句测试查询性能
6. 常见问题与解决方案
6.1 如何处理播放量相同的情况?
题目中通过添加song_id作为次要排序条件来解决这个问题。在实际业务中,可能需要根据具体需求确定次要排序条件,比如:
- 按歌曲发布时间
- 按字母顺序
- 按用户评分等
6.2 如果数据量很大怎么办?
对于海量数据,可以考虑以下优化方案:
- 分区表:按月份或年份对play_log表进行分区
- 物化视图:预计算每月统计数据
- 批处理:在非高峰期运行统计任务
- 使用更专业的分析工具:如ClickHouse等列式数据库
6.3 如何扩展这个查询?
这个查询模式可以扩展到很多类似场景:
- 统计每周TopN
- 按地区分组统计
- 多维度组合分析(如年龄+性别分组)
7. 实际应用案例
7.1 电商销售分析
类似的查询可以用于分析:
- 每个品类每月销量Top10商品
- 各地区每周销售额Top5店铺
- 各年龄段用户最喜欢的商品类别
7.2 内容平台统计
在视频或文章平台中,可以分析:
- 每月播放量最高的视频
- 每周阅读量最高的文章分类
- 每日活跃用户最喜欢的标签
8. 替代方案比较
8.1 使用子查询方案
不使用窗口函数的替代方案:
sql复制SELECT
m1.month,
m1.song_name,
m1.play_pv,
COUNT(*) AS ranking
FROM monthly_stats m1
LEFT JOIN monthly_stats m2 ON m1.month = m2.month
AND (m1.play_pv < m2.play_pv
OR (m1.play_pv = m2.play_pv AND m1.song_id >= m2.song_id))
GROUP BY m1.month, m1.song_name, m1.play_pv
HAVING COUNT(*) <= 3
ORDER BY m1.month, ranking
这种方案:
- 可读性差
- 性能通常较差(O(n²)复杂度)
- 不推荐在实际中使用
8.2 使用临时表方案
- 先创建按月分组的统计临时表
- 然后对每个月份单独查询Top3
- 最后合并结果
这种方案:
- 代码冗长
- 需要多次查询
- 维护困难
- 只适合某些不支持窗口函数的旧数据库
9. 窗口函数深度解析
9.1 窗口函数语法
完整语法结构:
sql复制<窗口函数>() OVER (
[PARTITION BY <列清单>]
[ORDER BY <排序列> [ASC|DESC]]
[frame_clause]
)
9.2 常用窗口函数
-
排名函数:
- ROW_NUMBER()
- RANK()
- DENSE_RANK()
- NTILE()
-
聚合函数:
- SUM()
- AVG()
- COUNT()
- MAX()
- MIN()
-
分析函数:
- LEAD()
- LAG()
- FIRST_VALUE()
- LAST_VALUE()
9.3 性能考虑
窗口函数的性能通常优于自连接或子查询方案,因为:
- 只需要扫描数据一次
- 数据库优化器可以更好地优化执行计划
- 减少了中间结果集的生成
10. 跨数据库兼容性
10.1 MySQL vs PostgreSQL
- 语法基本兼容
- PostgreSQL的窗口函数功能更强大
- 性能优化策略可能不同
10.2 Oracle vs SQL Server
- 都支持窗口函数
- 语法细节略有差异
- 函数名称可能不同
10.3 旧版本MySQL
MySQL 5.7及以下版本:
- 窗口函数支持有限
- 需要使用替代方案
- 建议升级到MySQL 8.0+
11. 实战经验分享
在实际工作中,我总结了几个使用窗口函数的技巧:
- 明确分区边界:PARTITION BY子句的选择直接影响结果,要确保分区逻辑正确
- 处理NULL值:注意ORDER BY中对NULL值的处理方式,可以使用COALESCE函数
- 性能测试:窗口函数在大数据量时可能消耗大量内存,需要进行压力测试
- 结果验证:对于关键业务指标,建议用不同方法验证结果一致性
12. 扩展思考
12.1 动态TopN
如果需要根据业务需求动态调整TopN的数量,可以考虑:
- 使用存储过程
- 应用层控制
- 准备多个查询模板
12.2 时间范围扩展
当前查询是固定2022年,可以扩展为:
- 任意时间段查询
- 滚动时间窗口统计
- 同比环比分析
12.3 多维度分析
除了按月分组,还可以考虑:
- 按周/季度分组
- 按用户属性分组
- 组合维度分析
13. 完整代码示例
以下是带注释的完整解决方案:
sql复制-- 最终解决方案:查询2022年18-25岁用户每月播放量Top3的周杰伦歌曲
SELECT
month, -- 月份
ranking, -- 排名
song_name, -- 歌曲名称
play_pv -- 播放量
FROM (
-- 子查询:计算每月每首歌的播放量并排名
SELECT
MONTH(pl.fdate) AS month, -- 提取月份
ROW_NUMBER() OVER (
PARTITION BY MONTH(pl.fdate) -- 按月分区
ORDER BY COUNT(*) DESC, -- 按播放量降序
si.song_id ASC -- 播放量相同时按歌曲ID升序
) AS ranking,
si.song_name,
COUNT(*) AS play_pv
FROM
play_log pl
-- 连接用户表,筛选18-25岁用户
INNER JOIN user_info ui ON pl.user_id = ui.user_id
AND ui.age BETWEEN 18 AND 25
-- 连接歌曲表,筛选周杰伦歌曲
INNER JOIN song_info si ON pl.song_id = si.song_id
AND si.singer_name = '周杰伦'
WHERE
YEAR(pl.fdate) = 2022 -- 筛选2022年的记录
GROUP BY
MONTH(pl.fdate), si.song_name, si.song_id -- 按月份和歌曲分组
) AS ranked_songs
WHERE
ranking <= 3 -- 筛选每个月份的前三名
ORDER BY
month ASC, -- 按月份升序
ranking ASC; -- 按排名升序
14. 测试与验证
14.1 测试用例设计
为确保查询正确性,应该设计以下测试用例:
- 边界年龄测试(18岁和25岁用户)
- 跨年数据测试(包含2021和2022年数据)
- 同名歌曲测试(不同歌曲但名称相同)
- 播放量相同的情况
- 数据量极少的月份
14.2 结果验证方法
- 手动计算小样本数据
- 使用不同方法实现相同逻辑进行交叉验证
- 检查排序规则的准确性
- 验证分组边界条件
15. 总结与个人体会
通过这道题目,我们深入学习了SQL中分组TopN查询的实现方法。窗口函数是SQL中非常强大的特性,能够优雅地解决许多复杂的数据分析问题。
在实际项目中,我发现窗口函数有以下几个优势:
- 代码简洁易读
- 性能通常优于传统方法
- 适用场景广泛
- 结果准确可靠
对于数据分析师和数据库开发人员来说,熟练掌握窗口函数是必备技能。建议多练习各种窗口函数的使用场景,包括:
- 排名计算
- 移动平均
- 累计求和
- 前后记录比较等
最后提醒一点:在使用窗口函数时,一定要注意分区和排序逻辑的正确性,这是获得准确结果的关键。