GROUPING SETS 让一条 SQL 同时生成多种聚合层级。销售报表可以一起返回地区与商品明细汇总、地区小计和总计。结果中混合了不同种类的行,因此,识别每行属于哪个层级,与算对金额同样重要;如果下游把所有结果行都当成普通明细,即使数据库计算正确,报表仍可能出错。
声明报表需要的层级
GROUPING SETS ((region,product),(region),()) 声明三种分组。第一种为每个实际出现的地区与商品组合输出一行,第二种为每个地区输出一行,空集合则汇总全部选中的输入行。这是在声明结果语义,具体执行方式由优化器选择。它可以在不同层级共享工作,但语法本身不保证固定的物理扫描次数,也不会消除排序和内存成本。
ROLLUP(region,product) 是相同前缀集合的简写,并包含空集合。交换两列顺序,会改变小计维度。CUBE(region,product) 还包括只按商品汇总的层级,覆盖维度的全部子集。六个独立维度对应六十四种分组集合,不是只输出六十四行;每种集合还可能包含很多不同分组,所以结果规模可能远大于层级数。
所有层级都汇总 FROM 和 WHERE 选出的总体。如果过滤条件只保留某个月的销售,总计也只代表该月。如果不同层级需要不同输入总体,直接对一个共享输入声明这些集合,可能并不符合需求。比较另一张报表的总额之前,要先确认日期、业务状态等筛选范围一致,不能只对照列名。
用 GROUPING 标记层级
某个分组集合没有使用的维度列,会在输出中填入 SQL NULL。但原始数据本来就可能含有 NULL,因此仅靠哪些列为空,无法可靠判断这是小计还是普通分组。GROUPING(region,product) 返回位掩码,说明当前集合缺少了哪些分组表达式;最右边的参数对应最低有效位。
在这里,零表示两个维度都参与分组,一表示省略商品,三表示两个维度都省略。真实空地区的小计与全表总计,可能同时显示空地区、空商品,但掩码不同。应把这个标记纳入输出契约,只有层级明确表示跨地区汇总时,才生成“所有地区”等展示标签。
表达式是否参与分组,与表达式的值是否非空,是不同问题。GROUPING 报告前者,其参数必须匹配查询中的分组表达式,并不是测试任意值是否为空的普通函数。向下游提供数值层级或其他无歧义标识,比把每个空值都替换为“小计”更可靠,也能保留真实缺失维度的含义。
坏掉的样子
把结果中所有 total 再相加,会让同一笔销售在每个层级重复计入。需要进一步汇总时,应先选择一个层级,或直接取已经算好的总计。平均值和去重计数更不能随意相加:没有按样本数加权的地区平均值,通常不能直接平均成整体平均值;重叠集合的去重数量也不能简单求和。聚合层级不是彼此独立的新增交易。
空分组集合在没有输入行时仍形成一个分组,但后续 HAVING 条件可以把这行过滤掉;没有这类过滤时,其中 count 为零,sum 为 NULL。因此,金额为空不等于查询没有返回总计行。应明确报表如何展示空总体,而不是把空值自动当作执行失败。还要避免无意重复声明分组集合;重复层级可能产生重复聚合行,并不代表多了一份独立证据。
快速验证
把下面内容保存为 grouping.sql,在可丢弃的 PostgreSQL 18 数据库中执行 psql -X -v ON_ERROR_STOP=1 -f grouping.sql。数据特意包含一个真实空地区,脚本用事务包住临时对象,末尾回滚清理。
\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE sales (region text, product text, amount integer);
INSERT INTO sales VALUES ('east','A',10),('east','B',20),(NULL,'A',5);
CREATE TEMP VIEW levels AS
SELECT region,product,sum(amount) AS total,GROUPING(region,product) AS level
FROM sales GROUP BY GROUPING SETS ((region,product),(region),());
SELECT * FROM levels ORDER BY level,region NULLS LAST,product NULLS LAST;
SELECT sum(total) AS incorrect_retotal,
sum(total) FILTER (WHERE level=0) AS detail_total FROM levels;
CREATE TEMP VIEW empty_level AS
SELECT count(*) AS n,sum(amount) AS total FROM sales WHERE false GROUP BY GROUPING SETS (());
SELECT * FROM empty_level;
DO $$ BEGIN
IF (SELECT count(*) FROM levels) <> 6 THEN RAISE EXCEPTION 'wrong level count'; END IF;
IF (SELECT total FROM levels WHERE level=3) IS DISTINCT FROM 35 THEN
RAISE EXCEPTION 'wrong grand total';
END IF;
IF (SELECT total FROM levels WHERE level=1 AND region IS NULL) IS DISTINCT FROM 5 THEN
RAISE EXCEPTION 'real null region subtotal lost';
END IF;
IF (SELECT count(*) FROM levels WHERE region IS NULL AND product IS NULL) <> 2 THEN
RAISE EXCEPTION 'test did not include ambiguous NULL labels';
END IF;
IF (SELECT n FROM empty_level) IS DISTINCT FROM 0::bigint OR
(SELECT total FROM empty_level) IS NOT NULL THEN
RAISE EXCEPTION 'wrong empty-input aggregate';
END IF;
END $$;
ROLLBACK;
PostgreSQL 18.6 返回六行:三个明细分组、两个地区小计和一个总计。east 小计为三十;真实空地区小计为五,掩码是一;全体总计为三十五,掩码是三。后两行的地区与商品列都为 NULL,直接证明依赖空值生成标签存在歧义。这里必须检查层级,而不是根据某一列看起来空白就推断。
错误地再次汇总全部 total,会得到一百零五;只汇总明细层则得到三十五。空输入查询仍返回一行,计数为零、总额为 NULL。断言检查层级行数、总计、真实空地区小计和空输入行为。最终 ORDER BY 明确安排展示顺序;GROUPING SETS 本身不承诺小计一定紧挨明细出现,消费方不能把偶然的输出排列当成分组契约。
来源
- PostgreSQL 表表达式,分组集合、ROLLUP、CUBE 与省略维度,已核验 2026-10-15。
- PostgreSQL 聚合函数,GROUPING 位掩码和空输入聚合,已核验 2026-10-15。
- PostgreSQL 18: SELECT 与 HAVING,HAVING 对聚合分组的过滤,已核验 2026-10-15。