Hive 窗口函数详解:让数据在分组中完成排名、累计和跨行计算

在学习 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 名;

比较当前记录与前后记录;

获取每组第一条或最后一条数据;

统计连续登录天数。

相关推荐
计算机毕业编程指导师2 小时前
【计算机毕设选题】基于Hadoop的零售交易者行为特征与生存状况数据分析及可视化系统源码 毕业设计 选题推荐 数据分析 机器学习
大数据·hadoop·python·spark·毕业设计·课程设计·零售
陈年老古董2 小时前
Hive 文件格式与查询学习笔记
hive·笔记·学习
Sayai1 天前
ClickHouse WITH FILL 实战:监控大屏时间序列补零,附多粒度指标表与物化视图级联设计
大数据·运维·数据仓库·clickhouse·物化视图
计算机毕业编程指导师1 天前
计算机毕设怎么做?Python+Hadoop的跨平台消费者评论情感分析与数据可视化系统实战 源码 毕业设计 选题推荐 毕设选题 数据分析
大数据·hadoop·python·计算机·spark·课程设计·消费者评论
卷毛迷你猪2 天前
快速实验篇(B16)用户级预测可行性审计(否定性意见)
大数据·hadoop·数据挖掘·聚类
计算机毕业编程指导师2 天前
【计算机毕设选题】基于Hadoop+Spark的化妆品销售数据分析与可视化系统源码 毕业设计 选题推荐 毕设选题 数据分析 机器学习
大数据·hadoop·python·计算机·spark·课程设计·化妆品
躺柒2 天前
读数据架构知识体系指南16数据网格(上)
数据仓库·架构·数据分析·数据湖·数据架构·关系数据库
省钱兄--zs3 天前
北京24小时自助健身房系统软件开发实战:从架构设计到部署指南
java·数据仓库·spring boot·小程序·需求分析
计算机毕业编程指导师3 天前
【Python毕设选题推荐】基于Spark的宫颈癌变光谱特征数据分析可视化系统源码 毕业设计 选题推荐 毕设选题 数据分析 机器学习
大数据·hadoop·python·计算机·spark·毕业设计·宫颈癌变