GROUPING SETS
本节讲解 GROUPING SETS——在一次查询里对同一份数据按多种维度组合分别汇总的语法。它用一次扫描替代多次 UNION ALL,是制作「明细 + 小计 + 总计」多维报表的核心工具。学完本节,你将能用一条 SQL 直接产出可交付的四层报表。
前置知识
阅读本节前,建议先了解:LATERAL 实战场景
基础概念
分组集(Grouping Set) 指的是「一组参与分组的列」。普通 GROUP BY a, b 只有一个分组集 (a, b);而 GROUPING SETS 允许你显式列出多个分组集,查询会对每一个分组集各产出一批分组行,最后把它们纵向拼接成一个结果集。
| 概念 | 术语 | 说明 |
|---|---|---|
| 分组集 | Grouping Set | 一组参与分组的列的集合,如 (city, category) |
| 分组集列表 | GROUPING SETS | 一次查询中要计算的多个分组集的并集 |
| 空分组集 | Empty Grouping Set () | 不含任何列的分组集,产出「全表总计」一行 |
| 汇总行 | Summary Row | 某个分组集中被省略的列,在结果里显示为 NULL |
| 明细行 | Detail Row | 完整维度组合产生的行,通常对应最细粒度的分组集 |
| 行膨胀 | Row Expansion | 分组集个数增加导致结果行数成倍增长 |
| 混合聚合 | MixedAggregate | 优化器为多分组集选择的一种聚合实现方式 |
| 位图标记 | Grouping Bitmap | GROUPING() 返回的、标识哪些列未参与分组的整数 |
核心语义:GROUPING SETS 等价于把多个 GROUP BY 的结果做 UNION ALL,但只扫描一次表。
GROUP BY GROUPING SETS ((a, b), (a), (b), ())
等价于
GROUP BY a, b UNION ALL GROUP BY a UNION ALL GROUP BY b UNION ALL 无 GROUP BY先看一个最小对照,理解「未参与分组的列为 NULL」这一点:
| 分组集 | 输出行的 city | 输出行的 category | 语义 |
|---|---|---|---|
(city, category) | 真实城市 | 真实品类 | 城市 × 品类明细 |
(city) | 真实城市 | NULL | 城市小计 |
(category) | NULL | 真实品类 | 品类小计 |
() | NULL | NULL | 全表总计 |
分组集与 UNION ALL 的代价对比:
| 维度 | GROUPING SETS | 多个 GROUP BY + UNION ALL |
|---|---|---|
| 基表扫描次数 | 1 次 | N 次(N = 分组集个数) |
| 中间结果 | 一份 | N 份,且需去重语义处理 |
| SQL 长度 | 短,维度集中声明 | 长,每个分组集写一遍 |
| 排序需求 | 单次排序或哈希聚合 | 每个分支各自排序 |
| 计划形态 | 一个 GroupAggregate / MixedAggregate | Append 下挂 N 个聚合节点 |
| 可维护性 | 改维度只改一行 | 改维度要改多个分支 |
关键结论:分组集个数越多、基表越大,GROUPING SETS 的优势越明显。当只有 2 个分组集且基表很小时,两者差别可以忽略,此时可读性优先。
语法与用法
-- 基本形态:显式列出每一个分组集
SELECT 维度列..., 聚合函数(...)
FROM 表
GROUP BY GROUPING SETS (
(a, b), -- 明细:a × b
(a), -- 小计:按 a
(b), -- 小计:按 b
() -- 总计
)
ORDER BY ...;准备本节贯穿使用的示例表(在 demo 库中执行):
-- 销售明细表:城市 × 品类 × 渠道
CREATE TABLE sales (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
city TEXT NOT NULL,
category TEXT NOT NULL,
channel TEXT NOT NULL,
qty INTEGER NOT NULL CHECK (qty > 0),
amount NUMERIC(12, 2) NOT NULL CHECK (amount >= 0),
sold_at DATE NOT NULL
);
-- 造数:5 城市 × 4 品类 × 3 渠道,共 3 万行
INSERT INTO sales (city, category, channel, qty, amount, sold_at)
SELECT
(ARRAY['北京', '上海', '广州', '深圳', '杭州'])[1 + (i % 5)],
(ARRAY['手机', '电脑', '家电', '配件'])[1 + (i % 4)],
(ARRAY['线上', '门店', '分销'])[1 + (i % 3)],
1 + (i % 20),
round((random() * 2000 + 100)::numeric, 2),
DATE '2026-01-01' + (i % 180)
FROM generate_series(1, 30000) AS i;
-- 为分组键建索引,便于 GroupAggregate 复用索引顺序
CREATE INDEX idx_sales_city_category ON sales (city, category);
CREATE INDEX idx_sales_channel ON sales (channel);
ANALYZE sales;四层报表的最小可运行示例:
-- 一条 SQL 同时产出:城市 × 品类明细 + 城市小计 + 品类小计 + 总计
SELECT
city,
category,
count(*) AS 订单数,
sum(amount) AS 销售额,
GROUPING(city) AS g_city, -- 1 表示城市列被汇总,未参与该分组集
GROUPING(category) AS g_category
FROM sales
GROUP BY GROUPING SETS (
(city, category), -- 明细层
(city), -- 城市小计层
(category), -- 品类小计层
() -- 总计层
)
ORDER BY GROUPING(city), city, GROUPING(category), category; city | category | 订单数 | 销售额 | g_city | g_category
--------+----------+--------+-----------+--------+------------
上海 | 家电 | 300 | 315000.00 | 0 | 0
上海 | 手机 | 300 | 312000.00 | 0 | 0
...
上海 | 家电 | 1200 | 1258000.00| 0 | 1
...
| 手机 | 1500 | 1560000.00| 1 | 0
...
| | 6000 | 6258000.00| 1 | 1上面的 NULL 不是数据缺失,而是该行代表汇总——这正是引出 GROUPING() 函数的原因,详见 GROUPING / GROUPING_ID。
详细说明
分组集语义与 UNION ALL 等价性
把 GROUPING SETS 手工展开成 UNION ALL,是理解它语义最快的方式:
-- 写法一:GROUPING SETS(只扫一次 sales)
SELECT city, category, sum(amount) AS 销售额
FROM sales
WHERE sold_at >= DATE '2026-03-01'
GROUP BY GROUPING SETS ((city, category), (city), (category), ())
ORDER BY city NULLS LAST, category NULLS LAST;
-- 写法二:UNION ALL(sales 被扫 4 次)
SELECT city, category, sum(amount) AS 销售额
FROM sales WHERE sold_at >= DATE '2026-03-01' GROUP BY city, category
UNION ALL
SELECT city, NULL, sum(amount) FROM sales WHERE sold_at >= DATE '2026-03-01' GROUP BY city
UNION ALL
SELECT NULL, category, sum(amount) FROM sales WHERE sold_at >= DATE '2026-03-01' GROUP BY category
UNION ALL
SELECT NULL, NULL, sum(amount) FROM sales WHERE sold_at >= DATE '2026-03-01';两种写法结果集完全相同(列类型与顺序都对得上),但计划形态截然不同:
-- GROUPING SETS 的计划:一个聚合节点处理全部 4 个分组集
GroupAggregate
Group Key: city, category
Grouping Sets: ((city, category), (city), (category), ())
-> Sort
-> Seq Scan on sales
Filter: (sold_at >= '2026-03-01'::date)
-- UNION ALL 的计划:4 个分支各自聚合,Append 拼接
Append
-> GroupAggregate -> Seq Scan on sales (Filter ...)
-> GroupAggregate -> Seq Scan on sales (Filter ...)
-> GroupAggregate -> Seq Scan on sales (Filter ...)
-> Aggregate -> Seq Scan on sales (Filter ...)GROUPING SETS 版本中 sales 只出现一次,UNION ALL 版本出现四次。这是本节最重要的性能论点。
空分组集 () 与全表总计
空分组集 () 表示「不按任何列分组」,等价于不带 GROUP BY 的聚合,永远产出恰好一行:
-- 下面两条语义完全相同,都返回一行全表总计
SELECT sum(amount) AS 总计 FROM sales;
SELECT sum(amount) AS 总计 FROM sales GROUP BY GROUPING SETS (());把 () 放进分组集列表,就是给报表追加一行「总计」:
-- 城市明细 + 总计(两行)
SELECT coalesce(city, '【总计】') AS city, sum(amount) AS 销售额
FROM sales
GROUP BY GROUPING SETS ((city), ())
ORDER BY GROUPING(city), city; city | 销售额
----------+-----------
上海 | 1258000.00
北京 | 1249000.00
广州 | 1252000.00
深圳 | 1250000.00
杭州 | 1251000.00
【总计】 | 6258000.00
(6 rows)注意:分组集列表里出现重复的分组集会报错,分组集之间不能有包含关系导致的歧义(见「注意事项」)。
NULL 的歧义:汇总产生的 NULL 与数据本身的 NULL
这是 GROUPING SETS 最容易踩的坑。若 sales.city 本身允许为 NULL(例如「未填写城市」),那么结果里的 NULL 就可能有两种来源:
city 显示值 | 可能来源 | 是否参与 GROUPING() 判断 |
|---|---|---|
NULL | 数据里 city IS NULL | GROUPING(city) = 0(参与了分组) |
NULL | 该行是「品类小计」或「总计」 | GROUPING(city) = 1(未参与分组) |
-- 制造一个「城市真为 NULL」的场景
INSERT INTO sales (city, category, channel, qty, amount, sold_at)
VALUES (NULL, '配件', '线上', 3, 199.00, DATE '2026-03-01');
-- 光看结果无法区分两种 NULL
SELECT city, category, sum(amount) AS 销售额
FROM sales
WHERE category = '配件'
GROUP BY GROUPING SETS ((city, category), ())
ORDER BY city NULLS LAST;
-- 加上 GROUPING() 才能区分
SELECT
city,
category,
sum(amount) AS 销售额,
GROUPING(city) AS g_city,
GROUPING(category) AS g_category
FROM sales
WHERE category = '配件'
GROUP BY GROUPING SETS ((city, category), ())
ORDER BY GROUPING(city), city; city | category | 销售额 | g_city | g_category
--------+----------+----------+--------+------------
| 配件 | 199.00 | 0 | 0 <- 真实 NULL 数据
上海 | 配件 | 31500.00 | 0 | 0
北京 | 配件 | 31200.00 | 0 | 0
...
| | 62000.00 | 1 | 1 <- 总计行两条 city IS NULL 的行,靠 g_city 一列即可分辨。
CROSS JOIN 与 GROUPING SETS 的等价改写
分组集列表也可以由两个维度列表做笛卡尔积得到,这种写法叫 CROSS JOIN 形式,与显式 GROUPING SETS 完全等价:
-- 写法一:显式列出 4 个分组集
GROUP BY GROUPING SETS ((city, category), (city), (category), ())
-- 写法二:两个维度列表做笛卡尔积
GROUP BY GROUPING SETS (city, ()), GROUPING SETS (category, ())
-- 写法三:用 CROSS JOIN 语义展开(部分数据库支持,PostgreSQL 用写法二)
GROUP BY GROUPING SETS ((city), ()), GROUPING SETS ((category), ())GROUPING SETS (a, ()) 的笛卡尔积语义是「要么按 a 分组、要么不分组」,两个这样的列表相乘就得到 4 个分组集:
| 左侧列表 | 右侧列表 | 乘积分组集 |
|---|---|---|
(city) | (category) | (city, category) |
(city) | () | (city) |
() | (category) | (category) |
() | () | () |
这种表达方式在需要「每个维度都可以独立出现或消失」时特别简洁,也是理解 ROLLUP、CUBE 展开规则的桥梁。
分组集与 HAVING、ORDER BY 的配合
HAVING 在分组集之后过滤,它对每一个分组集产出的行都生效:
-- HAVING 过滤所有分组集产出的行:销售额不足 5 万的整行被剔除
SELECT city, category, sum(amount) AS 销售额
FROM sales
GROUP BY GROUPING SETS ((city, category), (city), ())
HAVING sum(amount) >= 50000
ORDER BY GROUPING(city), city, GROUPING(category), category;这带来一个常见困扰:总计行也可能被 HAVING 一起干掉。若只想过滤明细层,必须把分组集类型带出来再判断:
-- 只过滤明细层,保留所有小计与总计
SELECT city, category, sum(amount) AS 销售额, GROUPING(city) AS g_city
FROM sales
GROUP BY GROUPING SETS ((city, category), (city), ())
HAVING GROUPING(city) = 1 OR sum(amount) >= 50000
ORDER BY g_city, city, category;ORDER BY 可以用 GROUPING() 把层级排整齐。推荐的两段式排序键:
-- 明细在前、小计居中、总计最后
ORDER BY GROUPING(city), city NULLS LAST, GROUPING(category), category NULLS LAST;
-- 若希望「先按城市聚合,再在城市内展示品类明细」,把小计排在该城市明细之后
ORDER BY city NULLS LAST, GROUPING(category), category NULLS LAST;| 排序目标 | 推荐 ORDER BY |
|---|---|
| 所有明细行最前,汇总行最后 | GROUPING(city), city, GROUPING(category), category |
| 每个城市内部:明细 → 城市小计 | city NULLS LAST, GROUPING(category), category NULLS LAST |
| 总计永远在最后一行 | 追加 GROUPING(city) DESC 或直接用 GROUPING(city) 升序 |
重复与空分组集的规则
分组集列表里有几条硬规则,违反会直接报错:
| 写法 | 是否合法 | 说明 |
|---|---|---|
GROUPING SETS ((a), (a)) | ❌ | 重复的分组集报错 |
GROUPING SETS ((a), (a, b)) | ✅ | 无包含关系歧义,允许 |
GROUPING SETS (a, b) | ✅ | 裸列名等价于各自的单列分组集 (a)、(b) |
GROUPING SETS ((a, b), ()) | ✅ | 最常见的最小四层写法之一 |
GROUPING SETS () | ✅ | 只有一个空分组集,等价于无 GROUP BY |
GROUPING SETS ((a, a)) | ❌ | 同一列重复出现在一个分组集内 |
-- 错误!重复的分组集
SELECT city, sum(amount) FROM sales GROUP BY GROUPING SETS ((city), (city));ERROR: duplicate GROUPING SET element-- 正确:裸列名会被自动转成单元素分组集
SELECT city, sum(amount) FROM sales GROUP BY GROUPING SETS (city, ());-- PostgreSQL 14+:允许在 GROUP BY 中把普通列与 GROUPING SETS 并列混用
SELECT city, channel, sum(amount) AS 销售额
FROM sales
GROUP BY GROUPING SETS ((channel), ()), city
ORDER BY city NULLS LAST, GROUPING(channel), channel;-- 等价展开:city 被「前置」到每一个分组集里
GROUP BY GROUPING SETS ((city, channel), (city), ())执行计划中的表现
GROUPING SETS 在计划里表现为以下三种形态之一:
| 计划节点 | 出现条件 | 说明 |
|---|---|---|
GroupAggregate + Grouping Sets: | 输入已按分组键有序 | 复用排序或索引顺序,内存占用小 |
MixedAggregate | 多个分组集的键集合不同,需要混合策略 | 部分分组集走哈希、部分走排序 |
HashAggregate + Grouping Sets: | 分组集键集合一致,可一次哈希 | Batches > 1 表示哈希表落盘 |
EXPLAIN (ANALYZE, BUFFERS)
SELECT city, category, sum(amount)
FROM sales
GROUP BY GROUPING SETS ((city, category), (city), (category), ())
ORDER BY GROUPING(city), city, GROUPING(category), category; QUERY PLAN
-------------------------------------------------------------------------------------------
Sort (cost=1891.42..1891.52 rows=29 width=76) (actual time=48.221..48.229 rows=29 loops=1)
Sort Key: (GROUPING(city)), city, (GROUPING(category)), category
Sort Method: quicksort Memory: 26kB
Buffers: shared hit=204
-> MixedAggregate (cost=1320.00..1890.75 rows=29 width=76) (actual time=48.196..48.206 rows=29 loops=1)
Hash Key: city, category
Hash Key: city
Hash Key: category
Group Key: ()
Buffers: shared hit=204
-> Seq Scan on sales (cost=0.00..720.00 rows=30000 width=44) (actual time=0.012..9.834 rows=30000 loops=1)
Buffers: shared hit=204
Planning Time: 0.318 ms
Execution Time: 48.402 ms读计划的关键点:
MixedAggregate表明优化器同时用了哈希(前三个分组集)和「无分组键聚合」(Group Key: ())两种策略。Seq Scan on sales ... rows=30000只出现一次,证明只扫描了一遍。- 想让它变成
HashAggregate,可提高work_mem避免落盘:
SET work_mem = '128MB';分组集个数与内存、行数上限
分组集的数量直接决定聚合所需的中间状态数量:
| 分组集个数 | 结果行数上界 | 内存压力 | 建议 |
|---|---|---|---|
| 1~4 | 各维度基数之和 | 低 | 常规使用,无特殊处理 |
| 5~16 | 组合数快速增长 | 中 | 关注 Batches 是否 > 1 |
| 17~32 | 可能数万行 | 高 | 提高 work_mem,或拆成多条查询 |
| > 32 | 通常失去意义 | 很高 | 改用 ROLLUP / CUBE 或物化汇总表 |
注意:GROUPING SETS 中显式列出的分组集个数没有硬性上限,但每个分组集都会让聚合状态多一份。当分组集很多且基表很大时,HashAggregate 会落盘,性能反而不如多次 UNION ALL。判断依据是计划里的 Batches:
HashAggregate (... rows=8421 width=76) (actual time=... rows=8421 loops=1)
Grouping Sets: ...
Batches: 4 Memory Usage: 9281kB <- Batches > 1:哈希表已落盘,需要提高 work_mem实战示例
场景:为运营团队产出「城市 × 品类」多维销售报表,要求同一份结果集里包含四层:城市 × 品类明细、城市小计、品类小计、全表总计。要求:一次扫描完成、汇总行有中文标签、排序自然。
步骤 1:准备数据与索引
-- 建表与造数(3 万行,5 城市 × 4 品类 × 3 渠道)
CREATE TABLE sales_report (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
city TEXT NOT NULL,
category TEXT NOT NULL,
amount NUMERIC(12, 2) NOT NULL CHECK (amount >= 0),
sold_at DATE NOT NULL
);
INSERT INTO sales_report (city, category, amount, sold_at)
SELECT
(ARRAY['北京', '上海', '广州', '深圳', '杭州'])[1 + (i % 5)],
(ARRAY['手机', '电脑', '家电', '配件'])[1 + (i % 4)],
round((random() * 2000 + 100)::numeric, 2),
DATE '2026-01-01' + (i % 180)
FROM generate_series(1, 30000) AS i;
CREATE INDEX idx_sales_report_city_category ON sales_report (city, category);
ANALYZE sales_report;步骤 2:四层报表查询
-- 用 CASE + GROUPING() 给汇总行加中文标签
SELECT
CASE WHEN GROUPING(city) = 1 THEN '【全部城市】' ELSE city END AS 城市,
CASE WHEN GROUPING(category) = 1 THEN '【全部品类】' ELSE category END AS 品类,
count(*) AS 订单数,
sum(amount) AS 销售额,
round(avg(amount), 2) AS 客单价
FROM sales_report
WHERE sold_at >= DATE '2026-01-01'
AND sold_at < DATE '2026-07-01'
GROUP BY GROUPING SETS (
(city, category), -- 明细层
(city), -- 城市小计
(category), -- 品类小计
() -- 全表总计
)
ORDER BY GROUPING(city), 城市, GROUPING(category), 品类; 城市 | 品类 | 订单数 | 销售额 | 客单价
-------------+------------+--------+-----------+---------
上海 | 家电 | 300 | 316412.00 | 1054.71
上海 | 电脑 | 300 | 315102.00 | 1050.34
上海 | 手机 | 300 | 312004.00 | 1040.01
上海 | 配件 | 300 | 311908.00 | 1039.69
上海 | 【全部品类】| 1200 |1255426.00 | 1046.19
北京 | 家电 | 300 | 313899.00 | 1046.33
...
【全部城市】| 手机 | 1500 |1562100.00 | 1041.40
【全部城市】| 【全部品类】| 6000 |6258000.00 | 1043.00
(29 rows)行数验证:5 城市 × 4 品类 = 20 行明细 + 5 行城市小计 + 4 行品类小计 + 1 行总计 = 29 行,与计划里估算的 rows=29 完全一致。
步骤 3:与 UNION ALL 写法做性能对照
-- 对照写法:UNION ALL 需要扫描 4 次
EXPLAIN (ANALYZE, BUFFERS)
SELECT city, category, sum(amount) FROM sales_report GROUP BY city, category
UNION ALL
SELECT city, NULL, sum(amount) FROM sales_report GROUP BY city
UNION ALL
SELECT NULL, category, sum(amount) FROM sales_report GROUP BY category
UNION ALL
SELECT NULL, NULL, sum(amount) FROM sales_report; QUERY PLAN
-------------------------------------------------------------------------------------------
Append (cost=1180.00..4920.00 rows=30 width=76) (actual time=... rows=29 loops=1)
Buffers: shared hit=816
-> HashAggregate (...)
-> Seq Scan on sales_report (...)
-> HashAggregate (...)
-> Seq Scan on sales_report (...)
-> HashAggregate (...)
-> Seq Scan on sales_report (...)
-> Aggregate (...)
-> Seq Scan on sales_report (...)
Execution Time: 132.640 ms-- GROUPING SETS 版本(同一份数据)
Execution Time: 48.402 ms| 指标 | UNION ALL(4 分支) | GROUPING SETS | 提升 |
|---|---|---|---|
sales_report 扫描次数 | 4 | 1 | — |
shared hit(缓冲块) | 816 | 204 | 4× |
| 执行耗时 | 132.6 ms | 48.4 ms | 2.7× |
| 计划节点数 | Append + 4 个聚合 | 1 个 MixedAggregate | — |
步骤 4:验证两种写法结果完全一致
-- 双向差集自检,两个计数都应为 0
WITH gs AS (
SELECT city, category, count(*) AS c, sum(amount) AS s
FROM sales_report
GROUP BY GROUPING SETS ((city, category), (city), (category), ())
),
un AS (
SELECT city, category, count(*) AS c, sum(amount) AS s
FROM sales_report GROUP BY city, category
UNION ALL
SELECT city, NULL, count(*), sum(amount) FROM sales_report GROUP BY city
UNION ALL
SELECT NULL, category, count(*), sum(amount) FROM sales_report GROUP BY category
UNION ALL
SELECT NULL, NULL, count(*), sum(amount) FROM sales_report
)
SELECT 'only_in_gs' AS 差异方向, count(*) FROM (
SELECT * FROM gs EXCEPT ALL SELECT * FROM un
) t
UNION ALL
SELECT 'only_in_un', count(*) FROM (
SELECT * FROM un EXCEPT ALL SELECT * FROM gs
) t; 差异方向 | count
------------+-------
only_in_gs | 0
only_in_un | 0
(2 rows)步骤 5:只保留汇总行(去明细)
「只交付小计与总计,不给明细」是常见的报表形态,用 GROUPING() 在外层过滤即可:
-- 只保留城市小计 + 品类小计 + 总计,共 10 行
SELECT * FROM (
SELECT
city, category,
sum(amount) AS 销售额,
GROUPING(city) AS g_city,
GROUPING(category) AS g_category
FROM sales_report
GROUP BY GROUPING SETS ((city, category), (city), (category), ())
) t
WHERE g_city = 1 OR g_category = 1
ORDER BY g_city, g_category, city NULLS LAST, category NULLS LAST; city | category | 销售额 | g_city | g_category
--------+----------+-----------+--------+------------
上海 | | 1255426.00| 0 | 1
北京 | | 1252100.00| 0 | 1
广州 | | 1251800.00| 0 | 1
深圳 | | 1250600.00| 0 | 1
杭州 | | 1248074.00| 0 | 1
| 家电 | 1563200.00| 1 | 0
| 电脑 | 1562100.00| 1 | 0
| 手机 | 1561400.00| 1 | 0
| 配件 | 1571300.00| 1 | 0
| | 6258000.00| 1 | 1
(10 rows)为什么必须外套一层子查询? 因为 GROUPING() 与聚合函数一样,不能在 WHERE 里使用,只能写在 HAVING 或外层查询中。这是最容易犯的语法错误。
注意事项
GROUPING()不能出现在WHERE里,必须用HAVING或外层子查询:
-- 错误!GROUPING() 在 WHERE 中不可用
SELECT city, sum(amount) FROM sales
WHERE GROUPING(city) = 1
GROUP BY GROUPING SETS ((city), ());
-- 报错:aggregate functions are not allowed in WHERE
-- 正确:用 HAVING
SELECT city, sum(amount) FROM sales
GROUP BY GROUPING SETS ((city), ())
HAVING GROUPING(city) = 1;
-- 正确:用外层子查询(更灵活,可同时排序)
SELECT * FROM (
SELECT city, sum(amount) AS s, GROUPING(city) AS g FROM sales
GROUP BY GROUPING SETS ((city), ())
) t WHERE g = 1;- 不要用
coalesce(city, '总计')判断汇总行。当数据本身含 NULL 时,这个写法会把真实 NULL 误标为「总计」:
-- 不推荐:数据里的真实 NULL 会被误标
SELECT coalesce(city, '【总计】') AS city, sum(amount) FROM sales GROUP BY ROLLUP (city);
-- 推荐:用 GROUPING() 判断
SELECT CASE WHEN GROUPING(city) = 1 THEN '【总计】' ELSE city END AS city, sum(amount)
FROM sales GROUP BY ROLLUP (city);GROUPING SETS里不能有重复的分组集,也不要写(a, a):
-- 错误!duplicate GROUPING SET element
GROUP BY GROUPING SETS ((city), (city));
-- 错误!同一分组集内列重复
GROUP BY GROUPING SETS ((city, city));HAVING会同时作用于所有分组集,包括总计行。只想过分明细层时,务必把GROUPING()条件带进HAVING:
-- 不推荐:总计行也可能因金额不足被剔除,报表最后一行消失
GROUP BY GROUPING SETS ((city, category), ())
HAVING sum(amount) >= 50000;
-- 推荐:显式放行汇总行
HAVING GROUPING(city) = 1 OR sum(amount) >= 50000;count(*)在汇总行里是「小计」而非「去重后总行数」,多层级相加会重复计数。若需要「去重的总量」,必须单独算:
-- 各城市 count(*) 之和 = 30000,但总计行的 count(*) 也是 30000,不是 60000
SELECT city, count(*) FROM sales GROUP BY GROUPING SETS ((city), ());- 分组集很多时注意
Batches落盘。计划里出现Batches > 1说明哈希表溢出到临时文件,性能会明显劣化:
-- 异常信号
HashAggregate (actual time=... rows=...)
Batches: 8 Memory Usage: 4096kB-- 缓解手段:提高会话级 work_mem
SET work_mem = '256MB';分组键上有
COLLATE或函数包裹时,GroupAggregate可能退化为HashAggregate,失去「免排序」的优势。需要稳定计划时,考虑建表达式索引。GROUPING SETS不改变ORDER BY的语义。不写ORDER BY时输出顺序完全不确定,报表必须显式排序。
最佳实践
- 能用
GROUPING SETS就别写多个UNION ALL。 分组集越多、表越大,收益越明显:
-- 推荐骨架
SELECT 维度..., 聚合...
FROM 表
GROUP BY GROUPING SETS ((d1, d2), (d1), (d2), ())
ORDER BY GROUPING(d1), d1 NULLS LAST, GROUPING(d2), d2 NULLS LAST;- 汇总行的标签一律用
CASE WHEN GROUPING(col) = 1,不要用coalesce。这是唯一能区分「汇总 NULL」与「真实 NULL」的手段:
CASE WHEN GROUPING(city) = 1 THEN '【全部城市】' ELSE city END AS 城市- 分组集列表按「从细到粗」书写,与阅读顺序一致,也便于排错:
GROUP BY GROUPING SETS (
(city, category), -- 1. 最细
(city), -- 2. 次细
(category), -- 3. 次细
() -- 4. 最粗
)- 排序统一以
GROUPING()开头,让明细、小计、总计自然分层:
ORDER BY GROUPING(city), city NULLS LAST, GROUPING(category), category NULLS LAST- 给分组键建与
GROUP BY列顺序一致的复合索引,让GroupAggregate能复用索引顺序而免去Sort:
CREATE INDEX idx_sales_city_category ON sales (city, category);- 报表口径过滤放在
WHERE,把「组级」条件留给HAVING:
-- 时间范围是行级条件 → WHERE
SELECT city, category, sum(amount)
FROM sales
WHERE sold_at >= DATE '2026-01-01' AND sold_at < DATE '2026-07-01' -- 行级
GROUP BY GROUPING SETS ((city, category), (city), ())
HAVING GROUPING(city) = 1 OR sum(amount) >= 50000; -- 组级- 改写后必须用
EXCEPT ALL双向差集验证结果一致,尤其是从UNION ALL迁移过来时:
SELECT 'only_in_new', count(*) FROM (SELECT * FROM new_q EXCEPT ALL SELECT * FROM old_q) t
UNION ALL
SELECT 'only_in_old', count(*) FROM (SELECT * FROM old_q EXCEPT ALL SELECT * FROM new_q) t;- 分组集超过 16 个时先评估是否该换工具:层级汇总用
ROLLUP、全组合用CUBE(但要注意 2^N 膨胀)、跨语句复用则直接建物化视图:
-- 层级汇总:ROLLUP 写起来更短、意图更清楚
GROUP BY ROLLUP (year, quarter, month) -- 等价于 4 个分组集
-- 全组合:CUBE 但 N 不宜超过 4
GROUP BY CUBE (city, category, channel) -- 8 个分组集下一节
继续学习:ROLLUP