MySQL 分组之后 TopN 问题的处理方式
最近在工作中使用 MySQL
碰到了 TopN
,理论上来说这类问题不是 MySQL
的能力范围,不过在数据量较小的场景中仍然可以使用数据库的能力来完成 TopN
问题.
场景
为了避免场景难以理解,我们使用一个最简单的场景来实现该问题. 现在有一张学生成绩表,我们需要查寻出每门课程前 3 名的学生和成绩.
表结构
-- 学生分数
-- auto-generated definition
create table student_score
(
id int auto_increment
primary key,
name varchar(50) default '' not null comment '姓名',
course varchar(50) default '' not null comment '课程',
score int default -1 not null comment '分数'
)
comment '学生分数';
导入成绩
INSERT INTO student_score (id, name, course, score) VALUES (1, '张三', '数学', 44);
INSERT INTO student_score (id, name, course, score) VALUES (2, '张三', '语文', 55);
INSERT INTO student_score (id, name, course, score) VALUES (3, '张三', '英语', 58);
INSERT INTO student_score (id, name, course, score) VALUES (4, '李四', '数学', 45);
INSERT INTO student_score (id, name, course, score) VALUES (5, '李四', '语文', 87);
INSERT INTO student_score (id, name, course, score) VALUES (6, '李四', '英语', 45);
INSERT INTO student_score (id, name, course, score) VALUES (7, '王五', '数学', 76);
INSERT INTO student_score (id, name, course, score) VALUES (8, '王五', '语文', 34);
INSERT INTO student_score (id, name, course, score) VALUES (9, '王五', '英语', 89);
INSERT INTO student_score (id, name, course, score) VALUES (10, '赵六', '语文', 86);
INSERT INTO student_score (id, name, course, score) VALUES (11, '赵六', '数学', 95);
INSERT INTO student_score (id, name, course, score) VALUES (12, '赵六', '英语', 92);
INSERT INTO student_score (id, name, course, score) VALUES (13, '钱七', '数学', 56);
INSERT INTO student_score (id, name, course, score) VALUES (14, '钱七', '语文', 95);
INSERT INTO student_score (id, name, course, score) VALUES (15, '钱七', '英语', 96);
INSERT INTO student_score (id, name, course, score) VALUES (16, '周八', '数学', 93);
INSERT INTO student_score (id, name, course, score) VALUES (17, '周八', '语文', 99);
INSERT INTO student_score (id, name, course, score) VALUES (18, '周八', '英语', 94);
INSERT INTO student_score (id, name, course, score) VALUES (19, '孙九', '数学', 100);
INSERT INTO student_score (id, name, course, score) VALUES (20, '孙九', '语文', 89);
INSERT INTO student_score (id, name, course, score) VALUES (21, '孙九', '英语', 99);
INSERT INTO student_score (id, name, course, score) VALUES (22, '吴十', '数学', 96);
INSERT INTO student_score (id, name, course, score) VALUES (23, '吴十', '语文', 79);
INSERT INTO student_score (id, name, course, score) VALUES (24, '吴十', '英语', 80);
实现
方式一(union)
对于学生成绩这个场景来说,我们可以使用首先查询出每个科目前三名的成绩,然后使用 union
拼接数据,同理,也可以在外部程序中循环每个科目,然后查询前 N 名数据拼接到一起即可,但是该方式不具有通用性,尤其在科目较多的情况下.
在本场景中,只有三个科目,可以直接使用以下 SQL 查询.
select name, score, course
from (select name, score, course
from student_score
where course = '语文'
order by score desc
limit 3) temp1
union all
select name, score, course
from (select name, score, course
from student_score
where course = '数学'
order by score desc
limit 3) temp2
union all
select temp3.name, temp3.score, temp3.course
from (select name, score, course
from student_score
where course = '英语'
order by score desc
limit 3) temp3
当然选择在外部程序用循环科目查询也是可以的,这里不再演示
方式二(自关联)
注意
表连接查询,在 MySQL 5.7
及以下可以使用这种方式,如果是 MySQL 5.8
及以上,可以使用更方便的开窗函数处理,该方式不推荐在 MySQL 5.8
及以上使用
首先可以简单思考一下,第一名是不是代表没有人比他成绩还高?第二名是不是代表着有只有一个人成绩比他高? 根据这个思路,我们首先使用 SQL 查询出有几个人成绩比我高的数据
这是同表关联,关键点在与连接条件 s1.score < s2.score
,这表示比自己成绩高的人作为连接条件 s1.course = s2.course and s1.name != s2.name
则表示需要同科目比较,并且自己不参与比较
select s1.name, s1.course, s1.score
from student_score s1
left join student_score s2
on s1.score < s2.score
and s1.course = s2.course
and s1.name != s2.name
group by s1.name, s1.course, s1.score
这条 SQL 依然不完整,我们需要的是有几个人我成绩高的数据
提示
补充一个 count(s2.id)
表示有几个人成绩比我高,0 则表示没有人比我高,也就代表是第一名
select s1.name, s1.course, s1.score,
count(s2.id) as higher_than_me
from student_score s1
left join student_score s2
on s1.score < s2.score
and s1.course = s2.course
and s1.name != s2.name
group by s1.name, s1.course, s1.score
order by s1.course, s1.score desc;
执行查询会出现以下结果:
name | course | score | higher_than_me |
---|---|---|---|
孙九 | 数学 | 100 | 0 |
吴十 | 数学 | 96 | 1 |
赵六 | 数学 | 95 | 2 |
周八 | 数学 | 93 | 3 |
王五 | 数学 | 76 | 4 |
钱七 | 数学 | 56 | 5 |
李四 | 数学 | 45 | 6 |
张三 | 数学 | 44 | 7 |
孙九 | 英语 | 99 | 0 |
钱七 | 英语 | 96 | 1 |
周八 | 英语 | 94 | 2 |
赵六 | 英语 | 92 | 3 |
... | ... | ... | ... |
这其实就已经得出排名数据了,有 0 人比我高则代表我是第一名,有 1 人比我高则代表我是第二名. 我们继续为查询添加 having count(s2.id) < 3 条件,表示取前三名
select s1.name, s1.course, s1.score, count(s2.id) as higher_than_me,
count(s2.id)+1 as rank_num
from student_score s1
left join student_score s2
on s1.score < s2.score
and s1.course = s2.course
and s1.name != s2.name
group by s1.name, s1.course, s1.score
having count(s2.id) < 3
order by s1.course, s1.score desc;
此时就能得到想要的数据了
name | course | score | higher_than_me | rank_num |
---|---|---|---|---|
孙九 | 数学 | 100 | 0 | 1 |
吴十 | 数学 | 96 | 1 | 2 |
赵六 | 数学 | 95 | 2 | 3 |
孙九 | 英语 | 99 | 0 | 1 |
钱七 | 英语 | 96 | 1 | 2 |
周八 | 英语 | 94 | 2 | 3 |
周八 | 语文 | 99 | 0 | 1 |
钱七 | 语文 | 95 | 1 | 2 |
孙九 | 语文 | 89 | 2 | 3 |
方式三(用户变量)
什么是用户变量
MySQL 的用户变量是一种用户定义的变量,以 @
符号开头,可以在查询中使用。它们可以存储任何类型的值,仅在当前会话中有效,会话结束时会被销毁。用户变量可以用于存储中间结果或在查询中传递值。 我们可以使用用户变量来排名成绩,然后直接筛选前三名
注意
注意,切记每次使用前先清空@row_number
的数据,因为用户变量是会话级的数据,当前大多数应用都会使用连接池计数,如果不执行 set @row_number:= 0
会导致累加现象.
set @row_number := 0;
select name,
course,
score,
@orw_number := @row_number + 1 as rank_num
from student_score
order by course, score desc;
会得到如下结果
name | course | score | rank_num |
---|---|---|---|
孙九 | 数学 | 100 | 1 |
吴十 | 数学 | 96 | 2 |
赵六 | 数学 | 95 | 3 |
周八 | 数学 | 93 | 4 |
王五 | 数学 | 76 | 5 |
钱七 | 数学 | 56 | 6 |
李四 | 数学 | 45 | 7 |
张三 | 数学 | 44 | 8 |
孙九 | 英语 | 99 | 9 |
钱七 | 英语 | 96 | 10 |
... | ... | ... | ... |
可以看到,我们已经获取了排名数据 rank_num
,但是当前是连续的排名数据,我们需要让排名按照科目分组
如何让 rank_number 可以根据科目分组?
使用另一个用户变量 @course
判断当前这条数据的科目与上一条数据的科目是否不同,如果不同,从 1 开始重新累计排名
set @orw_number := 0;
set @course = '';
select name,
course,
score,
@row_number := if(@course = course, @row_number, 0) + 1 as rank_num,
@course := course as not_use
from student_score
order by course, score desc;
执行后可以得到如下结果,可以看到排名已经按照科目分组了 最后一列 not_use
对于查询结果来说无意义,这是赋值语句 @course:=course
的结果
name | course | score | rank_num | not_use |
---|---|---|---|---|
孙九 | 数学 | 100 | 1 | 数学 |
吴十 | 数学 | 96 | 2 | 数学 |
赵六 | 数学 | 95 | 3 | 数学 |
周八 | 数学 | 93 | 4 | 数学 |
王五 | 数学 | 76 | 5 | 数学 |
钱七 | 数学 | 56 | 6 | 数学 |
李四 | 数学 | 45 | 7 | 数学 |
张三 | 数学 | 44 | 8 | 数学 |
孙九 | 英语 | 99 | 1 | 英语 |
钱七 | 英语 | 96 | 2 | 英语 |
... | ... | ... | ... | ... |
继续加上前三名的筛选条件就达到目的了
set @row_number := 0;
set @course = '';
select name, course, score, rank_num
from (select name,
course,
score,
@row_number := if(@course = course, @row_number, 0) + 1 as rank_num,
@course := course as not_use
from student_score
order by course, score desc) temp
where temp.rank_num <= 3;
方式四(MySQL 8 开窗函数 row_number())
以上几种场景都是在 MySQL 5.7
没有开窗函数的条件下实用的,如果当前环境是 MySQL 8
,可以使用最简单的开窗函数完成统计
row_number()函数是什么?
row_number()
函数是 MySQL 8 开始提供的开窗函数,它的结果是返回当前的行数,与方案三(用户变量)@row_number
的方式类似,我们可以给每一行一个数字作为排名,然后筛选排名即可
我们首先直接给每一行一个编号作为排名
select course,
name,
score,
row_number() over () as rank_num
from student_score
可以得到尚未按照科目分组排名的数据
course | name | score | rank_num |
---|---|---|---|
数学 | 张三 | 44 | 1 |
语文 | 张三 | 55 | 2 |
英语 | 张三 | 58 | 3 |
数学 | 李四 | 45 | 4 |
语文 | 李四 | 87 | 5 |
英语 | 李四 | 45 | 6 |
数学 | 王五 | 76 | 7 |
语文 | 王五 | 34 | 8 |
英语 | 王五 | 89 | 9 |
... | ... | ... | ... |
row_number()
实际上在使用时可以在 over()
子句中指定分组规则和排序规则 例如 row_number() over (partition by course order by score desc)
表示按照 course
分组然后按照分数逆序返回行号 可以将 SQL 改写为如下形式,就可以达到预期效果了
select course, name, score, rank_num
from (select course,
name,
score,
row_number() over (partition by course order by score desc) as rank_num
from student_score) t
where t.rank_num <= 3
总结
综上所述,在 MySQL 5.7
的环境下,有限边界的情况下使用方式一(union)或者在外部程序中处理中较为方便,如果边界太大建议使用方式二(自关联)或者方式三(用户变量)的方式,如果是 MySQL 5.8
及以上环境下直接使用 row_number()
开窗函数即可