1. 为什么需要多维聚合:从一张销售表说起
做过数据仓库或者报表开发的人,大概率都遇到过这样的需求:一张销售明细表,业务方既要看每个城市的销售额,又要看每个省份的销售额,还要看全国的总销售额,最好还能按品类再拆一层。最笨的办法是写好几条SQL,用UNION ALL把结果拼起来,代码又长又难维护,跑起来还慢。Hive提供的GROUP BY ROLLUP、GROUP BY CUBE和GROUPING SETS就是专门解决这类多维聚合问题的语法糖,它们能在一次查询里生成多个维度的聚合结果,底层只扫描一遍数据,效率比手写UNION ALL高得多。
这篇文章面向的是已经会写基础GROUP BY、但对这三个高级聚合语法还不太熟的数仓开发、数据分析师和BI工程师。我会把三个语法的用法、区别、底层逻辑、实际案例和踩坑经验一次讲透,你看完之后应该能直接在自己的项目里用起来。需要说明的是,Hive的这三个语法在语义上跟标准SQL以及MySQL 8.0、PostgreSQL、Oracle等数据库基本一致,所以这套知识迁移到其他引擎也不亏。
先给一个最直观的对比。假设有一张销售表sales,字段是province(省份)、city(城市)、category(品类)、amount(金额)。业务方想要的结果是:
- 每个省份+城市+品类的销售额
- 每个省份+城市的销售额(不分品类)
- 每个省份的销售额(不分城市和品类)
- 全国总销售额
用ROLLUP一行就能搞定,用CUBE会多算出一些组合,用GROUPING SETS则是最灵活的手工指定。下面逐个拆解。
2. GROUP BY ROLLUP:层级递进的聚合利器
2.1 ROLLUP的语义与执行逻辑
ROLLUP的核心思想是层级递进。你写在ROLLUP()括号里的字段顺序很重要,它会按照这个顺序逐层向上汇总。比如GROUP BY ROLLUP(province, city, category),它会生成以下几组聚合:
(province, city, category)—— 最细粒度(province, city)—— 去掉category(province)—— 去掉city和category()—— 全表总计
注意它是从右往左依次去掉字段的,所以字段顺序决定了汇总的层级路径。如果你写成ROLLUP(category, province, city),那汇总路径就变成了先按品类,再按品类+省份,最后按品类+省份+城市,业务含义完全不同。这一点是新手最容易搞错的地方。
从执行角度看,Hive在处理ROLLUP时,本质上是在一次MapReduce(或Tez/Spark)作业里,通过不同的分组键组合来输出多行结果。对于不参与当前层级聚合的维度,Hive会用NULL来填充。这就是为什么你看到结果里会出现很多NULL值——它们不是数据缺失,而是表示"这一行是该维度上的汇总"。
2.2 一个完整的ROLLUP案例
建表和造数:
CREATE TABLE sales ( province STRING, city STRING, category STRING, amount DOUBLE ) ROW FORMAT DELIMITED FIELDS TERMINATED BY ','; -- 假设数据如下 -- 广东,广州,家电,1000 -- 广东,广州,服饰,500 -- 广东,深圳,家电,800 -- 浙江,杭州,家电,600 -- 浙江,杭州,服饰,300 -- 浙江,宁波,服饰,200查询:
SELECT province, city, category, SUM(amount) AS total_amount FROM sales GROUP BY ROLLUP(province, city, category);结果会长这样(简化展示):
| province | city | category | total_amount |
|---|---|---|---|
| 广东 | 广州 | 家电 | 1000 |
| 广东 | 广州 | 服饰 | 500 |
| 广东 | 广州 | NULL | 1500 |
| 广东 | 深圳 | 家电 | 800 |
| 广东 | 深圳 | NULL | 800 |
| 广东 | NULL | NULL | 2300 |
| 浙江 | 杭州 | 家电 | 600 |
| 浙江 | 杭州 | 服饰 | 300 |
| 浙江 | 杭州 | NULL | 900 |
| 浙江 | 宁波 | 服饰 | 200 |
| 浙江 | 宁波 | NULL | 200 |
| 浙江 | NULL | NULL | 1100 |
| NULL | NULL | NULL | 3400 |
你可以清楚地看到层级:最细的行、城市级汇总行、省份级汇总行、以及最后的全国总计行。这种结构非常适合做报表的钻取(drill-down),因为每一层的数据都在同一张结果集里。
2.3 ROLLUP的实操注意事项
第一,字段顺序即业务层级。写ROLLUP之前一定要想清楚业务上的层级关系,通常是从粗到细或者从细到粗,但必须符合实际的汇总路径。我见过有人把日期放在最前面,结果汇总出来一堆按日期跨维度的奇怪组合,排查半天才发现是顺序问题。
第二,NULL值的歧义。如果原始数据里某个维度本身就可能为NULL,那汇总产生的NULL和真实数据的NULL就分不清了。解决办法是用GROUPING()函数或者GROUPING__ID来区分。Hive里可以用GROUPING__ID(注意是两个下划线),它会返回一个位图值,标识当前行参与了哪些分组字段。
SELECT province, city, category, SUM(amount) AS total_amount, GROUPING__ID FROM sales GROUP BY ROLLUP(province, city, category);GROUPING__ID的值可以帮助你在应用层判断这一行到底是哪个层级的汇总,从而决定是否显示"合计"字样。
第三,性能考量。ROLLUP的聚合组合数是N+1(N是字段数),比如3个字段就是4种组合。数据量大时,这仍然比写4条SQL要快,因为只扫描一次源表。但如果字段特别多,组合数会线性增长,要评估一下内存和shuffle压力。
3. GROUP BY CUBE:全组合聚合的暴力美学
3.1 CUBE与ROLLUP的本质区别
如果说ROLLUP是"一条路径走到底",那CUBE就是"所有路径都走一遍"。GROUP BY CUBE(a, b, c)会生成2的3次方等于8种聚合组合:
(a, b, c)(a, b)(a, c)(b, c)(a)(b)(c)()
也就是说,CUBE会穷举所有可能的维度子集。这在业务上意味着什么?意味着你不仅能看"省份+城市+品类"的层级汇总,还能看"品类+城市"这种跨层级的组合,比如"每个城市每个品类的销售额,不分省份"。这种需求在交叉分析、多维透视里非常常见。
3.2 CUBE案例与结果解读
同样的表,换成CUBE:
SELECT province, city, category, SUM(amount) AS total_amount FROM sales GROUP BY CUBE(province, city, category);结果会包含上面ROLLUP的所有行,再加上这些额外的组合:
| province | city | category | total_amount |
|---|---|---|---|
| NULL | 广州 | 家电 | 1000 |
| NULL | 广州 | 服饰 | 500 |
| NULL | 广州 | NULL | 1500 |
| NULL | 深圳 | 家电 | 800 |
| NULL | 深圳 | NULL | 800 |
| NULL | NULL | 家电 | 2400 |
| NULL | NULL | 服饰 | 1000 |
| ... | ... | ... | ... |
可以看到,CUBE多出了"城市+品类"、"城市"、"品类"这些维度的汇总。数据量上,CUBE的结果行数通常是ROLLUP的好几倍,因为组合数从N+1变成了2的N次方。
3.3 CUBE的使用场景与代价
CUBE最适合的场景是多维交叉分析,比如电商里要分析"不同地区、不同品类、不同渠道"的销售情况,而且每个维度都要能单独看、两两组合看。BI工具里的透视表功能,底层很多时候就是用CUBE实现的。
但代价也很明显。第一是结果集膨胀。3个维度是8种组合,4个维度就是16种,5个维度32种,指数级增长。如果你的维度有6个以上,结果集可能大到无法接受,甚至把Reducer撑爆。第二是计算开销。虽然只扫描一次源表,但shuffle和聚合的数据量会显著增加。
我的经验是:维度超过4个就慎用CUBE,优先考虑GROUPING SETS手工指定真正需要的组合。很多业务方嘴上说"我全都要",实际用到的组合可能就三五种,用CUBE纯属浪费。
提示:Hive里
CUBE和ROLLUP可以混用,比如GROUP BY province, ROLLUP(city, category),但混用时语义容易混乱,建议先在小数据集上验证结果再上生产。
4. GROUPING SETS:精准控制聚合组合的手术刀
4.1 GROUPING SETS的灵活之处
GROUPING SETS是三个语法里最灵活、最可控的一个。它允许你显式指定需要哪些聚合组合,不想要的就不算。语法长这样:
SELECT province, city, category, SUM(amount) AS total_amount FROM sales GROUP BY GROUPING SETS ( (province, city, category), (province, city), (province), (category), () );上面这个查询会生成5种聚合组合,完全按你的意愿来。你可以只保留业务真正需要的组合,避免CUBE那种"全都要"的浪费。
4.2 用GROUPING SETS替代UNION ALL
GROUPING SETS最典型的应用就是替代一堆UNION ALL。以前我们写报表经常这样:
SELECT province, city, category, SUM(amount) FROM sales GROUP BY province, city, category UNION ALL SELECT province, city, NULL, SUM(amount) FROM sales GROUP BY province, city UNION ALL SELECT province, NULL, NULL, SUM(amount) FROM sales GROUP BY province UNION ALL SELECT NULL, NULL, NULL, SUM(amount) FROM sales;四条SQL扫四遍表,性能差不说,代码还难维护。换成GROUPING SETS,一遍扫描搞定,代码也清爽。实测在千万级数据上,GROUPING SETS比UNION ALL快3到5倍,数据量越大差距越明显。
4.3 GROUPING SETS的进阶用法
GROUPING SETS还支持一些ROLLUP和CUBE做不到的组合。比如你只想要"省份+品类"和"城市+品类"这两种跨层级组合,不要"省份+城市":
GROUP BY GROUPING SETS ( (province, category), (city, category) );这种需求用ROLLUP和CUBE都表达不了,只有GROUPING SETS能精确命中。另外,GROUPING SETS里还可以嵌套ROLLUP和CUBE,比如:
GROUP BY GROUPING SETS ( ROLLUP(province, city), CUBE(category, channel) );这种写法适合复杂报表,但可读性会下降,建议加注释说明每个组合的业务含义。
5. 三者对比:一张表看懂区别与选型
5.1 核心差异对照表
| 特性 | ROLLUP | CUBE | GROUPING SETS |
|---|---|---|---|
| 聚合组合数 | N+1 | 2^N | 自定义 |
| 是否穷举 | 否,层级递进 | 是,全组合 | 否,按需指定 |
| 字段顺序敏感 | 是 | 否 | 否 |
| 结果集大小 | 中等 | 最大 | 最小(可控) |
| 灵活性 | 低 | 中 | 最高 |
| 典型场景 | 层级钻取报表 | 多维交叉分析 | 精准报表、替代UNION ALL |
| 性能 | 较好 | 较差(维度多时) | 最好(组合少时) |
5.2 选型决策树
实际工作中怎么选?我总结了一个简单的判断流程:
- 如果业务是层级关系(省-市-区、年-季-月),用
ROLLUP,字段按层级顺序写。 - 如果业务要任意维度组合且维度不超过4个,用
CUBE。 - 如果只需要特定几种组合,或者要替代
UNION ALL,用GROUPING SETS。 - 如果维度超过4个又要全组合,先跟业务方确认是否真的需要,大概率可以用
GROUPING SETS砍掉一半。
5.3 一个容易混淆的点:NULL的语义
三个语法产生的汇总行都会用NULL填充未参与的维度,但NULL的含义是"该维度被汇总掉了",不是"该维度值为空"。如果原始数据里真的有NULL,就会产生歧义。解决办法有两个:一是用GROUPING__ID或GROUPING()函数区分;二是在ETL阶段就把NULL替换成'未知'之类的占位符,从源头避免。
SELECT province, city, category, SUM(amount) AS total_amount, GROUPING__ID AS gid FROM sales GROUP BY CUBE(province, city, category) HAVING GROUPING__ID != 0; -- 排除最细粒度行GROUPING__ID的具体位含义跟字段顺序有关,建议在开发时先跑一个小样本,把每个gid对应的组合打印出来,做成映射表,后续用起来就方便了。
6. 实战踩坑与性能优化经验
6.1 常见报错与排查
报错一:SemanticException: GROUPING SETS are not supported。这通常是Hive版本太老,GROUPING SETS在Hive 0.10之后才支持,CUBE和ROLLUP更早一些。先确认版本,SELECT version()看一下。如果是老版本,只能退回UNION ALL。
报错二:结果里NULL太多,分不清层级。前面说过,用GROUPING__ID解决。另外在Hive CLI里查询时,NULL默认显示为空,容易看漏,建议用NVL(province, 'ALL')之类的函数把汇总行标记出来。
报错三:数据倾斜导致某个Reducer卡死。多维聚合时,如果某个维度的值分布极不均匀(比如某个省份占了80%的数据),汇总到该维度时就会倾斜。解决办法是开启Hive的倾斜优化:
SET hive.groupby.skewindata = true;这个参数会让Hive先做一次预聚合,把相同key的数据分散到不同Reducer,再做最终聚合。实测对倾斜场景有明显改善。
6.2 性能优化清单
- 能不用CUBE就不用,优先
GROUPING SETS精确指定。 - 维度字段尽量用整型或短字符串,减少shuffle数据量。
- 开启向量化执行:
SET hive.vectorized.execution.enabled = true;,对聚合类查询提升明显。 - 合理设置Reducer数量:
SET hive.exec.reducers.bytes.per.reducer = 256000000;,根据数据量调整。 - 小表广播:如果聚合前要join维度表,用
/*+ MAPJOIN(dim) */提示,避免大表shuffle。 - 分区裁剪:聚合前先过滤分区,别让全表数据都参与聚合。
6.3 一个真实的优化案例
之前有个报表需求,4个维度做CUBE,结果16种组合,跑一次要40分钟,Reducer经常OOM。后来分析发现业务实际只用到了其中6种组合,改成GROUPING SETS后,跑一次只要8分钟,内存也稳了。这个案例说明:不要盲目用CUBE,先问清楚业务到底要哪些组合。很多时候业务方说"都要",其实是他没想清楚,你帮他梳理一下,往往能砍掉一大半。
注意:
GROUPING SETS里如果组合写错,比如字段名拼错,Hive可能不报错但结果不对。建议每次改完SQL,先用小数据集验证结果行数和关键汇总值,再上生产。
7. 从Hive到其他引擎:知识的迁移
这套多维聚合语法不是Hive独有的。MySQL 8.0、PostgreSQL、Oracle、Spark SQL、Flink SQL都支持类似的语法,语义基本一致。区别主要在细节上:
- MySQL 8.0:支持
ROLLUP,不支持CUBE和GROUPING SETS(截至8.0版本)。 - PostgreSQL:三个都支持,
GROUPING()函数用起来比Hive的GROUPING__ID更直观。 - Spark SQL:三个都支持,语法跟Hive几乎一样,因为Spark SQL兼容Hive语法。
- Flink SQL:支持
ROLLUP和CUBE,GROUPING SETS在较新版本才支持。
所以你在Hive里练熟的这套技能,换到Spark或Flink上基本能直接用,只需要注意版本差异和函数名的细微不同。这也是我建议花时间搞懂这三个语法的原因——一次学习,多处受益。
8. 几个容易被忽略的细节
最后分享几个我在实际项目里踩过的坑,都是文档里不太会写的。
第一,ROLLUP和CUBE里的字段不能是表达式。比如ROLLUP(YEAR(dt), MONTH(dt))在某些Hive版本会报错,得先在子查询里把YEAR(dt)和MONTH(dt)算成别名,再在外层聚合。
第二,GROUPING SETS里的空括号()表示全表汇总,这个别漏写,漏了就没有总计行了。有些同学以为不写就是总计,其实不写就是没有。
第三,聚合函数里可以用COUNT(DISTINCT),但性能很差。多维聚合加去重计数,数据量一大就容易崩。如果业务允许近似值,可以考虑用approx_distinct之类的近似算法,牺牲一点精度换性能。
第四,结果集的排序。多维聚合的结果默认无序,如果报表需要按层级排序,得在最后加ORDER BY,但ORDER BY会触发全局排序,数据量大时很慢。更好的做法是在应用层排序,或者用SORT BY做局部排序。
第五,测试数据要覆盖边界。造测试数据时,记得包含NULL值、空字符串、极端大值,这样才能验证GROUPING__ID的区分逻辑和汇总结果的正确性。我见过因为测试数据太干净,上线后遇到真实NULL导致报表数字翻倍的案例。
这套语法看起来简单,但真正用好需要结合业务理解字段层级、评估数据量、选择合适的组合方式。希望这篇内容能帮你少走一些弯路,直接在自己的项目里用起来。