首页 / 资讯中心 / 文章详情

Hive SQL实战:窗口函数实现用户累积访问次数

Hive SQL实战:窗口函数实现用户累积访问次数 ★ FEATURED ARTICLE
做数据开发这几年Hive SQL求每个用户的累积访问次数是我见过出镜率最高的需求之一面试题里有它实际报表需求里也有它。我第一次真刀真枪碰到这个场景是刚接手用户增长报表的时候产品扔过来一张模板要按天看每个注册用户的累计访问次数曲线底层表几千万行。我当时的SQL水平还停留在MySQL那套思维里第一反应是自连接加GROUP BY写完一跑半个多小时出不来最后被运维打电话催着kill任务。后来改成窗口函数几十秒就出了结果那一瞬间我才意识到同一个需求写法和写法之间的差距能有多大。这篇文章我就把这道题完完整整拆一遍从需求怎么理解、表怎么建、数据怎么造到窗口函数和自连接两种实现再到生产环境的各种坑一次性讲透。无论你是准备面试还是在写用户行为分析报表按这个思路走基本不会出问题。1. 题目拆解这个“累积”到底在算什么1.1 需求背后的业务含义题目是“hive sql每个用户的累积访问次数”看起来只是一句话但拆开看有三个关键词用户、累积、访问次数。用户说明分组维度是用户级别通常是user_id这一列。访问次数单次访问产生的计数可能是某一天某用户的访问次数也可能是每行一条访问日志。累积这是核心表示按某个顺序一般是时间顺序把当前行及之前所有行的访问次数加起来形成一个“到当前为止的总数”。对应到业务上最常见的就是用户活跃分析。比如产品想看新用户从注册第一天开始每天的累计活跃天数或者累计访问次数用来判断用户粘性和留存趋势。如果表里已经是“用户日期当天访问次数”的粒度那累积就很好算直接按用户分组、按日期排序对访问次数做累计求和。如果表里是“一行一条访问日志”的明细粒度比如每次点击、每次打开App都记一行那先要按用户和日期做聚合COUNT(*)出每天访问次数再做累计。这两种场景对应两种写法后面我会分别给出来。1.2 为什么这道题容易考也容易挂这道题之所以常被拿来当面试题是因为它一个点就能测出你对窗口函数的真实掌握程度。很多人知道SUM()配合OVER()用但一到细节就翻车窗口框架没指定默认行为搞不清楚PARTITION BY和ORDER BY写反排序字段有重复值导致结果和预期不符。这些问题在真实数据上都会暴露。从另一个角度说这个需求也是很多复杂SQL的基础。比如计算累计UV、累计GMV、累计订单量逻辑都是同一个套路只是换了个加和的字段。把这道题吃透等于把这一类累计问题都吃透了。2. 建表造数先把数据环境准备好2.1 两种常见的数据粒度动手写SQL之前得先搞清楚表里的数据长什么样。实际工作中大概率遇到的是这两种粒度一用户-日期-访问次数聚合表这种表已经按用户和日期做了聚合每行是某用户某天的访问次数字段大概是user_id、visit_date、visit_cnt。粒度二访问明细表这种表每行是一次访问记录比如user_id、visit_time、page_url一天内同一个用户可能有很多行。这里我先用聚合表来演示因为逻辑最直观看完你能直接迁移到明细表场景。明细表的处理我放在后面的变体部分单独讲。2.2 在Hive里建表和造测试数据假设我们要分析一批用户的访问数据表结构如下CREATE TABLE IF NOT EXISTS user_visit ( user_id STRING COMMENT 用户ID, visit_date STRING COMMENT 访问日期格式yyyy-MM-dd, visit_cnt INT COMMENT 当天访问次数 ) COMMENT 用户每日访问次数表 PARTITIONED BY (dt STRING COMMENT 分区字段) STORED AS ORC;分区字段我习惯用dt按天分区这样后续按日期过滤时可以直接裁剪分区避免全表扫描。造测试数据的时候直接用INSERT INTO手动插几行INSERT INTO TABLE user_visit PARTITION (dt2024-01-06) VALUES (u001, 2024-01-01, 3), (u001, 2024-01-02, 5), (u001, 2024-01-03, 2), (u002, 2024-01-01, 1), (u002, 2024-01-02, 4), (u002, 2024-01-03, 6);从Hive 3.0开始VALUES语法可以用了但生产上很少这么造数大多是从业务表INSERT OVERWRITE刷出来。测试阶段这样写没问题关键是数据里面要埋几个“坑”不同用户的访问日期不一样所以不能按行号硬推。同一个用户的多行数据日期是递增的。有的用户可能中间某天没访问也就是缺行这一点很容易被忽略。这些坑后面都会用到先留着。2.3 先看下原始数据长什么样写完表习惯性跑一下SELECT user_id, visit_date, visit_cnt FROM user_visit WHERE dt 2024-01-06 ORDER BY user_id, visit_date;结果user_idvisit_datevisit_cntu0012024-01-013u0012024-01-025u0012024-01-032u0022024-01-011u0022024-01-024u0022024-01-036目标就是得到这样的累积列user_idvisit_datevisit_cntcum_visit_cntu0012024-01-0133u0012024-01-0258u0012024-01-03210u0022024-01-0111u0022024-01-0245u0022024-01-03611看清楚这个目标后面所有写法都是往这个结果上靠。3. 核心实现窗口函数一把梭3.1 完整SQL与执行结果窗口函数是解决累计类问题最自然的方式直接上SQLSELECT user_id, visit_date, visit_cnt, SUM(visit_cnt) OVER (PARTITION BY user_id ORDER BY visit_date) AS cum_visit_cnt FROM user_visit WHERE dt 2024-01-06 ORDER BY user_id, visit_date;执行结果user_idvisit_datevisit_cntcum_visit_cntu0012024-01-0133u0012024-01-0258u0012024-01-03210u0022024-01-0111u0022024-01-0245u0022024-01-03611没错就一个SUM OVER搞定。但这段代码背后的语义很多人其实没完全搞懂。3.2 窗口函数的执行逻辑拆解我把这段SQL拆成人话讲PARTITION BY user_id按用户分组。每个用户的数据单独计算不同用户互不干扰。这个分区的含义是“把每个用户的数据切到一个独立计算空间里”。ORDER BY visit_date组内按访问日期排序。排序是累计的基础没有顺序就没有“累积”这一说。默认窗口框架从分区内的第一行到当前行。所以每到一个新行SUM(visit_cnt)就是把从第一天到当前天的所有访问次数加起来。计算过程很好理解u001第一行3累计就是3。u001第二行5累计是358。u001第三行2累计是35210。这里再强调一遍默认窗口框架。Hive和很多SQL引擎一样当OVER里同时有PARTITION BY和ORDER BY时默认窗口范围是RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW也就是从分区起点到当前行。理解这一点后面遇到排序字段重复值导致结果“不正常”时你才知道问题出在哪。3.3 细粒度表怎么直接用窗口函数如果原始表是访问明细表即每行是一次访问记录那就不能直接对原始行做SUM先聚合再累计SELECT user_id, visit_date, cnt AS visit_cnt, SUM(cnt) OVER (PARTITION BY user_id ORDER BY visit_date) AS cum_visit_cnt FROM ( SELECT user_id, to_date(visit_time) AS visit_date, COUNT(*) AS cnt FROM user_visit_detail WHERE dt 2024-01-06 GROUP BY user_id, to_date(visit_time) ) t ORDER BY user_id, visit_date;核心逻辑没变只是先用内层子查询把明细压成“用户日期次数”的粒度再在外面做累积。4. 不用窗口函数自连接方案的原理与局限4.1 自连接怎么实现累计窗口函数是后来才有的老版本Hive里没有这些函数时经典写法是自连接加GROUP BY。思路是把每一行和它之前的所有行连起来然后对连接结果做分组求和。以u001的三行数据为例要实现2024-01-02的累计值就要找所有visit_date 2024-01-02的行加起来。SQL写成这样SELECT a.user_id, a.visit_date, a.visit_cnt, SUM(b.visit_cnt) AS cum_visit_cnt FROM user_visit a JOIN user_visit b ON a.user_id b.user_id AND b.visit_date a.visit_date WHERE a.dt 2024-01-06 AND b.dt 2024-01-06 GROUP BY a.user_id, a.visit_date, a.visit_cnt ORDER BY a.user_id, a.visit_date;这个SQL在逻辑上完全正确对于每条a记录把同一个用户且日期小于等于它自己的b记录都连上然后求和。我最早就是这样的写法在一个几千万行的表上跑直接翻车。4.2 自连接的性能灾难现场自连接看着逻辑简单实际执行代价非常大。问题在于b.visit_date a.visit_date不是等值连接Hive对不等值连接的处理方式是Nested Loop也就是每一行a都要去扫描几乎整个b表这是一个典型的笛卡尔积导致的数据爆炸。假设一个用户有30天访问记录自连接会产生30*30/2450行中间结果。如果全表有1000万用户、平均每人30条记录中间结果就是45亿行。这种SQL跑在集群上MapReduce或Spark任务会疯狂shuffle轻则跑几十分钟重则直接把某个reduce节点打到OOM。所以我的建议是在新版Hive里能上窗口函数就上窗口函数自连接方案只适合拿来当面试题讲原理实际生产不推荐。5. 生产环境必踩的坑与优化手段5.1 排序字段有重复值时结果“不对”这是窗口函数踩坑频率最高的一类。假设同一天内有两条访问记录比如INSERT INTO TABLE user_visit PARTITION (dt2024-01-06) VALUES (u001, 2024-01-02, 2), (u001, 2024-01-02, 3);再看默认窗口框架。RANGE BETWEEN ... CURRENT ROW的定义是和当前行在ORDER BY列上值相等的所有行都会包含在窗口里。所以当排序字段visit_date有重复时SUM会把所有同日期行一次性加进去而不是逐行累加。这就导致结果变成user_idvisit_datevisit_cntcum_visit_cntu0012024-01-0225u0012024-01-0235第一行的累计不是2而是235。从业务角度说“同一天的所有访问次数本来就应该一起累计”这个逻辑不算错但如果你希望每一行单独算就要把窗口改成行类型SUM(visit_cnt) OVER ( PARTITION BY user_id ORDER BY visit_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cum_visit_cntROWS是物理行不管排序字段是否重复每行只取到这一行为止。做累积计算时我一般直接写ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW避免RANGE语义带来的意外。5.2 NULL值会把累计结果带偏如果visit_cnt字段存在NULLSUM会忽略NULL但累计逻辑可能不符合预期。比如某天访问次数是NULL表示当天没有访问记录那累计值应该保持之前的总数不动SUM天然满足这一点。但如果你是先做明细统计再计算累计要小心COUNT(*)和COUNT(visit_cnt)的区别。COUNT(*)统计行数包括NULL行COUNT(visit_cnt)只统计非NULL值。按天统计访问次数时应该用COUNT(visit_cnt)或者COUNT(*)具体取决于NULL代表的业务含义。5.3 分区裁剪先缩数据再计算生产表动辄几亿行如果SQL不写分区条件直接全表扫再好的窗口函数也救不回来。前面建表时我用了dt分区查询时一定要带WHERE dt 2024-01-01 AND dt 2024-01-31只扫需要的时间范围这比任何SQL优化都管用。遇到跨月累计需求时分析窗口要包含起始月之前的数据否则累计值会从0开始和业务预期不符。5.4 数据倾斜怎么处理和判断如果一个超级活跃用户访问数据量巨大在按用户分区的窗口计算中单个reduce很可能成为瓶颈。Hive的窗口函数实现中同一个PARTITION BY键的数据会分到同一个reduce处理热门用户数据量过大就会出现长尾。判断数据倾斜先数一下每个用户的行数分布SELECT user_id, COUNT(*) AS cnt FROM user_visit WHERE dt 2024-01-06 GROUP BY user_id ORDER BY cnt DESC LIMIT 20;如果某一两个用户的行数比其他用户高出几个数量级就要做针对性处理。常见做法有两种一是热点用户单独计算后UNION ALL合并二是给热点用户加随机前缀打散算完再去掉前缀。前者简单可控后者适合数据量极大的场景。我之前遇到过一个大V用户的访问日志比全站其他用户加起来还多单用户跑了十几分钟全站任务被它拖垮。最终方案是把热点用户拆出来单独调度主任务卡在凌晨并发低时跑才算解决。6. 常见变体从“累计访问次数”到“累计活跃天数”6.1 只有访问明细时怎么按人按天去重如果表是访问明细一个用户一天内访问多次现在要算“累计有几天访问过”而不是“累计访问次数”计算逻辑就从SUM变成了“按天去重之后计数再做累计”。Hive里的解法是窗口函数配合COUNT(DISTINCT)SELECT user_id, visit_date, COUNT(DISTINCT visit_date) OVER (PARTITION BY user_id ORDER BY visit_date) AS cum_access_days FROM user_visit_detail WHERE dt 2024-01-06;不过这里有一个大坑COUNT(DISTINCT visit_date) OVER (...)在Hive的某些版本里不支持或者执行效率很低。我实测过Hive 2.x到3.x对这个语法支持都不稳定。稳妥做法是分两步走第一步内层先对明细去重成“用户日期”的唯一组合SELECT user_id, visit_date FROM user_visit_detail WHERE dt 2024-01-06 GROUP BY user_id, visit_date第二步外层再窗口函数累计计数SELECT user_id, visit_date, COUNT(*) OVER (PARTITION BY user_id ORDER BY visit_date) AS cum_access_days FROM ( SELECT user_id, visit_date FROM user_visit_detail WHERE dt 2024-01-06 GROUP BY user_id, visit_date ) t ORDER BY user_id, visit_date;这样既避免COUNT(DISTINCT ...) OVER的不确定性逻辑也更清晰。6.2 跨月重置累计按月窗口有些业务要求累计数按自然月重置也就是1月1日重新从0开始累计。这种需求只需把窗口函数的分区改成“用户月份”SELECT user_id, visit_date, visit_cnt, SUM(visit_cnt) OVER ( PARTITION BY user_id, substr(visit_date, 1, 7) ORDER BY visit_date ) AS month_cum_visit_cnt FROM user_visit WHERE dt 2024-01-06 ORDER BY user_id, visit_date;substr(visit_date, 1, 7)提取yyyy-MM部分作为月分区。注意如果数据是跨年的这个逻辑不会自动重置年份比如2023年1月和2024年1月会混在一起。要按年重置就在分区里加上年份字段或者用substr(visit_date, 1, 7)本身已经包含年份所以1月和1月会按不同年月分区。6.3 指定时间窗口的累计最近7天累计有的需求不是从第一天累计而是“最近7天累计”窗口框架就要用ROWS限定行数SELECT user_id, visit_date, visit_cnt, SUM(visit_cnt) OVER ( PARTITION BY user_id ORDER BY visit_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW ) AS last_7d_cnt FROM user_visit WHERE dt 2024-01-06 ORDER BY user_id, visit_date;这个案例适合做用户短期活跃度变化分析。需要注意的是如果数据不是按天连续存的ROWS BETWEEN 6 PRECEDING AND CURRENT ROW取的是物理行数“6行”不是自然意义上的7天。如果必须按“自然日期7天”来约束需要先补日期序列或者用RANGE配合时间字段实现。6.4 配合行转列把累计结果展开成一列趋势很多报表希望把每位用户的累计次数展示成一行比如列名是day1_cum、day2_cum、day3_cum这就涉及行转列。Hive里的经典做法是SUM(CASE WHEN ...)配合聚合SELECT user_id, SUM(CASE WHEN visit_date 2024-01-01 THEN cum_visit_cnt ELSE 0 END) AS day1_cum, SUM(CASE WHEN visit_date 2024-01-02 THEN cum_visit_cnt ELSE 0 END) AS day2_cum, SUM(CASE WHEN visit_date 2024-01-03 THEN cum_visit_cnt ELSE 0 END) AS day3_cum FROM ( SELECT user_id, visit_date, SUM(visit_cnt) OVER (PARTITION BY user_id ORDER BY visit_date) AS cum_visit_cnt FROM user_visit WHERE dt 2024-01-06 ) t GROUP BY user_id;这种写法把“纵向往”的累计记录变成了一行横向趋势在报表前端非常好用但列数是固定的。如果日期范围是动态的就得用collect_list加concat_ws来拼JSON或字符串那是另一个话题了。7. 常见问题与排查技巧实录7.1 报错或结果不对先按这个顺序排查我在群里帮人看过无数道类似SQL出问题基本逃不出这几种。整理成一张速查表现象可能原因排查和解决结果只有当天值没有累计OVER里忘记写ORDER BY加ORDER BY visit_date没有排序就没有累计语义不同用户的数据串了PARTITION BY写错或漏写确认每个用户一个分区PARTITION BY user_id同一天有多行累计值提前“跳”默认RANGE窗口框架把所有同值行一起纳入加ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW明细表直接跑出来结果巨大没先聚合每行明细都参与SUM先按天COUNT再在外层做累计跑很久不出结果缺分区条件全表扫描或自连接数据爆炸检查WHERE dt分区裁剪确认使用窗口函数而非自连接日期字段是字符串排序不对字符串排序和日期排序不一致用to_date()或确保yyyy-MM-dd格式可字符串排序7.2 几个能救命的小习惯最后分享几个我实际踩坑踩出来的小习惯。第一写任何窗口函数前先明确排序字段的格式。visit_date如果是字符串务必保证yyyy-MM-dd这种前导零一致的格式否则2024-01-02会排在2024-01-10后面累积结果直接错。第二开发环境一定要先LIMIT看数据再上全量。先拿几行数据验证窗口逻辑对不对再放开跑能省下大量等待任务的时间。第三搭好的Hive环境里建议提前配好常用参数比如set hive.cli.print.headertrue; set hive.cli.print.row.to.verticaltrue; set hive.exec.paralleltrue;hive.cli.print.headertrue让结果带列名输出可读性提升很大。hive.exec.paralleltrue能让多个不相关阶段并行执行任务快不少。第四不管visit_cnt是不是真的可能为NULL都建议在计算前统一处理一次用NVL(visit_cnt, 0)或COALESCE(visit_cnt, 0)。NULL在窗口求和里的坑很隐蔽排查起来费时不如一劳永逸。7.3 终极方案选型建议把这几种实现摆在一起对比实现方式优点缺点适用场景窗口函数 SUM OVER性能好代码简洁语义清晰需要较新Hive版本支持生产首选自连接GROUP BY逻辑直观老版本也能跑性能极差中间结果膨胀严重仅用于理解原理或小数据量测试明细先聚合再累计结果准确避免重复计数多一层子查询明细表必须用我个人的标准是处理100万行以下怎么顺手怎么写处理100万行以上只考虑窗口函数。底层表是明细表永远不要直接拿明细行去做累积求和先聚合再算既稳又快。8. 一次实际调优过程的复盘去年我接手过一个慢任务就是这道题的生产版本。表有2亿多行跑的是月度累计访问次数任务天天超时最长跑过两个小时。我翻了一下SQL发现问题出在三个地方第一查询没写分区裁剪每次都是全表扫描实际只需要30天数据。加完WHERE dt date_sub(current_date, 30)扫描量降了一个量级。第二原本的SQL把窗口函数写在了最外层而最外层之前已经有一个包含COUNT(DISTINCT)的聚合导致所有明细先shuffle一次窗口函数又shuffle一次。我把明细先按天去重压缩再在外面套窗口函数shuffle数据量大幅下降。第三这个任务每天的调度高峰正好撞上全公司的夜间批量任务集群资源挤占严重。调整调度时间到凌晨两点后再也没超时。改完之后任务从两个小时缩到不到八分钟。这个case给我最大的教训是SQL写得好有时候不如先确认数据和业务场景但SQL写得烂再好的集群也扛不住。题目“每个用户的累积访问次数”看似基础里面却串起了分区设计、数据粒度、窗口函数语义、性能优化这些实打实的功底。你在面试里答好这道题不难难的是在生产环境里把它写得既对又快。希望这篇文章能让你在下次遇到它时心里有底手上有方案。
阅读完成 · 觉得有帮助?
咨询建站