在关系型数据库里,GROUP BY并不是简单把选定列拼成唯一组合,而是按照你写的字段顺序建立一层套一层的分组结构。当使用多个字段时,数据库先依据第一个字段将数据集划分成若干大组,然后在每个大组内部再依据第二个字段划分子组,以此类推。这种层级关系决定了聚合函数的作用范围,也决定了排序与临时表的使用方式。

一、GROUP BY多字段的执行逻辑
从执行计划角度看,GROUP BY多个字段等价于生成一个复合分组键。假设我们有字段A、B、C,写上GROUP BY A, B, C,数据库会先扫描数据,按A的值做第一次散列或排序分桶;在同一个A值范围内,再按B的值继续切分;最后在A和B都相同的记录里按C区分。聚合函数如SUM、COUNT只会在最底层的分组(即A、B、C都相同的那一组)上计算,随后如果上卷查询,才可能在更高层级汇总。
这种顺序意味着字段的书写顺序在语义上就是分组的嵌套顺序。虽然标准SQL只要求分组键唯一,不强制物理执行一定按此顺序,但绝大多数优化器会沿用书写顺序来构建分组层次,尤其在没合适索引时,会先排序第一个字段,再排第二个。因此理解顺序有助于预测执行计划和性能瓶颈。
1.1 用示例表说明
我们创建一张销售表,包含地区、年份、品类、销售额。下面的建表与插入语句用于后续演示:
CREATE TABLE sales (
region VARCHAR(20),
year INT,
category VARCHAR(20),
amount DECIMAL(10,2)
);
INSERT INTO sales VALUES
('华东', 2021, '手机', 1000.00),
('华东', 2021, '电脑', 1500.00),
('华东', 2022, '手机', 1200.00),
('华北', 2021, '手机', 900.00),
('华北', 2022, '电脑', 1100.00);
当我们执行带三个字段的分组时,可以直观看到层级:先分地区,地区内部分年份,同年内再分品类。每一行结果都对应最细粒度的组合。
SELECT region, year, category, SUM(amount) AS total FROM sales GROUP BY region, year, category;
二、多级分组统计的常见写法
实际业务中往往既要最细粒度,也要各层级小计。单纯写一条GROUP BY只能得到最底层,要得到地区合计、地区加年份合计,需要借助扩展语法或UNION ALL拼接。
2.1 使用ROLLUP获取层级汇总
ROLLUP是标准SQL的扩展,它按分组字段顺序自动生成从右往左逐步上卷的小计。对GROUP BY region, year, category使用ROLLUP,会额外产出(region, year)小计、(region)小计以及总计。注意ROLLUP的层级严格依赖字段顺序,把region放第一,就能先得到地区汇总。
SELECT region, year, category, SUM(amount) AS total FROM sales GROUP BY ROLLUP(region, year, category);
上述语句结果里,category为NULL且year非NULL时,代表该地区该年份所有品类合计;region、year、category全NULL则是全局总计。这种方式比手写多个GROUP BY再UNION更简洁,也能让优化器复用一次扫描。
2.2 使用UNION ALL手动拼层级
如果数据库不支持ROLLUP,可以分别统计各层级再用UNION ALL合并。虽然繁琐,但逻辑清晰,且能灵活控制哪些层级需要。
SELECT region, year, category, SUM(amount) AS total FROM sales GROUP BY region, year, category UNION ALL SELECT region, year, NULL, SUM(amount) AS total FROM sales GROUP BY region, year UNION ALL SELECT region, NULL, NULL, SUM(amount) AS total FROM sales GROUP BY region;
手动拼接时要注意NULL在结果中的含义,最好在应用层或SQL里用常量标注层级,避免和真实NULL混淆。另外每个子查询都会扫描一次表,数据量大时不如ROLLUP高效。
三、执行顺序对性能与索引的影响
分组字段的顺序不仅改变结果层级,也改变数据库构建分组的代价。如果第一个字段基数极低,比如只有'是'与'否',那么首次分桶几乎没减少数据量,后续字段还要在几乎全量数据上继续排序,临时表会非常庞大。
3.1 索引如何匹配分组顺序
当存在复合索引(region, year, category)时,数据库可顺着索引顺序读取已排序数据,GROUP BY region, year, category能避免额外排序。但若写成GROUP BY category, year, region,索引顺序和分组顺序不一致,通常仍需重排。因此写多字段GROUP BY时,尽量让前面字段和常用索引前缀一致。
-- 假设有索引 idx_region_year_cat (region, year, category) -- 以下语句易命中索引避免排序 SELECT region, year, SUM(amount) FROM sales GROUP BY region, year;
如果业务必须按category先分组,可以考虑建(category, region, year)的索引,或者接受排序开销。在慢查询排查中,执行计划里的Using temporary、Using filesort往往就和多字段分组顺序不当有关。
3.2 聚合口径错误案例
新手常把COUNT(*)和COUNT(DISTINCT)混用。在多级分组里,COUNT(*)统计的是最细分组内的行数;若想统计某个层级下不重复的客户数,应把DISTINCT放在对应子查询里先算好,再上卷,否则直接在粗粒度上COUNT(DISTINCT)会跨子组去重,导致数值偏小。
-- 错误:在地区层级直接对品类去重计数无意义 SELECT region, COUNT(DISTINCT category) FROM sales GROUP BY region; -- 正确:先按最细粒度聚合,再上卷求和 SELECT region, SUM(cnt) AS category_sales_count FROM ( SELECT region, category, COUNT(*) AS cnt FROM sales GROUP BY region, category ) t GROUP BY region;
通过子查询明确每一层聚合的对象,可以避免口径偏差。多级分组统计的核心就是先想清楚层级,再决定字段顺序与聚合位置。
四、总结与实践建议
面对多字段GROUP BY,先画出字段层级树:谁在外、谁在内。需要上卷汇总优先用ROLLUP,并注意字段顺序就是上卷顺序。建索引时让前缀匹配分组前字段,减少排序。对去重类指标,先在细粒度算完再汇总。掌握这些,就能写出结果正确且高效的SQL多级统计语句。