GROUP BY慢不一定没走索引,但索引列顺序必须严格匹配GROUP BY列顺序且不能跳过前导列;函数、NULL值、列顺序错误均会导致索引失效。

GROUP BY慢,是不是没走索引?
先明确一点:不是所有的 GROUP BY 操作都能自动享受到索引的红利。无论是 MySQL(包括最新的8.0+版本)还是 PostgreSQL 这类主流数据库引擎,它们复用索引来加速分组是有严格前提的。这个前提可以概括为一句话:索引列的顺序必须与 GROUP BY 子句中列的顺序严丝合缝地匹配,并且不能跳过索引的前导列。
举个例子就清楚了:GROUP BY a, b 可以利用索引 (a, b, c),因为分组列顺序与索引前缀完全一致。但如果索引是 (b, a) 或者 (a, c),对不起,这个索引对本次分组就基本无效了。
怎么判断索引有没有生效?看执行计划(EXPLAIN)的输出。如果看到 type=ALL(全表扫描),或者 Extra 字段里出现了 Using temporary; Using filesort 这样的字眼,那就等于数据库在坦白:“我没办法,只能建临时表、做文件排序来完成分组了。”——这通常就是索引完全失效的明确信号。
多列索引该按什么顺序写?
这是设计索引时的核心问题。记住一个核心原则:把 GROUP BY 的列放在索引的最左边,紧接着是 WHERE 条件中的列(如果存在的话),如果查询中还有 ORDER BY,并且希望避免额外的排序开销,可以把排序列追加在索引末尾。 这个顺序错一个,索引的效力就可能大打折扣。
来看一个典型的查询:
SELECT dept, status, COUNT(*)
FROM user
WHERE city = 'Beijing'
GROUP BY dept, status
ORDER BY status
针对这个查询,最优的索引设计是:CREATE INDEX idx_dept_status_city ON user (dept, status, city)。这里要注意,city 被放在了最后,因为它是 WHERE 的过滤条件,而非分组依据;同时,由于 status 已经在索引中且顺序符合 ORDER BY,额外的排序步骤也被省去了。
我们来对比几种不同的索引顺序:
(city, dept, status)❌:索引的前导列是city,它并不在GROUP BY的列中,导致索引无法用于高效的分组操作。(dept, city, status)⚠️:city插在了dept和status中间,破坏了分组列在索引中的连续性。这会导致ORDER BY status无法被索引覆盖,可能仍需额外排序。(dept, status)✅:这个索引基础可用,能加速分组。但如果加上city形成覆盖索引,可以让WHERE过滤也走索引,进一步减少回表查询,性能更佳。
NULL值和函数会让索引直接失效
这是一个常见的“坑”。只要 GROUP BY 的表达式里出现了函数调用或者隐式的类型转换,比如 GROUP BY UPPER(name) 或者 GROUP BY DATE(create_time),那么即使对应的字段上有索引,优化器通常也会放弃使用,查询会退化为全表扫描。
同理,如果分组列允许为 NULL,并且表中存在大量 NULL 值时,部分数据库引擎(例如一些旧版本的 MySQL)可能会拒绝使用索引进行分组。这背后的原因在于,NULL 值在 B+ 树索引的排序和比较逻辑中处理方式特殊。
针对这些问题,有几个实操建议:
- 尽量避免在
GROUP BY子句中直接使用函数。如果业务必须,可以考虑创建函数索引(MySQL 8.0+ 支持,例如CREATE INDEX ... (UPPER(name)))。 - 确认用于分组的列是否允许
NULL。如果业务逻辑允许,将其设为NOT NULL并赋予默认值,往往能避免一些潜在的性能问题。 - 使用更详细的执行计划命令来验证,例如 MySQL 8.0 的
EXPLAIN FORMAT=TREE或 PostgreSQL 的EXPLAIN (ANALYZE, BUFFERS),这能更清晰地展示是否真正使用了索引扫描(Index Scan)而非全索引查找(Full Index Lookup)或全表扫描。
聚合字段要不要放进索引?
答案很明确:通常不需要。索引本身并不存储 COUNT(*)、SUM(amount) 这类聚合函数的结果,它只负责加速数据的分组和定位过程。盲目地把聚合函数涉及的字段也塞进索引,只会增加索引的体积,拖慢数据写入和更新的速度,对提升 GROUP BY 的性能几乎没有直接帮助。
这里有一个例外情况,就是覆盖索引(Covering Index)。例如,查询是 SELECT dept, COUNT(*) FROM user GROUP BY dept,而你恰好为 dept 字段建立了索引。这时,数据库引擎可以仅通过扫描索引树就获取到所有分组需要的信息,完全不需要回表去查找数据页,效率极高。但是,如果查询中还包含了其他未被索引覆盖的字段,比如 SELECT dept, name, COUNT(*) ...,那么无论索引里有多少列,数据库最终都免不了要回表查询,覆盖索引的优势也就不复存在了。
所以,设计索引时的重点永远是:优先确保分组列和过滤列能够高效地利用索引,而不是简单地把 SELECT 后面的所有字段都堆砌进去。
最后提一个容易被忽略的点:索引的列顺序一旦确定,后续如果新增了 WHERE 条件,或者调整了 GROUP BY 列的顺序,很可能会导致现有的索引完全失效,而不是“性能略有下降”。因此,在应用上线前,务必使用接近真实数据量的样本,通过 EXPLAIN 等手段进行充分的验证。
