嵌套 group by 性能差的本质是多次全表扫描、临时排序和重复聚合;应拆为两步:先物化中间聚合结果到临时表并建索引,再对外层查询优化。

为什么嵌套 GROUP BY 会慢得离谱
本质是数据库被迫多次扫描、临时排序、反复聚合。比如外层 GROUP BY 依赖内层子查询结果,而子查询又带 GROUP BY 和 JOIN,执行计划里常出现 Using temporary; Using filesort —— 这不是警告,是性能已崩的诊断书。
常见错误现象:EXPLAIN 显示 type=ALL 或 rows 值远超表实际行数;查询耗时随数据量非线性暴涨(10万行还秒出,100万行卡住20秒)。
- 别指望加索引能救嵌套分组:索引对子查询输出无直接作用,尤其当子查询含聚合或
DISTINCT - MySQL 5.7+、PostgreSQL 12+ 对 CTE 的物化支持有限,
WITH子句未必真缓存中间结果 - 如果外层只取前几条(如
LIMIT 10),但内层仍全量聚合,属于典型“算得多、用得少”
把嵌套 GROUP BY 拆成两步物理表 + 索引
核心思路:用临时表/物化视图承载中间聚合结果,让外层查询变成简单 SELECT ... FROM temp_table GROUP BY,彻底规避嵌套执行路径。
使用场景:报表类查询、后台批处理、BI 工具底层 SQL(可接受分钟级延迟)。
- 先建临时表:
CREATE TEMPORARY TABLE tmp_agg AS SELECT user_id, COUNT(*) cnt, AVG(amount) avg_amt FROM orders WHERE status = 'paid' GROUP BY user_id;
- 给临时表加索引:
CREATE INDEX idx_tmp_user ON tmp_agg(user_id); - 外层改写为:
SELECT region, SUM(cnt), AVG(avg_amt) FROM tmp_agg JOIN users USING(user_id) GROUP BY region; - 注意:临时表生命周期仅限当前连接;若需跨会话,改用普通表 + 定期
TRUNCATE或分区清理
HAVING 放错位置导致无法下推过滤
很多人把本该在子查询里做的过滤,拖到外层 HAVING,结果数据库必须先完成全部聚合,再筛——白白多算90%数据。
错误示例:
SELECT dept, COUNT(*) FROM (SELECT dept, emp_id FROM employees) t GROUP BY dept HAVING COUNT(*) > 100;其实
dept 分布极不均衡,99% 的部门只有个位数员工,但外层 HAVING 强制每个部门都算完才过滤。
- 正确做法:把过滤条件尽可能下沉。上例应写成:
SELECT dept, COUNT(*) FROM employees GROUP BY dept HAVING COUNT(*) > 100; - 涉及多表关联时,
WHERE条件必须放在JOIN之前,而非塞进外层HAVING - PostgreSQL 中
HAVING可引用窗口函数别名,但 MySQL 不行;跨版本迁移时容易漏掉这个兼容性坑
用 ROLLUP 或 GROUPING SETS 替代手工嵌套
当目标是生成多粒度汇总(如按省、市、区三级统计),硬写三层子查询不仅慢,还难维护。原生分组扩展语法能一次扫描产出所有层级。
适用数据库:MySQL 8.0+(ROLLUP)、PostgreSQL 9.5+(GROUPING SETS)、SQL Server。
- 替代三层嵌套:
SELECT province, city, district, SUM(sales) FROM sales GROUP BY province, city, district WITH ROLLUP;
-
GROUPING()函数识别空值是否由ROLLUP生成,避免把真实 NULL 当汇总行误判 - 注意
ROLLUP顺序敏感:GROUP BY a, b WITH ROLLUP产生 (a,b)、(a,NULL)、(NULL,NULL),不是 (b,NULL) - 如果只要特定组合(比如省+市、省+区,不要纯省),用
GROUPING SETS ((province,city), (province,district))更精准
真正难的是判断哪一层该提前裁剪、哪一层必须保留全量。比如用户行为日志按会话聚合后再按设备类型分组,session_id 的基数太高,强行先 GROUP BY session_id 就会撑爆内存——这时候就得换思路,用近似算法或预聚合表。










