Hive 从入门到实战(七):窗口函数------从"为什么需要"到三大面试题全解
这是《Hive 从入门到实战》系列第 7 篇,也是含金量最高的一篇:窗口函数 + 行列转换 + 连续登录 + TopN,全是大厂 SQL 面试高频题。
本篇学习路线图:先从一道"算占比"需求引出为什么要窗口函数 → 与 group by 划清界限 → 四类函数逐个上手(聚合 → 累积 → 排序 → 取值)→ 三大面试题逐个击破(行列转换、TopN、连续登录)→ 工程变式与文献印证。
目录
- 一、从一道需求说起:为什么需要窗口函数
- 二、什么是窗口函数:和 group by 的本质区别
- 三、聚合类窗口函数:sum / count / max / avg
- 四、加上 order by:窗口变成了"累积窗口"
- 五、排序类:row_number / rank / dense_rank
- 六、取值类:lag / lead / first_value / last_value
- 七、面试题一:行列转换
- 八、面试题二:连续登录 N 天(含实战变式追问)
- 九、总结
一、从一道需求说起:为什么需要窗口函数
需求:计算每个学生每科成绩占总分的比例。
总分要先按学生聚合出来,再拿单科成绩去除。最直觉的写法是"子查询聚合 + join":
sql
SELECT
a.id,
a.cid,
a.score,
b.sum_score,
round(a.score / b.sum_score,2) as p
FROM
scores as a
JOIN(
SELECT
id,
sum(score) as sum_score
FROM
scores
GROUP BY
id
) as b
on a.id = b.id;
能出结果,但有两个问题:
- 同一张表扫了两遍 (一遍算明细,一遍算总分),
scores数据量大时非常浪费 - 子查询聚合 + join 会产生两个 MapReduce 阶段,效率低------"减少重复扫描、减少执行阶段"正是查询优化研究的核心课题3
我们要的其实很朴素:在每一行后面"贴"一个该学生总分的批注,行数不变、不用 join。这就是窗口函数诞生的原因。
二、什么是窗口函数:和 group by 的本质区别
窗口函数:对每一行数据,以它所在的"窗口"(一组相关行)为范围做计算,结果附加在这一行上,行数不变。
和 group by 的本质区别(必须先想明白):
| group by | 窗口函数 | |
|---|---|---|
| 行数变化 | 多行压缩成一行 | 行数不变,只是加了一列计算结果 |
| 类比 | 把 3 科成绩"合计成"一行总分 | 在 3 行成绩旁边各"贴上"总分的批注 |

图1:group by 把三科成绩"压扁"成一行总分;窗口函数行数不变,每行"贴上"总分批注
通用语法(完整规范见官方 WindowingAndAnalytics 文档4):
sql
函数名(...) over(partition by 分组字段 order by 排序字段)
partition by:把数据分成多个窗口(相当于 group by 的分组,但不压缩行)order by:窗口内排序(部分函数需要)over(...)里的两个子句都可省略,省略 = 整张表一个大窗口
本文统一用成绩表演示:
sql
CREATE TABLE scores(
id string , -- 学号
cid string , -- 科目编号:1=语文 2=数学 3=英语
score double
)
ROW FORMAT DELIMITED FIELDS TERMINATED BY ','
location '/data/scores';
数据(10 行,特意造了 1002 和 1004 同分,后面讲 rank 要用):
1001,1,90
1001,2,85
1001,3,88
1002,1,75
1002,2,92
1002,3,80
1003,1,68
1003,2,78
1003,3,70
1004,1,75
1004,2,80
1004,3,92
三、聚合类窗口函数:sum / count / max / avg
3.1 sum() over():重写开头的比例需求
sql
SELECT
id,
cid,
score,
sum_score,
round(score / sum_score,2) as p
FROM
(
SELECT
id,
cid,
score,
sum(score) over(partition by id) as sum_score -- 每行的"窗口总分"
FROM
scores
) as a;
输出:
OK
id cid score sum_score p
1001 1 90.0 263.0 0.34
1001 2 85.0 263.0 0.32
1001 3 88.0 263.0 0.33
1002 1 75.0 247.0 0.3
1002 2 92.0 247.0 0.37
1002 3 80.0 247.0 0.32
1003 1 68.0 216.0 0.31
1003 2 78.0 216.0 0.36
1003 3 70.0 216.0 0.32
1004 1 75.0 247.0 0.3
1004 2 80.0 247.0 0.32
1004 3 92.0 247.0 0.37
对比第 1 节:一遍扫描、无 join、无 group by 压缩,原 12 行原样保留,每行多了总分列。这就是窗口函数最经典的用法。

图2:PARTITION BY 画墙不搬家,各算各的总分、互不掺和
3.2 count / max / avg 同理
sql
-- 每个学生一共考了几科
SELECT id, cid, score,
count(1) over(partition by id) as cnt
FROM scores;
-- 每个学生的最高分
SELECT id, cid, score,
max(score) over(partition by id) as max_score
FROM scores;
max 输出(1001 的最高分 90 贴在每行后面):
OK
id cid score max_score
1001 1 90.0 90.0
1001 2 85.0 90.0
1001 3 88.0 90.0
...
四、加上 order by:窗口变成了"累积窗口"
over(partition by id **order by cid**) 之后,语义变了:计算范围不再是整个窗口,而是"窗口起点 → 当前行"。
4.1 累积求和
sql
SELECT
id, cid, score,
sum(score) over(partition by id order by cid asc) as sum_score
from scores;
输出:
OK
id cid score sum_score
1001 1 90.0 90.0 ← 只加第1科
1001 2 85.0 175.0 ← 前2科累加
1001 3 88.0 263.0 ← 前3科累加
...
典型用途:累计销售额------按天 order by,每行就是"截至当天的累计值"。

图3:帧随当前行逐行"长大",90 → 175 → 263 逐行累积
4.2 逐个比较求最大值 / 逐个求平均
sql
-- 逐个比较:每行是"到目前为止"的最大值
SELECT id, cid, score,
max(score) over(partition by id order by cid) as max_score
from scores;
-- 逐个求平均:每行是"到目前为止"的平均值
SELECT id, cid, score,
avg(score) over(partition by id order by cid) as num
from scores;
max 输出:
OK
id cid score max_score
1001 1 90.0 90.0
1001 2 85.0 90.0
1001 3 88.0 90.0
记忆口诀:over 里加 order by,聚合就带"累积"属性;不加 order by,算的是整个窗口。
五、排序类:row_number / rank / dense_rank
三个函数都是"窗口内编排名号",区别在于遇到并列名次怎么处理。
5.1 组内排序
sql
SELECT
id, cid, score,
row_number() over(partition by id order by score DESC) as rn
from scores;
输出(每个学生内部按分数从高到低):
OK
id cid score rn
1001 1 90.0 1
1001 3 88.0 2
1001 2 85.0 3
...
5.2 面试题三:TopN 排名------三兄弟同台对比
需求:按总分排名,看三者的区别:
sql
SELECT
id,
sum(score) as sum_score,
row_number() over(order by sum(score) DESC) as rn,
rank() over(order by sum(score) DESC) as rk,
dense_rank()over(order by sum(score) DESC) as dr
from scores
GROUP BY id;
输出:
OK
id sum_score rn rk dr
1001 263.0 1 1 1
1002 247.0 2 2 2
1004 247.0 3 2 2
1003 216.0 4 4 3
三者区别一目了然:
| 函数 | 1001 | 1002 | 1004 | 1003 | 规则 |
|---|---|---|---|---|---|
| row_number | 1 | 2 | 3 | 4 | 并列也强行顺排:1,2,3,4 |
| rank | 1 | 2 | 2 | 4 | 并列同号,跳号:1,1,3 |
| dense_rank | 1 | 2 | 2 | 3 | 并列同号,不跳号:1,1,2 |
记忆口诀:rank 并列跳号,dense_rank 并列不跳号,row_number 根本不认并列。
TopN 标准套路:先算排名,再套一层 where rn <= N:
sql
-- 每个学生成绩最高的前2科
select * from (
SELECT id, cid, score,
row_number() over(partition by id order by score DESC) as rn
from scores
) t
where rn <= 2;
两个坑:
- 窗口函数里不能使用 select 里定义的别名 (上面
order by sum(score)不能写成order by sum_score),因为窗口函数和 select 同层求值 - TopN 的 where 过滤必须套在外层 ,不能直接在原查询里
where rn <= 2(where 先于窗口函数执行,此时 rn 还不存在)
六、取值类:lag / lead / first_value / last_value
6.1 lag / lead:取前一行 / 后一行
sql
SELECT
id,
sum(score) as sum_score,
lag(sum_score, 1, 0) over(order by sum(score) DESC) as lag_score,
lead(sum_score, 1, 0) over(order by sum(score) DESC) as lead_score
from scores
GROUP BY id;
输出:
OK
id sum_score lag_score lead_score
1001 263.0 0 247.0 ← 第一行,前面没有了,补默认值0
1002 247.0 263.0 247.0
1004 247.0 247.0 216.0
1003 216.0 247.0 0 ← 最后一行,后面没有了,补0
参数解读(都是可调的):
lag(字段, n, 默认值):取当前行前面第 n 行的值- n:偏移量,默认 1
- 默认值:越界时补什么,可省略 ,省略时补 NULL(上面的 0 是我们手动指定的)
lead同理,取后面的行
典型用途:环比/同比------本月销售额 - lag(上月销售额)。
6.2 first_value / last_value:窗口内第一个 / 最后一个值
sql
SELECT
id, score,
first_value(score) over(partition by id order by score desc) as fv_score
from scores;
OK
id score fv_score
1001 90.0 90.0
1001 88.0 90.0
1001 85.0 90.0
...
first_value 稳定输出每个学生的最高分。
last_value 有个大坑:
sql
SELECT
id, score,
last_value(score) over(partition by id order by score desc) as lv_score
from scores;
输出并不是你想象的"每行都是最低分 85",而是:
OK
id score lv_score
1001 90.0 90.0 ← 等于当前行自己
1001 88.0 88.0 ← 还是当前行自己
1001 85.0 85.0
原因 :加了 order by 后,窗口的默认边界是"从起点到当前行"(不是整个分组),所以 last_value 永远指向当前行。想取"真正的最后一个值",要手动把边界扩到整个窗口:
sql
SELECT
id, score,
last_value(score) over(
partition by id order by score desc
rows between unbounded preceding and unbounded following -- 边界=整个窗口
) as lv_score
from scores;
这样 lv_score 全部输出 85(每个学生的最低分)。记住这个框架写法,面试加分项。
6.3 窗口帧 rows between:累积与滑动的底层机制(面试加分项)
第 4 节的"累积"和 last_value 的坑,其实是同一个机制在起作用------窗口帧(window frame),它规定"参与计算的行到底从哪到哪";对窗口函数执行原理与优化想深挖的话,学界有专门研究可读1:
sql
函数(...) over(
partition by ... order by ...
rows between <起点> and <终点> -- 帧边界,可调
)
| 帧写法 | 计算范围 | 典型用途 |
|---|---|---|
rows between unbounded preceding and current row |
起点 → 当前行(order by 时默认) | 累计销售额(第 4 节) |
rows between unbounded preceding and unbounded following |
整个窗口 | last_value 取真正的末值(6.2 的解法) |
rows between 6 preceding and current row |
前 6 行 → 当前行 | 近 7 天滑动求和 / 移动平均 |
近 7 日滑动求和示例(daily_login 是"日期 + 当日登录数"的汇总表):
sql
select
login_date,
cnt,
sum(cnt) over(order by login_date rows between 6 preceding and current row) as last7_sum
from daily_login;
口诀:不写帧是累积(到当前行),写 n preceding 是滑动(近 n+1 天),unbounded following 是全窗口。

图4:彩色格子是聚合"看得见"的行,黑框是当前行
严谨备注:SQL 标准里默认帧其实是
range(按值界定,并列值同帧),本文场景与rows等价,写rows更直观;面试能说出 rows / range 的区别更是加分。
七、面试题一:行列转换
行列转换分两类:行转列(多行聚合成列)、列转行(一列炸成多行)。第 6 篇的 collect_set / lateral view 在这里合体实战。
7.1 需求
表1(长表)转表2(宽表),再转回来:
表1:name,item,score 表2:name,math,english
张三,数学,33 张三,33,77
张三,英语,77 李四,66,78
李四,数学,66
李四,英语,78
建表灌数:
sql
create table table1(
name STRING,
item STRING,
score DOUBLE
)ROW FORMAT DELIMITED FIELDS TERMINATED BY ','
STORED AS TEXTFILE;
LOAD DATA LOCAL INPATH '/root/sh_code/table1.csv' OVERWRITE INTO TABLE table1;
7.2 行转列:case when + sum
思路:case when 把"数学行"和"英语行"拆成两个通道,再按人求和:
sql
create table table2 as
SELECT
name,
-- 数学通道:是数学就取分数,不是就取0
sum(case when item = '数学' then score else 0 end) as math,
-- 英语通道:同理
sum(case when item = '英语' then score else 0 end) as english
from table1
GROUP BY name;
输出:
OK
name math english
张三 33.0 77.0
李四 66.0 78.0
多几门课就多几个 case when 通道,套路不变。
7.3 列转行:lateral view explode(map)
思路:把每人的 (数学,英语) 打包成 map,用 explode 炸开,key 变科目、value 变分数:
sql
SELECT
name,
item,
score
FROM
table2,
lateral view explode(
map(
'数学',math, -- key=数学, value=33.0/66.0
'英语',english -- key=英语, value=77.0/78.0
)
) T as item,score;
输出(完美还原表1):
OK
name item score
张三 数学 33.0
张三 英语 77.0
李四 数学 66.0
李四 英语 78.0
一图总结:行转列 = case when 聚合;列转行 = lateral view + explode。
八、面试题二:连续登录 N 天
题目:登录日志如下,找出连续登录天数 >= 3 天的用户。
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
01,2021-03-08
02,2021-03-01
02,2021-03-02
02,2021-03-03
02,2021-03-04
02,2021-03-06
03,2021-03-06
sql
create table user_login_log(
user_id STRING,
login_date STRING
)ROW FORMAT DELIMITED FIELDS TERMINATED BY ','
STORED AS TEXTFILE;
LOAD DATA LOCAL INPATH '/root/sh_code/user_login_log.csv' OVERWRITE INTO TABLE user_login_log;
8.1 核心思路:日期减序号 = 连续标记(差值法)
先观察一个规律------连续的日期,减去它排第几,差值是不变的:
| 日期 | 序号 rn | date_sub(日期, rn) |
|---|---|---|
| 03-01 | 1 | 02-28 |
| 03-02 | 2 | 02-28 ← 和上一行相同! |
| 03-04 | 3 | 03-01 ← 断了,差值变了 |
| 03-05 | 4 | 03-01 ← 又相同了 |
"日期 - 序号"相同 = 这一段日期连续。把相同差值分成一组,组内 count 就是连续登录天数。这就是面试官想听到的核心思路。这类"从登录日志里挖行为规律"的分析,也正是用户行为分析系统的典型场景2。

图5:差值相同分一组,组内 count 就是连续天数
8.2 三步实现
第一步:row_number 编序号
sql
SELECT
user_id,
login_date,
row_number() over(partition by user_id order by login_date) as rank
FROM
user_login_log;
输出(节选用户01):
OK
user_id login_date rank
01 2021-02-28 1
01 2021-03-01 2
01 2021-03-02 3
01 2021-03-04 4
01 2021-03-05 5
01 2021-03-06 6
01 2021-03-08 7
第二步:算差值,按差值分组计数
sql
SELECT
user_id,
min(login_date) as min_login_date, -- 连续段开始
max(login_date) as max_login_date, -- 连续段结束
date_sub(login_date, rank) as sub_date,-- 差值(分组依据)
count(1) as num -- 连续天数
FROM(
SELECT
user_id,
login_date,
row_number() over(partition by user_id order by login_date) as rank
FROM
user_login_log
) as a
GROUP BY user_id, date_sub(login_date, rank);
输出:
OK
user_id min_login_date max_login_date sub_date num
01 2021-02-28 2021-02-28 2021-02-27 1
01 2021-03-01 2021-03-02 2021-02-28 2
01 2021-03-04 2021-03-06 2021-03-01 3 ← 连续3天!
01 2021-03-08 2021-03-08 2021-03-02 1
02 2021-03-01 2021-03-03 2021-02-28 3 ← 连续3天!
02 2021-03-04 2021-03-04 2021-03-01 1
02 2021-03-06 2021-03-06 2021-03-02 1
03 2021-03-06 2021-03-06 2021-03-05 1
第三步:having 过滤 >= 3 天
sql
SELECT
user_id,
min(login_date) as min_login_date,
max(login_date) as max_login_date,
count(1) as num
FROM(
SELECT
user_id,
login_date,
row_number() over(partition by user_id order by login_date) as rank
FROM
user_login_log
) as a
GROUP BY user_id, date_sub(login_date, rank)
HAVING num >= 3;
最终输出:
OK
user_id min_login_date max_login_date num
01 2021-03-04 2021-03-06 3
02 2021-03-01 2021-03-03 3
口诀:连续问题先排序,日期减序号,差值同组即连续。 这套"差值分组"框架换个壳就能解"连续 N 天活跃""连续 N 月消费"等一大家子题。
面试官追问(实战变式):如果日志里同一用户同一天有多条登录记录怎么办?
答:先去重、再编号 。差值法的前提是"每个日期只出现一次"------若同一天有两条记录,row_number 会给它们编上不同序号,date_sub 就会算出两个不同差值,同一天的记录被硬拆进不同"连续段",天数直接算错。解法:内层先按
user_id, login_date去重(group by或select distinct),再套三步框架。这一问考察的是你知不知道框架的适用边界。
九、总结
- 窗口函数 = 在不压缩行数的前提下,给每行附加"所在窗口"的计算结果;与 group by 的区别是"贴批注 vs 压扁"
- 语法:
函数() over(partition by 分组 order by 排序),两个子句都可省 - 四类函数 + 一个机制:
- 聚合类:sum/count/max/avg(+order by 变累积)
- 排序类:row_number 不认并列、rank 跳号、dense_rank 不跳号
- 取值类:lag/lead(环比)、first_value/last_value(注意默认窗口边界坑)
- 底层机制:窗口帧 rows between------不写帧=累积,写 6 preceding=近 7 天滑动,unbounded following=全窗口
- 面试三件套 :
- 行列转换:case when 聚合 ↔ lateral view explode
- 连续登录:row_number + date_sub 差值分组(实战变式:先去重再编号)
- TopN:row_number 排名后外层 where rn <= N
- 至此 SQL 核心武器全部集齐。实战中再配合分区表(少扫数据)+ ORC(读得少压得小),就是一套完整的 Hive 数仓基本功