多级分组统计在报表开发中非常常见,比如既要看全国总额,又要看每个大区、每个省份、每个城市的销售额。普通 GROUP BY 一次只能按照固定的一组列做聚合,如果直接按大区、省份、城市分组,结果里不会出现大区小计或全国总计。为了拿到这些不同层级的汇总,过去通常需要写多个分组查询再用 UNION ALL 拼起来,代码重复且执行效率不理想。SQL 标准中的 GROUPING SETS 提供了一种更优雅的方案,它可以在一次扫描中生成多个分组维度。不过真实业务里往往不会直接对原始表做分组,而是先经过过滤、关联、退款扣除等一系列处理,再把中间结果交给 GROUPING SETS。这就涉及嵌套查询与多级分组统计的配合。

一、GROUPING SETS 解决什么问题
先看一个最基础的需求:销售表 sales 中包含 region、province、city、amount 四个字段,需要同时输出城市级、省份级、区域级和全国级四个汇总结果。如果只写 GROUP BY region, province, city,结果只有城市粒度;如果只写 GROUP BY region,结果只有区域粒度。为了在同一个结果集中看到不同层级,传统写法是四个查询用 UNION ALL 连接,每个查询都要扫描一次 sales 表,SQL 会变得很长。
GROUPING SETS 可以直接在 GROUP BY 子句中列出多个分组组合,语法如下:
SELECT region,
province,
city,
SUM(amount) AS total_amount
FROM sales
GROUP BY GROUPING SETS (
(region, province, city),
(region, province),
(region),
()
);
这段 SQL 会把四个分组组合一次性计算出来。没有参与分组的列在结果中会显示为 NULL,例如按大区汇总时,province 和 city 都是 NULL;整体汇总那一行三个维度列全是 NULL。它等价的 UNION ALL 写法至少要扫描四次销售表,而 GROUPING SETS 在多数数据库中可以只扫描一次底层数据,再根据分组键做分发聚合,IO 和 CPU 消耗通常更低。
不过 GROUPING SETS 并不是银弹。当业务口径复杂时,分组表达式不能直接写在原始表上。例如订单金额需要扣除退款金额,退款数据在另一张表;城市名称需要通过维度表映射;订单状态需要过滤掉未支付和已取消的记录。如果把这些逻辑全部塞进一个很长的 GROUP BY 查询里,可读性会明显下降。更合理的做法是先用子查询完成数据口径处理,再让外层查询专注于分组统计。
二、嵌套查询与 GROUPING SETS 的配合方式
嵌套查询配合 GROUPING SETS 的思路很简单:内层子查询负责把数据清理成口径统一、字段明确的中间结果,外层查询再对中间结果做多级聚合。这样职责分离后,内层可以自由使用 WHERE、JOIN、CASE WHEN、子查询甚至窗口函数,外层只需要关心分组维度和聚合指标。
下面是一个销售统计的完整例子。假设订单表 orders 保存订单金额和城市 ID,维度表 dim_region 保存城市、省份、区域名称,退款表 refunds 保存订单级退款金额。现在要按大区、省份、城市输出净销售额,同时也要大区小计和全国总计:
SELECT region_name,
province_name,
city_name,
SUM(net_amount) AS total_net_amount
FROM (
SELECT d.region_name,
d.province_name,
d.city_name,
o.amount - COALESCE(r.refund_amount, 0) AS net_amount
FROM orders o
JOIN dim_region d
ON o.city_id = d.city_id
LEFT JOIN refunds r
ON o.order_id = r.order_id
WHERE o.status = 'paid'
AND o.created_at >= TIMESTAMP '2024-01-01 00:00:00'
) t
GROUP BY GROUPING SETS (
(region_name, province_name, city_name),
(region_name, province_name),
(region_name),
()
);
内层查询完成了三件重要的事:通过 JOIN dim_region 把城市编码转换成可读的区域、省份、城市名称;通过 LEFT JOIN refunds 计算退款,并用 COALESCE 把没有退款记录的订单处理为 0;通过 WHERE 过滤掉非支付状态和指定日期之前的数据。外层拿到的 t 表已经是一张口径干净的明细表,它只负责按照 GROUPING SETS 定义的四个层级做 SUM 聚合。
这种写法的另一个好处是调试方便。当结果金额不对时,可以先单独运行内层查询,确认每一行的净销售额是否符合预期;确认无误后再打开外层的分组逻辑。如果所有逻辑都堆在一个查询里,定位问题会更困难。对于需要多套分组维度的报表,也可以复用同一个内层查询,只修改外层的 GROUPING SETS 组合,降低维护成本。
三、用 GROUPING 和 GROUPING_ID 识别汇总层级
GROUPING SETS 输出结果中,未参与分组的列会显示为 NULL。但 NULL 本身存在歧义:如果维度列数据本身可能为空,那么无法区分这一行到底是因为没有参与分组而产生的汇总 NULL,还是数据本身就缺失。比如城市名称在维度表清洗后通常不会为空,但如果原始数据质量差,城市 ID 无法匹配时可能保留 NULL,这时直接看结果就容易混淆。
SQL 提供了 GROUPING 函数来解决这个问题。GROUPING 接收一个分组列作为参数,当该列未参与当前分组时返回 1,否则返回 0。结合 CASE WHEN 可以把 NULL 转换成更容易阅读的标签。示例中还用多个 GROUPING 函数相加生成 group_level,用于排序和标识汇总层级:
SELECT CASE WHEN GROUPING(region_name) = 1 THEN '全部区域'
ELSE region_name END AS region_label,
CASE WHEN GROUPING(province_name) = 1 THEN '全部省份'
ELSE province_name END AS province_label,
CASE WHEN GROUPING(city_name) = 1 THEN '全部城市'
ELSE city_name END AS city_label,
SUM(net_amount) AS total_net_amount,
GROUPING(region_name) + GROUPING(province_name) + GROUPING(city_name) AS group_level
FROM (
SELECT d.region_name,
d.province_name,
d.city_name,
o.amount - COALESCE(r.refund_amount, 0) AS net_amount
FROM orders o
JOIN dim_region d
ON o.city_id = d.city_id
LEFT JOIN refunds r
ON o.order_id = r.order_id
WHERE o.status = 'paid'
) t
GROUP BY GROUPING SETS (
(region_name, province_name, city_name),
(region_name, province_name),
(region_name),
()
)
ORDER BY group_level, region_label, province_label, city_label;
GROUPING_ID 是另一种快捷函数,它接收多个列并返回一个十进制位掩码。例如对于 (region_name, province_name, city_name) 三个列,城市级明细返回 0,省份级返回 1,区域级返回 3,全国汇总返回 7。不过 GROUPING_ID 在 Oracle、SQL Server 等数据库中可用,MySQL 8.0 只支持 GROUPING 函数,PostgreSQL 也只有 GROUPING。为了兼容更多数据库,示例中使用了 GROUPING 求和的方式得到 group_level,0 表示城市明细,1 表示省份汇总,2 表示区域汇总,3 表示全国汇总。这个 group_level 同样可以用于排序,先展示最细粒度,再展示各级小计和总计。
在实际开发中,建议把 GROUPING 函数放在外层 SELECT 中处理,不要在内层子查询里处理。因为内层数据还处于明细阶段,每条记录都有真实的维度值,还没有产生汇总 NULL。只有外层经过 GROUPING SETS 之后,才会出现需要识别的 NULL。如果在内层使用 GROUPING,函数会因为缺少分组上下文而报错,或者返回固定值。
四、GROUPING SETS 与其他多级聚合方案的对比
除了 GROUPING SETS,SQL 还提供了 ROLLUP 和 CUBE 两种分组扩展。ROLLUP 适合有层级关系的维度,例如区域、省份、城市,它会自动生成从最细粒度到整体汇总的逐级上卷,语法上比 GROUPING SETS 更简洁。CUBE 则生成所有维度的所有可能组合,包括跨维度组合,比如只按省份而不按区域汇总。GROUPING SETS 的优势在于可以精确指定需要的组合,避免 ROLLUP 固定层级和 CUBE 组合爆炸的问题。
从性能角度看,GROUPING SETS 通常优于手工 UNION ALL。UNION ALL 的每个分支都是一个独立查询,即使数据库优化器识别出多个分支访问同一张表,合并扫描也需要额外的优化能力。GROUPING SETS 则是语法层面的直接声明,优化器可以更早地进行扫描共享和分组下推。但也要注意,GROUPING SETS 生成的分组组合越多,内存和临时表空间占用越大,尤其在分组键基数很高时,多层汇总会导致结果集迅速膨胀。因此不要把所有维度组合都无脑列出来,只保留业务真正需要的层级。
兼容性方面,GROUPING SETS 在 PostgreSQL、SQL Server、Oracle、MySQL 8.0 及以上版本中都有支持,但 MySQL 5.7 及更早版本不支持。迁移老系统时需要先确认数据库版本。如果遇到不支持的数据库,一种替代方案是使用多个 GROUP BY 加 UNION ALL,并尽量把公共过滤条件下推到每个分支中,减少重复扫描。另一种方案是在应用层做多级汇总,把明细数据取到内存中,按不同维度分别聚合,但这会带来较大的网络传输和内存压力,只适合数据量较小的场景。
最后还要注意索引设计。嵌套查询中的内层过滤条件如果选择性较高,应在 orders.status、orders.created_at、orders.city_id 等列上建立合适索引;如果退款表参与 JOIN,refunds.order_id 也需要索引。外层 GROUPING SETS 的分组列来自内层结果,这些列通常无法直接走索引,因为中间结果可能已经物化到临时表,重点应放在减少内层扫描行数和输出行数上。
SQL GROUPING SETS嵌套查询多级分组统计修改时间:2026-09-28 03:05:10