group by性能崩掉主因是分组逻辑失控而非数据量大,高基数字段直接分组导致磁盘临时表、索引失效,应通过join降维、子查询预聚合、避免函数分组等方式优化。

GROUP BY 性能崩掉,八成不是数据量太大,而是分组逻辑失控——Using temporary; Using filesort 出现在 EXPLAIN 的 Extra 列里,就说明 MySQL 已经放弃索引优化,开始往磁盘上写临时表了。
为什么 GROUP BY 会触发磁盘临时表
每多一个分组字段,潜在分组数不是线性增长,而是乘积级膨胀。比如 GROUP BY region, city, user_id,哪怕只有 100 个 region、1000 个 city,百万用户也意味着上亿个桶。高基数列(如 user_id、ip_address)直接进 GROUP BY,等于主动关闭松散索引扫描(Using index for group-by)。
tmp_table_size 和 max_heap_table_size 调再大也扛不住——磁盘 I/O 一上来,整个实例响应就拖慢。SHOW PROCESSLIST 里频繁出现 Coping to tmp table on disk,就是明确警报。
- 不是“加索引就能解决”,而是分组维度本身已超出数据库合理处理范围
-
GROUP BY字段总长度超 1024 字节会直接报错(ERROR 1118),不是慢,是根本执行不了 - 函数或表达式(如
GROUP BY DATE(created_at))会让索引完全失效,优化器只能全表扫
用 JOIN 替代原始粒度分组
真实业务中,90% 的报表不需要按 user_id 聚合,需要的是它归属的低基数维度——这些字段通常已在关联表中存在,且基数极低。
查“各地区高价值用户订单数”,别写 GROUP BY region, user_id,改用:
SELECT o.region, u.tier, COUNT(*) FROM orders o JOIN users u ON o.user_id = u.id GROUP BY o.region, u.tier;
查“各渠道新用户来源分布”,别 GROUP BY channel, ip_address,先通过 IP 库映射出 country 或 isp,再 GROUP BY channel, country。
-
JOIN条件必须走索引:比如users.id是主键,ip_geo.ip_range上得有区间索引,否则只是把性能问题从GROUP BY转移到JOIN - 避免在
ON条件里用函数(如ON SUBSTRING(o.ip, 1, 7) = g.prefix),这会让JOIN失去索引能力 - 维度表主键建议用整型代理键(如
region_id),不用字符串自然键,减少比较开销
用子查询先聚合再 JOIN
当必须保留原始粒度(比如导出明细 + 汇总统计共存),强行在主查询堆字段会让优化器彻底放弃索引策略。更稳的做法是把高成本聚合下沉到子查询。
错误写法:
SELECT o.region, u.city, u.department, COUNT(*) FROM orders o JOIN users u ON o.user_id = u.id GROUP BY o.region, u.city, u.department;
正确拆解:
SELECT t1.region, t2.city, t2.department, t1.cnt FROM ( SELECT region, user_id, COUNT(*) cnt FROM orders GROUP BY region, user_id ) t1 JOIN ( SELECT id, city, department FROM users ) t2 ON t1.user_id = t2.id;
- 子查询体积小、速度快;外层
JOIN后的数据集已大幅缩小,GROUP BY不再扫全表 - 若需分页(如
LIMIT 80,20),必须放在子查询里,否则外层仍要处理全部分组 - MySQL 5.7 不支持
JSON_OBJECTAGG,可用GROUP_CONCAT(CONCAT(k, ':', v))模拟,但注意group_concat_max_len限制
建索引时必须匹配 WHERE + GROUP BY 顺序
索引不是越多越好,但对 GROUP BY 场景,索引必须覆盖“过滤 → 分组 → 排序”三阶段的访问路径。核心原则:索引字段顺序 = WHERE 等值条件列 + GROUP BY 列(按出现顺序)。
例如查询:
SELECT user_id, COUNT(*) FROM orders WHERE shop_id = 123 GROUP BY user_id;
最优索引是:
CREATE INDEX idx_orders_shop_user ON orders (shop_id, user_id);
-
shop_id放最左:满足WHERE等值过滤,快速定位数据段 -
user_id紧随其后:使该段内数据天然按user_id物理有序,GROUP BY无需额外排序 - 不要加
status或created_at除非它们也在WHERE中参与等值匹配;范围查询(如created_at > '2024-01-01')只能放最后,且会截断索引有效长度
最容易被忽略的一点:即使你建了 (a, b) 索引,GROUP BY b, a 依然大概率触发临时表——顺序错,索引就废了一半。











