在学习 Hive 时,我们经常会遇到这样的需求:
1.计算每名学生的总成绩,同时保留每一科的成绩;
2.查询每个班级成绩最高的前三名;
3.比较当前记录和上一条记录;
4.计算累计销售额;
5.找出连续登录三天以上的用户。
如果只使用普通的 GROUP BY,很多需求实现起来会比较麻烦。因为 GROUP BY 会把多行数据合并成一行,原来的明细数据就无法继续保留。
而窗口函数既能完成分组统计,又能保留原来的每一行数据,因此特别适合处理排名、累计、占比和前后行比较等问题。
一、什么是窗口函数?
窗口函数也叫分析函数。它会在当前数据周围划出一个"窗口",然后对窗口中的数据进行计算。
它的基本格式如下:
窗口函数() over (
partition by 分组字段
order by 排序字段
rows between 窗口起点 and 窗口终点
)
其中:
1.partition by:对数据进行分组;
2.order by:规定组内数据的顺序;
3.rows between:规定当前行可以看到哪些数据;
4.over():表示前面的函数按照窗口规则进行计算。
例如:
select
id,
cid,
score,
sum(score) over(partition by id) as sum_score
from bigdata.scores;
这段代码表示:
按照学生
id分组,计算每名学生的总成绩,同时保留每一科的成绩。
假设原始数据如下:
| id | cid | score |
|---|---|---|
| 1 | 语文 | 80 |
| 1 | 数学 | 90 |
| 1 | 英语 | 70 |
| 2 | 语文 | 85 |
| 2 | 数学 | 75 |
执行后得到:
| id | cid | score | sum_score |
|---|---|---|---|
| 1 | 语文 | 80 | 240 |
| 1 | 数学 | 90 | 240 |
| 1 | 英语 | 70 | 240 |
| 2 | 语文 | 85 | 160 |
| 2 | 数学 | 75 | 160 |
可以看到,窗口函数计算出了总成绩,但是没有把三门课程合并成一行。
这就是窗口函数与普通聚合函数最大的区别。
二、窗口函数与 GROUP BY 的区别
普通聚合查询可以这样写:
select
id,
sum(score) as sum_score
from bigdata.scores
group by id;
执行后,每名学生只保留一行:
| id | sum_score |
|---|---|
| 1 | 240 |
| 2 | 160 |
如果使用窗口函数:
select
id,
cid,
score,
sum(score) over(partition by id) as sum_score
from bigdata.scores;
原来的课程明细仍然会被保留。
可以简单总结为:
GROUP BY:多行变成一行
窗口函数:完成统计,但不会减少数据行数
如果既想看到每科成绩,又想看到总成绩、平均成绩或者最高成绩,窗口函数通常会更加方便。
三、常用窗口函数的分类
按照功能,常用窗口函数可以分为三类:
1. 聚合类窗口函数
常见函数包括:
sum():求和
avg():求平均值
max():求最大值
min():求最小值
count():统计数量
2. 排名类窗口函数
常见函数包括:
row_number():连续编号
rank():并列排名,后面的名次会跳跃
dense_rank():并列排名,后面的名次不会跳跃
3. 跨行取值函数
常见函数包括:
lag():获取前面某一行的数据
lead():获取后面某一行的数据
first_value():获取窗口中的第一个值
last_value():获取窗口中的最后一个值
四、聚合类窗口函数
1. 使用 SUM 计算每名学生的总成绩
select
id,
cid,
score,
sum(score) over(partition by id) as sum_score
from bigdata.scores;
代码解析:
sum(score)
表示对成绩进行求和。
partition by id
表示按照学生编号进行分组。
因此,同一个学生的所有课程成绩会被放到同一个窗口中求和。
2. 计算每科成绩占总成绩的比例
如果不使用窗口函数,可能需要先计算总成绩,再与原表进行关联:
select
a.id,
a.cid,
a.score,
b.sum_score,
round(a.score / b.sum_score, 4) as p
from bigdata.scores as a
join (
select
id,
sum(score) as sum_score
from bigdata.scores
group by id
) as b
on a.id = b.id;
这段代码需要先分组统计,再进行 JOIN,执行过程相对复杂。
使用窗口函数后,可以写成:
select
id,
cid,
score,
sum_score,
round(score / sum_score, 4) as p
from (
select
id,
cid,
score,
sum(score) over(partition by id) as sum_score
from bigdata.scores
) as a;
里面一层先计算每名学生的总成绩,外面一层再计算单科成绩所占的比例。
例如某名学生三科成绩分别为 80、90、70,总成绩是 240,那么数学成绩占比就是:
90 ÷ 240 = 0.375
这种写法不需要再把原表与统计结果进行关联,逻辑更加清楚。
3. 使用 MAX 查询个人最高成绩
select
id,
cid,
score,
max(score) over(partition by id) as max_score
from bigdata.scores;
执行后,每一行都会显示该学生的最高成绩。
例如:
| id | cid | score | max_score |
|---|---|---|---|
| 1 | 语文 | 80 | 90 |
| 1 | 数学 | 90 | 90 |
| 1 | 英语 | 70 | 90 |
原来的明细没有丢失,同时又增加了最高成绩。
4. 使用 COUNT 统计课程数量
select
id,
cid,
score,
count(1) over(partition by id) as course_num
from bigdata.scores;
这段代码会统计每名学生一共有多少条成绩记录。
如果一个学生有语文、数学和英语三条数据,那么 course_num 就是 3。
五、在窗口中加入 ORDER BY
窗口中的 order by 不只是让结果看起来有顺序,它还会影响窗口函数的计算范围。
例如:
select
id,
cid,
score,
sum(score) over(
partition by id
order by cid
) as sum_score
from bigdata.scores;
当窗口中没有 order by 时,sum() 计算的是整个分组的总和。
加入 order by 后,通常会变成从窗口开头到当前行的累计计算。
假设某名学生的成绩按课程编号排列如下:
| cid | score |
|---|---|
| 1 | 80 |
| 2 | 90 |
| 3 | 70 |
累计结果为:
| cid | score | sum_score |
|---|---|---|
| 1 | 80 | 80 |
| 2 | 90 | 170 |
| 3 | 70 | 240 |
计算过程是:
第一行:80
第二行:80 + 90 = 170
第三行:80 + 90 + 70 = 240
1. 累计求最大值
select
id,
cid,
score,
max(score) over(
partition by id
order by cid
) as current_max
from bigdata.scores;
它会从第一行开始,逐行比较当前已经出现的最大值。
假设成绩为:
80、90、70、95
计算结果为:
80、90、90、95
2. 累计求平均值
select
id,
cid,
score,
avg(score) over(
partition by id
order by cid
) as current_avg
from bigdata.scores;
它计算的是截至当前行的平均成绩。
假设成绩为:
80、90、70
结果为:
第一行:80
第二行:(80 + 90) / 2 = 85
第三行:(80 + 90 + 70) / 3 = 80
六、自定义窗口范围
窗口范围通常使用下面的格式:
rows between 起点 and 终点
常见写法包括:
unbounded preceding:分组中的第一行
n preceding:当前行前面的第n行
current row:当前行
n following:当前行后面的第n行
unbounded following:分组中的最后一行
1. 从第一行累计到当前行
sum(score) over(
partition by id
order by cid
rows between unbounded preceding and current row
)
可以理解为:
从本组第一行开始,一直计算到当前行
它常用于累计求和。
2. 计算前一行、当前行和后一行
sum(score) over(
partition by id
order by cid
rows between 1 preceding and 1 following
)
假设当前行是第三行,那么它会计算:
第二行 + 第三行 + 第四行
这种写法可以用于计算移动求和或移动平均值。
例如:
select
id,
cid,
score,
avg(score) over(
partition by id
order by cid
rows between 1 preceding and 1 following
) as moving_avg
from bigdata.scores;
3. 计算整个分组
sum(score) over(
partition by id
rows between unbounded preceding
and unbounded following
)
表示窗口从本组第一行一直覆盖到最后一行,因此得到的是整个分组的总和。
七、ROWS 和 RANGE 的区别
在窗口函数中,经常会看到两种范围定义:
rows between ...
以及:
range between ...
它们最大的区别是:
ROWS 按照数据的实际行数划分窗口;
RANGE 按照排序字段的值划分窗口。
假设排序字段中存在相同值:
| score |
|---|
| 80 |
| 90 |
| 90 |
| 100 |
使用 ROWS 时,两条 90 分的数据是两行不同的记录。
使用 RANGE 时,排序值相同的两条 90 分记录可能会被看成同一个范围。
因此,如果想严格按照"前一行、当前行、后一行"计算,通常使用 ROWS 会更加直观。
八、排名类窗口函数
1. ROW_NUMBER:连续编号
select
id,
cid,
score,
row_number() over(
partition by id
order by score desc
) as ranking
from bigdata.scores;
这段代码表示:
按照学生分组,在每名学生的成绩中从高到低进行编号。
即使两门课程的分数相同,row_number() 也会给出不同的序号。
假设成绩是:
100、90、90、80
排名结果是:
1、2、3、4
它不会产生并列名次。
2. RANK:并列后跳过名次
select
id,
sum(score) as sum_score,
rank() over(
order by sum(score) desc
) as ranking
from bigdata.scores
group by id;
假设学生总成绩为:
300、280、280、260
使用 rank() 后,排名结果为:
1、2、2、4
两个 280 分并列第二名,因此下一个名次直接变成第四名。
3. DENSE_RANK:并列后不跳过名次
select
id,
sum(score) as sum_score,
dense_rank() over(
order by sum(score) desc
) as ranking
from bigdata.scores
group by id;
仍然使用刚才的数据:
300、280、280、260
dense_rank() 的结果为:
1、2、2、3
并列第二名以后,下一个名次仍然是第三名。
4. 三种排名函数的区别
假设成绩为:
100、90、90、80
结果如下:
| 成绩 | row_number | rank | dense_rank |
|---|---|---|---|
| 100 | 1 | 1 | 1 |
| 90 | 2 | 2 | 2 |
| 90 | 3 | 2 | 2 |
| 80 | 4 | 4 | 3 |
可以这样记忆:
row_number:不考虑并列,编号绝不重复
rank:考虑并列,后面的名次会跳号
dense_rank:考虑并列,后面的名次不跳号
九、使用窗口函数求 Top N
窗口函数非常适合处理"每组前几名"的问题。
例如,查询每名学生分数最高的两门课程:
select
id,
cid,
score,
ranking
from (
select
id,
cid,
score,
row_number() over(
partition by id
order by score desc
) as ranking
from bigdata.scores
) as a
where ranking <= 2;
里面一层按照学生分组,并对成绩进行排名。
外面一层只保留前两名:
where ranking <= 2
这里不能直接在同一层的 WHERE 中使用 ranking,因为窗口函数通常是在 WHERE 之后计算的,所以需要使用子查询。
这类写法经常用于:每个班级成绩最高的三名学生;每个部门工资最高的五名员工;
十、跨行取值函数
1. LAG:获取前面的数据
lag() 用来取得当前行前面的某一行。
基本格式为:
lag(字段, 向前几行, 默认值)
over(order by 排序字段)
例如:
select
id,
sum(score) as sum_score,
lag(sum(score), 1, 0) over(
order by sum(score) desc
) as previous_score
from bigdata.scores
group by id;
其中:
sum(score):需要获取的字段
1:获取当前行前面第1行
0:如果前面没有数据,就返回0
它可以用于比较当前数据与上一条数据之间的差值。
例如:
select
id,
sum_score,
previous_score,
sum_score - previous_score as score_difference
from (
select
id,
sum(score) as sum_score,
lag(sum(score), 1, 0) over(
order by sum(score)
) as previous_score
from bigdata.scores
group by id
) as a;
2. LEAD:获取后面的数据
lead() 与 lag() 相反,用来取得当前行后面的某一行。
select
id,
sum(score) as sum_score,
lead(sum(score), 1, 0) over(
order by sum(score) desc
) as next_score
from bigdata.scores
group by id;
可以简单记忆:
lag:向前看
lead:向后看
需要注意,lag() 和 lead() 的偏移位置由函数参数决定,一般不通过 rows between 自定义窗口。
3. FIRST_VALUE:获取窗口内第一个值
select
id,
cid,
score,
first_value(score) over(
partition by id
order by score desc
) as first_score
from bigdata.scores;
因为按照成绩从高到低排列,所以窗口中的第一个值就是最高成绩。
在这种情况下,first_value(score) 得到的效果和最高分比较接近。
4. LAST_VALUE:获取窗口内最后一个值
select
id,
cid,
score,
last_value(score) over(
partition by id
order by score desc
rows between unbounded preceding
and unbounded following
) as last_score
from bigdata.scores;
这里要特别注意:
如果窗口中有 order by,但是没有指定完整的窗口范围,窗口终点通常只到当前行。这时 last_value() 很可能返回当前行的值,而不是整个分组最后一行的值。
所以,如果想获取整个分组的最后一个值,最好明确写上:
rows between unbounded preceding
and unbounded following
这表示从本组第一行一直看到最后一行。
十一、使用窗口函数统计连续登录
窗口函数的一个经典案例,是查找连续登录三天及以上的用户。
假设登录记录如下:
| user_id | login_date |
|---|---|
| 01 | 2021-02-28 |
| 01 | 2021-03-01 |
| 01 | 2021-03-02 |
| 01 | 2021-03-04 |
| 01 | 2021-03-05 |
| 01 | 2021-03-06 |
| 02 | 2021-03-01 |
| 02 | 2021-03-02 |
| 02 | 2021-03-03 |
我们的目标是找出连续登录不少于三天的时间段。
方法一:日期减去序号
先给每名用户的登录日期编号:
select
user_id,
login_date,
row_number() over(
partition by user_id
order by login_date
) as rn
from bigdata.user_login_log;
假设某名用户连续登录三天:
| login_date | rn |
|---|---|
| 2021-03-01 | 1 |
| 2021-03-02 | 2 |
| 2021-03-03 | 3 |
分别用日期减去序号:
2021-03-01 - 1天 = 2021-02-28
2021-03-02 - 2天 = 2021-02-28
2021-03-03 - 3天 = 2021-02-28
连续日期减去连续序号后,会得到相同的日期。因此,可以使用这个结果分组。
完整代码如下:
select
user_id,
min(login_date) as start_date,
max(login_date) as end_date,
count(1) as login_days
from (
select
user_id,
login_date,
date_sub(login_date, rn) as group_date
from (
select
user_id,
login_date,
row_number() over(
partition by user_id
order by login_date
) as rn
from bigdata.user_login_log
) as a
) as b
group by user_id, group_date
having count(1) >= 3;
代码思路可以概括为:
按照用户和日期排序
↓
生成连续序号
↓
登录日期减去序号
↓
相同结果划分为一组
↓
统计每组记录数量
↓
保留数量大于等于3的组
方法二:使用 LAG 标记是否连续
首先,获取上一次登录日期:
select
user_id,
login_date,
lag(login_date, 1) over(
partition by user_id
order by login_date
) as previous_date
from bigdata.user_login_log;
然后判断当前日期与上一次登录日期是否相差一天:
if(
datediff(login_date, previous_date) = 1,
0,
1
) as flag
如果相差一天,说明连续,标记为 0;否则标记为 1。
接着,对标记进行累计求和:
sum(flag) over(
partition by user_id
order by login_date
) as group_id
每遇到一次不连续,累计值就增加 1,因此相同累计值的数据属于同一个连续登录区间。
完整代码如下:
select
user_id,
min(login_date) as start_date,
max(login_date) as end_date,
count(1) as login_days
from (
select
user_id,
login_date,
sum(flag) over(
partition by user_id
order by login_date
) as group_id
from (
select
user_id,
login_date,
if(
datediff(login_date, previous_date) = 1,
0,
1
) as flag
from (
select
user_id,
login_date,
lag(login_date, 1) over(
partition by user_id
order by login_date
) as previous_date
from bigdata.user_login_log
) as a
) as b
) as c
group by user_id, group_id
having count(1) >= 3;
这种方法虽然代码稍长,但是逻辑很清楚:
获取上次登录时间
↓
判断是否连续
↓
在不连续的位置打标记
↓
累计标记形成分组
↓
统计每组连续天数
十二、窗口函数常见注意事项
1. 窗口函数不会减少数据行数
窗口函数是在原有数据旁边增加计算结果,不会像 GROUP BY 那样把多行合并成一行。
2. PARTITION BY 可以省略
如果省略 partition by,所有数据会被当成一个整体窗口。
例如:
row_number() over(order by score desc)
表示对全部数据进行排名。
如果写成:
row_number() over(
partition by class_id
order by score desc
)
则表示在每个班级内部重新排名。
3. ORDER BY 会影响窗口范围
下面两条语句含义不同:
sum(score) over(partition by id)
表示计算每名学生的全部成绩。
sum(score) over(
partition by id
order by cid
)
通常表示按课程顺序累计求和。
4. 窗口函数一般不能直接写在 WHERE 中
下面的写法通常不可用:
select
id,
score,
row_number() over(order by score desc) as rn
from bigdata.scores
where rn <= 3;
因为执行 WHERE 时,rn 还没有计算出来。
应该使用子查询:
select *
from (
select
id,
score,
row_number() over(
order by score desc
) as rn
from bigdata.scores
) as a
where rn <= 3;
5. LAST_VALUE 要注意窗口终点
想获取整个分组最后一个值时,最好明确指定:
rows between unbounded preceding
and unbounded following
否则它可能只返回当前行。
十三、窗口函数与 UDF、UDAF、UDTF
Hive 中还经常出现三个概念:
1. UDF
UDF 表示一行输入、一行输出。
例如:
upper(name)
year(order_date)
length(name)
可以理解为:
传入一行,返回一行
2. UDAF
UDAF 表示多行输入、一行输出,通常是聚合函数。
例如:
sum()
count()
max()
avg()
可以理解为:
传入多行,汇总成一行
3. UDTF
UDTF 表示一行输入、多行输出。
例如:
explode()
可以理解为:
传入一行,展开成多行
窗口函数虽然可以使用 sum()、count() 等聚合函数,但加上 over() 后,它不会把明细行合并掉。
十四、向老师讲解时可以这样说
如果需要用一段话介绍窗口函数,可以这样讲:
窗口函数是在不减少原有数据行数的情况下,对一组相关数据进行计算。
partition by负责分组,order by负责组内排序,rows between负责设置计算范围。窗口函数主要包括聚合类、排名类和跨行取值类。它可以用于计算累计值、组内排名、前后行差值、Top N 和连续登录天数等需求。
如果需要举一个最简单的例子,可以使用:
select
id,
cid,
score,
sum(score) over(partition by id) as sum_score
from bigdata.scores;
然后解释:
这条语句按照学生编号分组,计算每名学生的总成绩,但是仍然保留每一科的成绩明细。这就是窗口函数和普通
GROUP BY的主要区别。
十五、总结
窗口函数最核心的特点是:
既能完成分组计算,又能保留原始明细。
常用结构如下:
函数名() over(
partition by 分组字段
order by 排序字段
rows between 窗口起点 and 窗口终点
)
三类常用窗口函数分别是:
聚合类:
sum、avg、max、min、count
排名类:
row_number、rank、dense_rank
跨行取值类:
lag、lead、first_value、last_value
实际开发中,窗口函数经常用于:
分组统计;
累计求和;
移动平均;
组内排名;
查询每组前 N 名;
比较当前记录与前后记录;
获取每组第一条或最后一条数据;
统计连续登录天数。