ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Hive SQL面试高频题解析:窗口函数、连续登录与数据倾斜实战

Hive SQL面试高频题解析:窗口函数、连续登录与数据倾斜实战

1. 为什么2021年的Hive SQL面试题至今还在被反复问

先抛一个我自己的观察:这两年帮团队筛简历、做技术面,发现一个挺有意思的现象——很多候选人简历上写着“精通Hive数仓”,结果一问窗口函数就卡壳,一让写连续登录就露馅。2021年前后那批Hive SQL面试题,放到今天依然高频出现,原因其实不复杂:数仓这套东西的底层逻辑没怎么变。存储换了、引擎换了、调度换了,但“怎么把业务问题翻译成SQL”这个核心能力,面试官永远在考。

我自己是从传统数仓一路做到现在的湖仓架构,面过的人没有一百也有八十。说句实在话,Hive SQL面试题看着老,但它考的东西特别扎实:窗口函数、行列转换、连续区间、TopN、去重计数、数据倾斜,这几类题几乎覆盖了数仓日常开发的八成场景。你把这几类吃透,不光面试能过,真到了写生产任务的时候也会发现,翻来覆去就是这些套路在变体。

这篇内容我打算按“面试官到底想考什么”这个角度来拆,而不是干巴巴地列题目和答案。因为我自己当面试官的时候,最烦的就是候选人背答案——题目稍微一变就不会了。所以下面每一类题,我都会先讲它背后的业务场景是什么,再讲解题的通用思路,最后给可以直接跑的SQL。适合准备面试的同学,也适合已经工作但想系统梳理一遍SQL能力的朋友。

提示:下面所有SQL都基于Hive的语法习惯来写,部分函数在Spark SQL、Presto里也能用,但细节会有差异,实际跑之前建议先确认引擎版本。

2. 窗口函数:面试出现频率最高的一块硬骨头

2.1 为什么面试官偏爱窗口函数

窗口函数是Hive SQL面试的绝对核心,没有之一。我统计过自己出过的题,十道里有六道会用到窗口函数。原因很简单:它同时考察了你对分组、排序、行间关系的理解。一个只会group by的人,遇到“每个用户最近一次登录”这种需求就懵了,而窗口函数能优雅地解决。

很多人对窗口函数的理解停留在“row_number()排个序”,这远远不够。真正要掌握的是三类:

  • 排名类:row_number()、rank()、dense_rank()
  • 聚合类:sum() over()、avg() over()、count() over()
  • 偏移类:lag()、lead()、first_value()、last_value()

这三类的区别和适用场景,下面用具体题目来说。

2.2 排名三兄弟:row_number、rank、dense_rank到底怎么选

这是最经典的送分题,也是最容易翻车的题。我见过太多人张口就说“都是排名”,结果一问并列情况就答不上来。

假设有一张成绩表score_table,字段是student_id、class_id、score。现在要按班级给成绩排名,三个函数的输出是这样的:

student_idscorerow_numberrankdense_rank
A95111
B95211
C90332
D85443

规律很清楚:

  • row_number():强行不并列,哪怕值一样也给你编上1、2、3、4
  • rank():并列后跳号,两个95都是第1,下一个直接跳到第3
  • dense_rank():并列后不跳号,两个95都是第1,下一个是第2

那实际业务里怎么选?我的经验是:

  • 要取每组TopN且不允许重复(比如抽奖、去重取一条),用row_number()
  • 要排名次且允许并列(比如成绩榜、销售榜),用rank()或dense_rank()
  • 具体用哪个,看业务方要不要“跳号”。一般排行榜用dense_rank()更符合直觉
SELECT student_id, class_id, score, row_number() OVER (PARTITION BY class_id ORDER BY score DESC) AS rn, rank() OVER (PARTITION BY class_id ORDER BY score DESC) AS rk, dense_rank() OVER (PARTITION BY class_id ORDER BY score DESC) AS drk FROM score_table;

注意:PARTITION BY后面跟的是分组字段,ORDER BY后面跟的是排序字段,这两个写反了结果就完全错了。我面过的人里,至少三成在这里栽过。

2.3 偏移函数:lag和lead解决“和上一行比较”的问题

lag()和lead()是处理同比、环比、连续行为的利器。它们的含义是:取当前行往前(lag)或往后(lead)第N行的值。

举个真实场景:有一张用户访问日志表visit_log,字段是user_id、visit_date。现在要找出连续两天都访问的用户。

思路是这样的:先用lag()把每个用户上一次的访问日期取出来,然后判断“当前日期 - 上一次日期 = 1天”。

SELECT user_id, visit_date, lag(visit_date, 1) OVER (PARTITION BY user_id ORDER BY visit_date) AS prev_date FROM visit_log;

拿到prev_date之后,用datediff一减,等于1的就是连续两天。

SELECT DISTINCT user_id FROM ( SELECT user_id, visit_date, lag(visit_date, 1) OVER (PARTITION BY user_id ORDER BY visit_date) AS prev_date FROM visit_log ) t WHERE datediff(visit_date, prev_date) = 1;

这个套路可以无限扩展:连续三天、连续N天、连续登录的最大天数,都是在这个基础上加条件或加聚合。核心就一句话:用lag把上一行拉过来,然后做差判断。

2.4 窗口帧:rows between到底在框什么

窗口帧(window frame)是很多人忽略的知识点,但面试官一旦问到“累计求和”“滑动平均”,不会窗口帧就写不出来。

默认情况下,如果ORDER BY存在,窗口帧是RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,也就是从第一行到当前行。这就是为什么sum() over(order by ...)能实现累计求和。

但如果你要算“最近3天的滑动平均”,就得显式指定ROWS BETWEEN 2 PRECEDING AND CURRENT ROW。

SELECT dt, amount, sum(amount) OVER (ORDER BY dt ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cumulative_sum, avg(amount) OVER (ORDER BY dt ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg_3d FROM daily_sales;

这里有个坑要提醒:ROWS和RANGE的区别。ROWS是按物理行数算,RANGE是按值算。如果排序字段有重复值,RANGE会把相同值的行都算进去,结果可能和你想的不一样。日常开发我基本都用ROWS,因为它的行为更可预测。

3. 连续区间问题:从“连续登录”到“连续编号”的通用解法

3.1 连续登录问题的本质是什么

连续登录是Hive SQL面试的“常青树”,2021年考,现在还在考。但很多人只会背那一种写法,题目一变就废。我先讲清楚它的本质。

连续登录的本质是:给连续的行编上相同的组号。怎么编?用“日期减去排名”这个技巧。

假设用户A在1月1、2、3、5、6号登录。我们按日期排序,给每行编个号:

visit_daterndate - rn
2021-01-0112020-12-31
2021-01-0222020-12-31
2021-01-0332020-12-31
2021-01-0542021-01-01
2021-01-0652021-01-01

看出来了吗?连续的日期减去连续的编号,差值是一样的。1、2、3号减完都是2020-12-31,5、6号减完都是2021-01-01。这个差值就是天然的“连续组号”。

3.2 完整SQL与逐步拆解

SELECT user_id, min(visit_date) AS start_date, max(visit_date) AS end_date, count(1) AS continuous_days FROM ( SELECT user_id, visit_date, date_sub(visit_date, row_number() OVER (PARTITION BY user_id ORDER BY visit_date)) AS group_id FROM ( SELECT DISTINCT user_id, visit_date FROM visit_log ) a ) b GROUP BY user_id, group_id HAVING count(1) >= 3;

拆解一下:

  1. 最内层DISTINCT:去重。同一天多次登录只算一次,这一步很多人漏掉,导致结果偏大
  2. 中间层:用date_sub减去row_number,得到group_id
  3. 最外层:按user_id和group_id分组,count就是连续天数

提示:date_sub是Hive的日期函数,第一个参数是日期,第二个是减去的天数。如果你用的是Spark SQL,也可以用date_sub,但要注意日期格式必须是yyyy-MM-dd。

3.3 举一反三:连续编号、连续区间都能套

这个“减去排名”的套路,不止能解连续登录。我举几个变体:

  • 连续编号问题:给一串数字,找出连续递增的区间。思路一样,用数字 - row_number,差值相同的归为一组
  • 连续空位问题:找出座位号里连续的空位。同样处理
  • 连续达标问题:连续N天销售额超过目标。把日期换成业务日期,逻辑不变

我面试的时候特别喜欢在这个题上追问:“如果要求连续登录的最大天数呢?”这时候你只需要把上面的结果再套一层max:

SELECT user_id, max(continuous_days) AS max_continuous_days FROM ( -- 上面的查询 ) t GROUP BY user_id;

能答出这一层,面试官基本就认可你的SQL思维了。

4. 行列转换:collect_list、collect_set和explode的组合拳

4.1 行转列:把多行合并成一行

行转列的需求在数仓里太常见了。比如用户标签表,一个用户有多个标签,每个标签一行,现在要合并成“标签1,标签2,标签3”这种形式。

Hive里用collect_list或collect_set配合concat_ws:

SELECT user_id, concat_ws(',', collect_list(tag)) AS tags FROM user_tag GROUP BY user_id;

collect_list和collect_set的区别:

  • collect_list:保留重复,按出现顺序收集
  • collect_set:去重,但不保证顺序

选哪个看业务。如果标签本身不该重复,用collect_set更安全。如果要保留顺序,用collect_list,但要注意Hive里collect_list的顺序在分布式环境下不保证稳定,这是个坑。

注意:collect_list在大数据量下可能造成单行过大,甚至OOM。如果标签特别多,建议先做限制或者分批处理。

4.2 列转行:explode把一行炸成多行

反过来,如果一张表里存的是“标签1,标签2,标签3”这样的字符串,要拆成多行,就用explode配合split:

SELECT user_id, tag FROM user_tag_concat LATERAL VIEW explode(split(tags, ',')) t AS tag;

LATERAL VIEW是Hive特有的写法,作用是把炸裂出来的虚拟表跟原表做笛卡尔积。explode负责把数组炸成多行,split负责把字符串切成数组。

这里有个细节:如果tags是空字符串或者null,explode会直接把这行丢掉。如果你希望保留空值,得用explode_outer或者先做coalesce处理。

4.3 一个综合案例:用户行为路径分析

把行转列和列转行结合起来,能解决很复杂的路径分析问题。比如有一张用户行为表,记录每个用户的操作序列,现在要分析“访问首页 -> 搜索 -> 下单”这条路径的转化率。

思路是:先用collect_list按时间顺序把行为拼成数组,再用concat_ws拼成字符串,最后用like或正则去匹配路径。

SELECT count(1) AS total_users, sum(if(path like '%首页%搜索%下单%', 1, 0)) AS converted_users FROM ( SELECT user_id, concat_ws('>', collect_list(action)) AS path FROM ( SELECT user_id, action, action_time FROM user_behavior ORDER BY user_id, action_time ) a GROUP BY user_id ) b;

这个写法在数据量不大的时候很好用,但数据量大了之后collect_list会成为瓶颈。生产环境我一般会用窗口函数或者专门的路径分析工具来做。

5. TopN与去重计数:面试官最爱追问的两个点

5.1 分组TopN:为什么不能直接用limit

“每个部门工资最高的3个人”这种题,新手最容易写成:

SELECT * FROM employee ORDER BY salary DESC LIMIT 3;

这是全局TopN,不是分组TopN。正确的做法是用窗口函数:

SELECT * FROM ( SELECT emp_id, dept_id, salary, row_number() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn FROM employee ) t WHERE rn <= 3;

面试官在这里通常会追问:“如果同一个人有两条记录怎么办?”这时候就要考虑用dense_rank还是先去重。分组TopN的核心是PARTITION BY,把分组字段想清楚,题目就解了一半。

5.2 去重计数:count distinct的坑和优化

count(distinct)是数仓里最常用的操作之一,但它有个大问题:数据倾斜。当某个值特别多的时候,所有数据会集中到一个reduce上,跑得特别慢。

优化思路是用group by改写:

-- 原始写法,容易倾斜 SELECT count(distinct user_id) FROM visit_log; -- 优化写法,先分组再去重 SELECT count(1) FROM ( SELECT user_id FROM visit_log GROUP BY user_id ) t;

这两种写法结果一样,但后者在数据量大时通常更快,因为它把去重的工作分散到了多个reduce上。

还有一个更高级的技巧:如果只是要近似去重计数,可以用approx_distinct(Presto)或者HyperLogLog相关的函数,牺牲一点精度换性能。这个在面试里说出来是加分项。

5.3 面试追问:如何求中位数

中位数是TopN的变体,也是高频题。Hive里没有现成的中位数函数(老版本),需要用窗口函数算。

思路:先给每行编号,再根据总行数确定中位数的位置。

SELECT avg(salary) AS median_salary FROM ( SELECT salary, row_number() OVER (ORDER BY salary) AS rn, count(1) OVER () AS total FROM employee ) t WHERE rn IN (floor((total + 1) / 2), ceil((total + 1) / 2));

这个写法的巧妙之处在于:奇数行时两个位置重合,偶数行时取中间两个的平均。floor和ceil配合,一个SQL搞定两种情况。

6. 数据倾斜:面试里区分“会用”和“懂原理”的分水岭

6.1 数据倾斜到底是怎么产生的

数据倾斜是Hive面试的“压轴题”,也是实际工作中最头疼的问题。它的本质是:在shuffle阶段,某些key的数据量远大于其他key,导致个别reduce任务跑得特别慢。

举个典型场景:一张订单表,90%的订单来自同一个大客户,剩下10%分散在几千个小客户。你按客户ID做group by,那个大客户的数据全跑到一个reduce上,其他reduce早就跑完了,就它还在那儿转。

面试官问数据倾斜,其实是想看你有没有真正处理过生产问题。光背“加盐”“打散”这些词没用,得说清楚什么场景用什么方案。

6.2 常见倾斜场景与对应方案

我把常见的倾斜场景和方案整理成一张表:

场景表现解决方案
group by倾斜某个key数据量过大两阶段聚合(加随机前缀)
join倾斜大表join小表MapJoin(小表广播)
join倾斜大表join大表,key分布不均加盐打散后join
count distinct倾斜单key去重先group by再去重
空值倾斜null值集中过滤null或给null加随机值

两阶段聚合是最常用的方案,思路是:先给key加上随机前缀做一次聚合,再去掉前缀做第二次聚合。

-- 第一阶段:加随机前缀 SELECT concat(key, '_', cast(rand() * 10 as int)) AS salted_key, sum(value) AS partial_sum FROM source_table GROUP BY concat(key, '_', cast(rand() * 10 as int)); -- 第二阶段:去掉前缀再聚合 SELECT split(salted_key, '_')[0] AS key, sum(partial_sum) AS total_sum FROM stage_one GROUP BY split(salted_key, '_')[0];

这个方案的核心是把一个大key拆成10个小key,让数据分散到多个reduce上,最后再合并。

6.3 MapJoin:小表广播的正确打开方式

如果倾斜发生在join阶段,而且其中一张表比较小(比如几万行),用MapJoin是最优解。原理是把小表加载到每个map节点的内存里,在map阶段就完成join,避免了shuffle。

Hive里开启MapJoin有两种方式:

-- 方式一:hint SELECT /*+ MAPJOIN(small_table) */ * FROM big_table JOIN small_table ON big_table.id = small_table.id; -- 方式二:设置参数 SET hive.auto.convert.join = true; SET hive.mapjoin.smalltable.filesize = 25000000; -- 25MB

注意:MapJoin的小表不能太大,否则会撑爆内存。一般建议小表在25MB以内,具体看集群配置。如果小表超过这个大小,还是老老实实用加盐打散。

6.4 我踩过的坑:倾斜不一定是数据问题

说个我自己的教训。有一次线上任务跑得特别慢,我第一反应是数据倾斜,查了半天发现是join的key类型不一致——一边是string,一边是bigint,导致Hive没法用MapJoin,只能走普通的shuffle join。改完类型之后,任务从40分钟降到3分钟。

所以遇到慢任务,先别急着加盐,先看执行计划(explain),确认瓶颈到底在哪。很多时候问题出在类型、分区裁剪、小文件这些地方,跟倾斜没关系。

7. 面试之外:这些SQL能力在实际工作中怎么用

7.1 从面试题到生产任务的思维转换

面试题是“给定表结构,写出结果”,生产任务是“给定业务需求,设计表结构和SQL”。这两者之间有个鸿沟,很多人面试过了,到了实际工作还是不会写。

我的经验是:把每个业务需求先翻译成“输入是什么、输出是什么、中间要经过几步”。比如“统计每个用户的连续登录天数”,输入是日志表,输出是用户和天数,中间要经过去重、编号、分组三步。想清楚这三步,SQL自然就出来了。

另外,生产环境还要考虑性能、可维护性、数据质量。面试题只要求结果对,生产任务还要求跑得快、别人看得懂、异常能处理。这些是面试不会考但工作天天用的能力。

7.2 几个我常用的SQL优化习惯

分享几个我写SQL时雷打不动的习惯:

  • 先过滤再join:能提前过滤的数据绝不带到join里,减少shuffle量
  • 分区裁剪:where条件里一定带上分区字段,避免全表扫描
  • 小表放右边:虽然Hive会自动优化,但显式写清楚更保险
  • 避免select *:只取需要的列,减少IO
  • 用explain看计划:写完复杂SQL先explain一下,看看有没有笛卡尔积、有没有走MapJoin

这些习惯看着简单,但坚持下来能让你的任务快不少。我带过的几个新人,改掉select *和漏分区这两个毛病之后,任务耗时普遍降了三成以上。

7.3 关于刷题的一点个人看法

最后说点掏心窝的话。现在网上Hive SQL面试题一搜一大把,很多人就是刷题背答案。我不反对刷题,但刷题的目的是建立思维模式,不是记住答案。

我的建议是:每做一道题,问自己三个问题——这道题考的是什么知识点?如果条件变一下还能不能解?有没有更优的写法?把这三个问题想清楚,一道题顶十道。反过来,如果只是把答案抄一遍,遇到变体照样懵。

数仓这个方向,SQL是基本功,但基本功之上还有建模、调度、数据治理、性能调优一大堆东西。面试题只是敲门砖,真正决定你能走多远的,是解决实际问题的能力。我自己到现在还在不断学新东西,因为数据量在涨、引擎在换、业务在变,没有一劳永逸的事。

如果你正在准备面试,把上面这几类题吃透,基本就够用了。如果你已经工作了,不妨拿这些题当镜子,照照自己哪块还有短板。SQL这东西,写多了自然就有感觉了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进