1. 先搞明白:为什么 GROUP BY 解决不了“保留明细”的问题
1.1 一个典型场景引发的思考
想象一个最常见不过的需求:你有一张员工表,字段是员工姓名、部门ID、薪资、入职时间。现在老板说,我要看每个员工的姓名和薪资,同时还得告诉我这个员工的部门平均薪资是多少。用 GROUP BY 能做到吗?能,但只做了一半——按部门聚合之后,每个部门只剩下一行,员工明细全没了。你要是硬把员工姓名字段塞进 GROUP BY,分组粒度就变成了“部门+员工”,统计的就不再是部门平均薪资了。
这种“既想要明细行,又想要分组后的汇总值”的需求,正是窗口函数存在的根本理由。窗口函数可以在不压缩行数的前提下,对每一行所在的一个“窗口范围”做聚合、排序、偏移取值等计算。它不是一个炫技的语法,而是数据清洗、数仓建模、报表分析里的日常工具。网约车大数据项目里尤其常见:要分析每个司机当天接了哪些订单、累计收入到某个时刻是多少、按订单时长给司机排名,这些场景如果只用 GROUP BY,就得反复自关联或者多层子查询,写起来非常痛苦。
还有一个高频需求是“给每一行标号”。这个说法听起来简单,但 ROW_NUMBER、RANK、DENSE_RANK 到底该用哪个,很多人第一次写都会拿不准。这些问题本质上都属于 SQL 标准里的 OLAP 函数,在 Hive 里从 0.11 版本就开始支持,发展到 3.1.3 已经非常成熟。窗口函数不是奢侈品,是必需品。
1.2 窗口函数与 GROUP BY 的本质差异
很多人学窗口函数时最大的障碍,就是脑子里已经被 GROUP BY 的分组思维固化了。我用一张表来说明两者的差异:
| 对比点 | GROUP BY | 窗口函数 |
|---|---|---|
| 结果行数 | 每组压缩成一行,行数减少 | 保持原始行数,每一行都保留 |
| SELECT 列限制 | 只能选择分组字段和聚合结果 | 可以选择任意字段,窗口结果附加在新列上 |
| 计算范围 | 全组一次性聚合 | 通过 OVER() 控制分组、排序、窗口边界 |
| 典型用途 | 汇总统计、去重后聚合 | 分组排名、累计计算、相邻行比较、明细+汇总 |
GROUP BY 就像把一箱苹果按产地分成几堆,每一堆只留下一张标签。窗口函数则不同,它给每一颗苹果都发一个牌子,写上“你是哪个产地的”,然后还要按大小排一次队,发一个号码。两套逻辑解决不同的问题,关键看你要的是“汇总后的一个值”还是“明细行旁边的补充信息”。
2. “开窗”到底开的是什么:OVER() 三个部分的精确含义
窗口函数的语法用一句话概括:选定一个计算函数,然后用 OVER() 指定它的计算范围。函数本身不难,难的是 OVER() 括号里那三个部分的语义。
2.1 PARTITION BY:切分数据,但不会合并行
PARTITION BY 后面跟的是分组字段,作用上很像 GROUP BY,但结果完全不一样。以“统计部门人数”为例,用 GROUP BY 得到每个部门一行,用窗口函数写 COUNT(*) OVER (PARTITION BY dept) 得到的是:每一行员工都会带上自己所在部门的人数,员工行一条不少。
SELECT emp_name, dept_id, salary, COUNT(*) OVER (PARTITION BY dept_id) AS dept_cnt FROM employee;
这个写法特别适合做“明细+汇总”混合展示。运营看板里要列出每笔订单的订单号、金额,同时还要显示这一天的城市总订单金额。正常写法要么加子查询,要么 GROUP BY 再 JOIN 回来。用了窗口函数,一行就能搞定:
SELECT order_id, city_id, amount, SUM(amount) OVER (PARTITION BY city_id, dt) AS city_daily_amount FROM dwd_order_dtl WHERE dt = '2024-06-01';
结果行数等于订单明细行数,多出来的那一列就是每个订单所在城市当天的总金额。之后要算城市占比,直接 amount / city_daily_amount 就行。
PARTITION BY 还可以写多个字段,比如 PARTITION BY city_id, dt 就是按城市和日期两个维度分组。需要特别留意的是,窗口函数中的 PARTITION BY 字段不会像 GROUP BY 那样要求出现在 SELECT 中,它纯粹是计算控制条件。
如果不写 PARTITION BY,只有一个空括号 OVER(),那意思就是把整张表看作一个组,每一行都拿全表的数据做计算。比如计算每个订单金额占全表总金额的比例,可以写 SUM(amount) OVER(),非常方便。
2.2 ORDER BY:在窗口函数里不只是排序
OVER() 里的 ORDER BY 有两层作用。第一层很简单,给组内的行排个序,排序标号类函数依赖这个顺序。第二层更关键,它决定了“累计”的方向。
同样一个 SUM(amount) OVER (PARTITION BY city_id),加不加 ORDER BY 结果截然不同。不加 ORDER BY 时,窗口范围是整个分组,SUM 返回的是全组总和,每一行都一样。加了 ORDER BY 时,默认窗口范围变成“从组内第一行到当前行”,于是 SUM 变成截止当前行的累计值。
这里有两点提醒。第一,如果 ORDER BY 后面有重复值,Hive 默认采用 RANGE 模式,相同 ORDER BY 值的行会被看作一个整体,累计结果会把这些行的值一起累加,而且这些行之间的计算结果完全一样。如果你想要的是“按物理行逐行累计”,需要在 ORDER BY 后面加一个唯一字段,或者显式使用 ROWS BETWEEN 子句。第二,ORDER BY 字段的类型要统一,尤其是日期字段,建议用 yyyy-MM-dd HH:mm:ss 这种文本格式,不要混用字符串和日期类型,否则执行计划里容易出现隐式转换,导致窗口结果错乱。
还要注意排序方向对累计值的影响。ORDER BY order_time ASC 时,累计值从最早一笔叠加到当前时间;ORDER BY order_time DESC 时,累计值从最新一笔往旧时间方向叠加。若你想要“按时间正序看累计”,不要把方向写反,这个错误在报表里表现得很隐蔽——数字不是负数,只是顺序倒过来了。
2.3 窗口子句:控制从哪一行看到哪一行
窗口子句一般只出现在聚合类窗口函数后面,语法是 ROWS BETWEEN 下限 AND 上限。常用的写法有:
- ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW:从组内第一行到当前行,这是最常见的累计计算。
- ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING:从当前行到组内最后一行。
- ROWS BETWEEN n PRECEDING AND CURRENT ROW:从当前行往前数 n 行,比如最近 7 天的移动平均可以写成 6 PRECEDING AND CURRENT ROW。
- ROWS BETWEEN n PRECEDING AND n FOLLOWING:前后各 n 行,适合做平滑处理。
不写窗口子句时,聚合类窗口函数有默认规则:有 ORDER BY 时默认窗口到当前行;没有 ORDER BY 时默认整个分区。很多线上数据问题都是对“默认范围”理解不一致导致的,尤其是 LAST_VALUE、SUM 这类函数。所以我自己的习惯是:只要是聚合类窗口函数,一律显式写窗口子句,不依赖默认行为。
RANGE BETWEEN 和 ROWS BETWEEN 的区别也需要知道。ROWS 按物理行数计算,RANGE 按排序键的值计算。举个例子,ORDER BY order_time,如果有多笔订单发生在同一秒,RANGE 会把同一秒内的所有订单归到一个窗口单元,ROWS 则严格按顺序逐行移动。对于逐笔累计金额,ROWS 通常更符合直觉。
3. 三类高频窗口函数逐一拆解:从函数名到行为差异
3.1 排序标号类:ROW_NUMBER、RANK、DENSE_RANK、NTILE
这组函数是入门最常用的。四个函数的共同点是给组内每一行分配一个序号,区别在于重复值怎么处理。
- ROW_NUMBER():不管有没有重复值,都按顺序给 1、2、3、4,序号绝对不重复。
- RANK():遇到重复值会并列排名,但下一个名次会跳跃。比如 1、1、3。
- DENSE_RANK():遇到重复值并列排名,但下一个名次不跳跃。比如 1、1、2。
- NTILE(n):把组内数据尽量平均分成 n 个桶,每行返回桶编号 1 到 n。
举个例子,按订单金额给每个城市的订单排名:
SELECT city_id, order_id, amount, ROW_NUMBER() OVER (PARTITION BY city_id ORDER BY amount DESC) AS rn, RANK() OVER (PARTITION BY city_id ORDER BY amount DESC) AS rk, DENSE_RANK() OVER (PARTITION BY city_id ORDER BY amount DESC) AS drk FROM dwd_order_dtl;
如果同一城市有两条订单金额相同,ROW_NUMBER 会随机给其中一条标 1,另一条标 2;RANK 会把两条都标成并列 1,然后下一个标 3;DENSE_RANK 会把两条都标成并列 1,下一个标 2。这个差异在 TopN 场景里非常关键:要求取前 10 条且最多 10 条,用 ROW_NUMBER;允许并列但名次可以跳,用 RANK;允许并列且名次连续,用 DENSE_RANK。
NTILE 用得少一点,但很有意思。NTILE(10) 会把数据尽量均匀分成 10 份。有一种“把用户按消费金额分成高、中、低三档”的标签需求,可以直接写 NTILE(3) OVER (ORDER BY amount DESC),桶号 1、2、3 就是档位。注意分桶不能保证每桶行数完全一样,行数不足时前几个桶会多一行。
3.2 聚合计算类:SUM、COUNT、AVG 在窗口里发生了什么
聚合函数放到窗口里之后,语义发生了质变。它的逻辑是:对每一行,以它为“当前行”,在窗口范围内做一次聚合,结果附加到这一行上。所以同一组内不同行的结果很可能不同。
最经典的用法是累计求和。订单明细表里,想看每个用户从第一单到当前单的累计消费金额:
SELECT user_id, order_time, order_amount, SUM(order_amount) OVER (PARTITION BY user_id ORDER BY order_time ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cumulative_amount FROM dwd_order_dtl;
这段 SQL 里,PARTITION BY user_id 把不同用户隔开,ORDER BY order_time 决定累计的方向,窗口子句限定“从第一行到当前行”。每处理一行,就把当前行之前的金额全部加起来,得到的是一个单调递增的累计值。网约车项目里的“司机当日累计完单数”“平台累计交易额”都是这个套路。
如果把窗口子句去掉,结果会变吗?会。Hive 默认在有 ORDER BY 的情况下窗口范围是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。大部分情况下结果一样,但如果 order_time 有重复,重复行会被包含在一个窗口单元里一起累加,结果看起来会“跳了一下”。
COUNT(*) OVER (PARTITION BY dept) 这种“分组打标签”的写法也很有用。比如要过滤掉部门人数小于 5 的员工,却不想 JOIN 聚合表。直接在外面套 WHERE 是不行的,因为窗口函数的结果不能直接进 WHERE。SQL 的执行顺序是先 FROM、WHERE、GROUP BY、HAVING,然后才轮到 SELECT 里的窗口函数计算,WHERE 根本看不到窗口函数的输出。解决办法是包一层子查询,或者用 WITH 临时表。
3.3 位移取值类:LEAD、LAG、FIRST_VALUE、LAST_VALUE
这组函数解决的是“跟相邻行做比较”的问题。LEAD(col, n, default) 取当前行后面第 n 行的值,LAG(col, n, default) 取前面第 n 行的值,第三个参数是取不到时返回的默认值。
业务场景非常直观:计算每个司机相邻两笔订单的时间间隔。
SELECT driver_id, order_time, LAG(order_time) OVER (PARTITION BY driver_id ORDER BY order_time) AS prev_order_time FROM dwd_order_dtl;
拿上一笔订单时间到当前行,在外面套一层子查询,把两个时间相减,就能算出间隔。这里要提醒一点:LAG/LEAD 的结果是物理相邻行,而不是“间隔 n 分钟”的行。想取时间间隔在某个范围内的上一笔,光靠 LAG 做不到,得结合窗口子句做聚合,或者自连接。
FIRST_VALUE 和 LAST_VALUE 用来取窗口内第一行和最后一行的某个字段。FIRST_VALUE 比较直接,取排序后的第一行。LAST_VALUE 则有个经典坑:如果不显式写窗口子句,默认窗口结束位置是当前行,那么 LAST_VALUE 返回的其实不是整个分组最后一行,而是当前行自己的值。想取组内最后一行,必须写 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING。
这组函数里还可以用 LAG 判断事件是否发生变化。比如司机状态从“空闲”变成“接单”,按司机和时间排好序后,用 LAG(状态) 和当前状态比较,不一样就说明发生了状态变化。做用户行为漏斗、状态机分析时,这种写法比子查询高效得多。
3.4 偏冷门但有用:PERCENT_RANK、CUME_DIST、NTH_VALUE
Hive 3.1.3 还支持几个偏冷门的窗口函数,偶尔会有奇效。
PERCENT_RANK() 返回某一行在组内的百分比排名,范围从 0 到 1。公式是 (rank - 1) / (组内行数 - 1)。如果要看“客户的消费水平超过百分之多少的人”,这个函数比手算排名再除总数方便得多。
CUME_DIST() 返回小于等于当前值的行数占总行数的比例,范围从大于 0 到 1。它适合做累计分布分析,比如查看订单金额达到某个值后覆盖了多少比例的订单。
NTH_VALUE(col, n) 取窗口内第 n 行的某个字段值。比如要看每个用户第 3 笔订单的金额,可以用 NTH_VALUE(order_amount, 3) OVER (PARTITION BY user_id ORDER BY order_time)。注意不是所以版本都支持,用之前先确认 Hive 的版本和引擎。
4. 实战 SQL:从“给每一行标号”到网约车订单分析
4.1 “给每一行标号”的四种写法与结果对比
“hive 给每一行标号”是个高频需求。给一张表加自增序号,最直接的写法是:
SELECT ROW_NUMBER() OVER (ORDER BY id) AS rn, * FROM table_name;
这会在全表范围内从 1 开始编号。但遇到重复数据,你要的是“相同业务键的多条里选一条”,就不能全表编号,必须按业务键分组。下面这张表可以帮你快速选择:
| 需求 | 写法 | 序号特征 |
|---|---|---|
| 全局自增行号 | ROW_NUMBER() OVER (ORDER BY 唯一字段) | 1,2,3...唯一 |
| 组内自增行号 | ROW_NUMBER() OVER (PARTITION BY 业务键 ORDER BY 事件时间) | 每组内从1开始 |
| 组内并列且跳号 | RANK() OVER (PARTITION BY 业务键 ORDER BY 指标 DESC) | 1,1,3... |
| 组内并列不跳号 | DENSE_RANK() OVER (PARTITION BY 业务键 ORDER BY 指标 DESC) | 1,1,2... |
| 组内分桶 | NTILE(10) OVER (PARTITION BY 业务键 ORDER BY 指标) | 1~10桶号 |
这里有个容易引发线上事故的点:如果 ORDER BY 字段不是唯一,纯 ROW_NUMBER 的编号顺序是不稳定的,多次执行可能得到不同结果。做去重时,ORDER BY 后面一般要加一个唯一字段兜底,比如订单号、流水号。实在没有唯一字段,可以用 ROW_NUMBER() OVER (ORDER BY rand()),但要接受随机性。
4.2 网约车大数据项目里的窗口函数实例
网约车订单明细表一般有 order_id、driver_id、passenger_id、city_id、order_time、order_amount、status 等字段。窗口函数在这个项目里能解决大量分析问题。
第一个需求:求每个司机的订单累计金额,观察司机收入曲线。
SELECT driver_id, order_id, order_time, order_amount, SUM(order_amount) OVER (PARTITION BY driver_id ORDER BY order_time ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS driver_cum_amount FROM dwd_order_dtl WHERE dt = '2024-06-01';
第二个需求:求每个城市订单量 Top3 的小时时段。需要先按城市和小时聚合出单量,再用 DENSE_RANK 给每个城市的时段排名:
SELECT city_id, hour_no, cnt FROM ( SELECT city_id, HOUR(order_time) AS hour_no, COUNT() AS cnt, DENSE_RANK() OVER (PARTITION BY city_id ORDER BY COUNT() DESC) AS rk FROM dwd_order_dtl WHERE dt = '2024-06-01' GROUP BY city_id, HOUR(order_time) ) t WHERE t.rk <= 3;
注意这里不能直接在 HAVING 里引用 rk,因为窗口函数是在分组聚合之后才计算的,必须包一层子查询。
第三个需求:数据质量校验,找出同一个订单是否被重复上报。按 order_id 分组、按 ETL 时间排序,取每个订单的最新一条记录。这是实时数仓里最经典的“取最新状态”操作:
SELECT * FROM ( SELECT order_id, status, etl_time, ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY etl_time DESC) AS rn FROM ods_order_log ) t WHERE t.rn = 1;
这套模式几乎所有做数据的人都会用。不管是 Binlog 接入的订单日志,还是重复上报的埋点数据,统一思路都是“找到同一个业务主键下最新的那条”。
4.3 窗口函数与 GROUP BY 的组合拳
有些分析需求既要分组聚合,又要保留明细,灵活的做法是分两步走:先用 GROUP BY 算出聚合结果,再用窗口函数把聚合结果关联回明细行。但大多数情况,窗口函数能帮你省掉 JOIN。
例如,查每个部门的员工明细和部门平均薪资:
SELECT emp_name, dept_id, salary, AVG(salary) OVER (PARTITION BY dept_id) AS dept_avg_salary FROM employee;
要算“每个员工薪资与部门平均薪资的差额”,直接再减一下即可。不需要任何 JOIN,也减少了一次 shuffle。在超大表场景下,少一个 stage 对执行时间的影响非常明显。
4.4 常见需求的 SQL 模板
除了上面这些,下面几个模板在我日常工作中出现频率极高。
占比计算:每个城市的订单金额占全平台的比重。
SELECT order_id, city_id, amount, amount / SUM(amount) OVER(PARTITION BY city_id) AS city_share, amount / SUM(amount) OVER() AS global_share FROM dwd_order_dtl;
相邻时间间隔:每个用户相邻两次行为的时间差。
SELECT user_id, action_time, UNIX_TIMESTAMP(action_time) - UNIX_TIMESTAMP(LAG(action_time) OVER(PARTITION BY user_id ORDER BY action_time)) AS interval_sec FROM ods_user_action;
分组 TopN:每个城市订单金额最高的前 5 笔。
SELECT city_id, order_id, amount FROM ( SELECT city_id, order_id, amount, ROW_NUMBER() OVER(PARTITION BY city_id ORDER BY amount DESC) AS rn FROM dwd_order_dtl ) t WHERE t.rn <= 5;
这些模板可以直接套用,换掉表名和字段名就能跑。
5. 我在实际项目中踩过的窗口函数坑(附带排查思路)
5.1 坑一:LAST_VALUE 返回的不是组内最后一行
这是最经典的坑,没有之一。我当年在一个报表需求里,想把每个渠道最新一条活动记录的时间带出来,写的是 LAST_VALUE(activity_time) OVER (PARTITION BY channel_id ORDER BY activity_time),结果每行返回的都是当前行自己的时间,报表数据怎么都对不上。
排查思路:先拿一个小临时表复现,分别测试不写窗口子句和显式写 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING 的差异。一复现就明白了,Hive 的默认窗口范围到当前行,LAST_VALUE 取到的自然是当前行自己。解决办法是显式扩大窗口边界。这个坑对我来说是个很好的提醒:遇到任何聚合类函数,先确认窗口范围,不要默认 Hive 会给你全组统计。
5.2 坑二:窗口函数不能直接用于 WHERE,导致诡异的报错
Hive 的 SQL 执行顺序决定了 WHERE 发生在窗口函数计算之前。在 WHERE 里引用 rn = 1 或者 dept_avg_salary > 1000 时,Hive 会报类似 “Invalid table alias or reference” 的错误。这不是操作失误,是语法顺序问题。
排查思路:出现这类报错,第一时间把筛选逻辑挪到外层。标准套路是:
SELECT * FROM ( SELECT ..., ROW_NUMBER() OVER (...) AS rn FROM tbl ) t WHERE t.rn = 1;
这个套路能解决九成以上的窗口函数筛选问题。另外,如果在 Hive 3.1.3 里碰到窗口函数相关执行计划不一致的情况,先检查执行引擎,Tez 和 Spark 对同一 SQL 的优化策略会有差异,但语法层面基本一致。
5.3 坑三:数据倾斜和临时文件膨胀
窗口函数不是性能银弹。最典型的问题是 PARTITION BY 某个严重倾斜的键时,比如按 city_id 分组,某一个特大城市的订单量比其他城市高一个数量级,那么一个 reducer 要处理的数据量会非常大,其他 reducer 早就跑完了,整个任务卡在倾斜分区上。Hive 的窗口函数在 reduce 端执行,每个分区数据可能被写到临时文件,分区特别大时临时文件也会膨胀,甚至导致磁盘溢出。
排查思路:先看执行计划确认 window 操作对应的 reducer 数,再用日志判断哪个 stage 耗时最长。遇到大 key 倾斜,首先想业务上能不能换分区键,比如用 city_id + dt 这种更细的键。如果一定要按 city_id 分组,可以在预处理阶段过滤无关数据,缩小扫描范围。窗口函数本身没有通用的“加盐再聚合”办法,因为加盐会改变分组语义,尤其对累计类、排序类函数,乱加随机盐会导致结果完全错误。
5.4 坑四:窗口函数不是越多越好
我见过有人一个 SQL 里塞了十几个窗口函数,每个都 OVER 同一个分组排序条件。Hive 即使面对完全相同的 OVER 条件,也不会做公共子表达式消除,每个窗口函数都会走独立的计算流程。窗口条件重复时,开销并不会自动合并。
优化思路是把公共分组排序条件提取到子查询,先按分组排序好,再在外面多次调用不同函数。更激进的做法是拆成两个临时表,分步计算。看起来牺牲了一点代码整洁,但执行效率往往高得多。
5.5 坑五:NULL 排序和隐式类型转换
窗口函数里的 ORDER BY 遇到 NULL 值时,排序位置和预期很可能不一致。Hive 中 NULL 在升序时默认排在最前面,很多业务希望空值排在最后。如果业务不允许 NULL 影响排名,可以在 ORDER BY 字段上做处理,比如 COALESCE 到默认值,或者使用 NULLS LAST 语法。
类型转换的问题更隐蔽。ORDER BY 一个字符串类型的金额字段时,Hive 按字典序排序,结果可能是 100 排在 20 前面,TopN 取出来完全不对。解决方案是建表时就把金额字段定义成 DECIMAL,而不是偷懒用 STRING。窗口函数做的隐式 CAST 一方面增加 CPU 开销,另一方面会带来排序错误,属于双重隐患。
6. 想在 Hive 3.1.3 里用好窗口函数:环境准备和几个参数建议
6.1 版本选型和基础准备
市面上的 Hive 版本很多,如果是从零开始,建议直接用 Hive 3.1.3。这个版本对 Tez 和 Spark 引擎的支持都比较成熟,窗口函数相关语法也稳定。环境准备上,Hive 依赖 Hadoop HDFS/YARN 和 Metastore 数据库,通常用 MySQL 存储元数据。很多入门教程会让你用内嵌 Derby,但我不建议在真实项目里这么干,一旦多会话同时连接就会出现锁冲突。
也别把精力全耗在“hive 的安装与配置”上太久。使用 CDH、HDP 这类发行版,或者容器环境快速起一个验证集群,把时间留给 SQL 本身更划算。窗口函数的语法在不同 Hive 版本间基本兼容,真正影响体验的是执行引擎和各种 runtime 参数。
6.2 执行引擎选择和必要的参数调优
Hive 3.1.3 里我默认推荐 Tez 引擎。窗口函数在 Tez 上的 DAG 调度表现比老 MapReduce 好很多。基础参数设置:
set hive.execution.engine=tez;
数据量大时,适当提高容器内存:
set tez.container.size=4096; set tez.runtime.io.sort.mb=512;
窗口函数任务通常会产生多个 reducer,输出文件数量如果过多,会加重小文件问题。Hive 3.1.3 里可以开启合并小文件:
set hive.merge.mapfiles=true; set hive.merge.mapredfiles=true; set hive.merge.size.per.task=256000000; set hive.merge.smallfiles.avgsize=16000000;
这几个参数的含义是:Map 阶段输出和最终输出都允许自动合并,合并后的目标文件大小约 256MB,如果输入的平均文件小于 16MB 就触发合并。窗口函数任务往往是“明细读取 + 多 reducer 输出”,很容易产生大量小文件,这套配置对后续查询性能帮助很大。
6.3 看执行计划,别靠猜
写完窗口函数 SQL,性能不理想时一定要学会看 EXPLAIN。Hive 里执行 EXPLAIN SELECT ... 会输出完整的 operator tree,窗口函数通常对应 PTFOperator 或 WindowingTableFunction。
看执行计划时重点关注两点。一是确认 PARTITION BY 字段是否和 reduce 的 partition 一致;二是看有没有额外的 Sort Operator。窗口函数在组内排序时,Hive 会在 reducer 内部做一次局部排序。数据量大时,这个排序会成为瓶颈。可以考虑在 SQL 中提前用 DISTRIBUTE BY + SORT BY 控制数据分布,减少 reducer 内的重复排序。
6.4 DDL 和分区字段的小提醒
窗口函数 SQL 通常都会带 WHERE dt = 某个分区条件,所以建表时建议把常用过滤字段设为分区列,比如 dt 分区。这样窗口函数读取数据时能走分区裁剪,减少扫描量。
DDL 阶段如果字段类型不规范,比如金额用 STRING 存储,窗口函数做 SUM 时需要 CAST,会拖慢执行。建模阶段就把字段类型定准:金额用 DECIMAL,日期用 TIMESTAMP 或统一格式的 STRING,能省掉大量不必要的转换。
7. 结尾:我判断窗口函数写法的三条经验
窗口函数学起来不难,真正难的是在具体业务里快速翻译成 SQL。我在实际项目中总结出三条判断经验,分享给你。
第一条:先问“结果要保留明细行还是压缩成一行”。要保留明细行,优先想窗口函数;要压缩成一行,优先想 GROUP BY。
第二条:再问“这个函数需要感知前后行吗”。纯分组统计用聚合窗口函数即可;要看排名、序号、相邻行,才用排序类和位移类。
第三条:最后检查窗口子句。只要聚合类函数没写 ROWS/RANGE BETWEEN,就默认可能会出问题。我习惯把所有窗口函数的边界都显式写清楚,不依赖默认行为。
我自己的习惯是维护一个“窗口函数场景速查表”,把常见业务需求映射成标准式子。比如“去重取最新”等于 ROW_NUMBER() OVER (PARTITION BY 主键 ORDER BY 时间 DESC) rn=1;“累计求和”等于 SUM(x) OVER (PARTITION BY y ORDER BY z ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW);“同环比”等于 LAG(x, n) OVER (PARTITION BY y ORDER BY z)。用熟了之后,窗口函数会成为 SQL 里最顺手的一类工具。至少在 Hive 的日常分析任务里,它值得你花半小时彻底搞懂。