导读:GROUP BY 慢,网上最常见的建议是"给分组字段加索引,消掉 Using temporary"。我在 MySQL 8.2 上拿 400 万行订单表照着做,按店铺统计金额的查询从 1.1 秒变成了 6.9 秒。这篇把 GROUP BY 常见的几种写法都跑了一遍:什么时候加索引有用、什么时候反而更慢、带时间范围的统计索引怎么建、分组数很多时临时表落盘有多慢、排行榜类查询什么时候该上汇总表。
一、先说清楚:GROUP BY 慢在哪
GROUP BY 要做的事很简单:把行按某一列归堆,每堆算一个 COUNT 或 SUM。MySQL 有两种归堆的办法:
- 边扫边建一张临时表 :扫到一行,就去临时表里找它的分组,找到就累加,没有就新建一行。EXPLAIN 里显示
Using temporary - 按索引顺序扫:如果有一个以分组列开头的索引,数据本来就是按分组排好的,扫完一组就出一组结果,不需要临时表
不管它会怎样?表小的时候两种办法都很快,根本感觉不到。数据到了几百万行,统计报表、后台首页的"销量排行"这类查询会从几十毫秒涨到好几秒,而且是每打开一次页面就扫一遍全表。
什么时候值得动它:慢查询日志里出现了带 GROUP BY 的语句,或者某个统计接口明显比其他接口慢。先看 EXPLAIN 再决定加什么索引,下面会看到,照着"消掉 Using temporary"去加索引,可能让查询慢好几倍。
二、实测环境和数据
| 项 | 值 |
|---|---|
| MySQL | 8.2.0(本机二进制 + 单独初始化的数据目录,buffer pool 1 GB) |
| 机器 | Apple M1 Pro |
| 数据 | orders 表 400 万行,数据文件约 188 MB,全部能放进 buffer pool |
| 分布 | 2000 个店铺、约 50 万个用户、5 种状态、金额 0~1000、下单时间均匀分布在最近一年 |
建表和造数据的 SQL:
sql
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
shop_id INT NOT NULL,
status TINYINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
created_at DATETIME NOT NULL
) ENGINE=InnoDB;
-- 递归 CTE 一次生成 400 万行,本机约 20 秒
SET SESSION cte_max_recursion_depth = 5000000;
INSERT INTO orders(user_id, shop_id, status, amount, created_at)
WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n < 4000000)
SELECT FLOOR(1+RAND()*500000), FLOOR(1+RAND()*2000), FLOOR(1+RAND()*5), ROUND(RAND()*1000,2),
'2025-11-07' + INTERVAL FLOOR(RAND()*365*86400) SECOND FROM seq;
⚠️ 造数据时我第一次用了
RAND(n)、RAND(n+7)这种带种子的写法,结果相邻种子生成的随机数有关联,"最近一个月的订单"只落在 680 个店铺里,数据分布明显不对。换成不带种子的RAND()才正常(最近一个月 40.6 万行,2000 个店铺都有)。自己造测试数据时留意一下。
每条查询跑 3 次,下文取后两次的耗时(第一次有时偏慢)。数据都在内存里,测的是 CPU 和内存访问的开销,真实环境数据不在 buffer pool 里时,回表的代价会更大。
三、坑一:只给分组列加索引,SUM 慢了 6 倍
先看最简单的按店铺计数,没有索引时:
sql
SELECT shop_id, COUNT(*) FROM orders GROUP BY shop_id;
-- EXPLAIN: type=ALL Extra: Using temporary
0.82 秒。按常见建议加一个 idx_shop(shop_id),EXPLAIN 变成 type=index, Extra: Using index,Using temporary 没了,耗时降到 0.37 秒。到这里一切正常。
然后把 COUNT(*) 换成 SUM(amount),同样的索引:
| 写法 | EXPLAIN Extra | 耗时 |
|---|---|---|
不走索引(IGNORE INDEX(idx_shop)) |
Using temporary | 1.12 秒 |
走 idx_shop(shop_id) |
(空) | 6.87~6.99 秒 |
走 idx_shop_amount(shop_id, amount) |
Using index | 0.54 秒 |
加了索引慢了 6 倍。原因是 amount 不在 idx_shop 里:MySQL 按索引顺序扫 400 万个 shop_id,每拿到一个就要回主键索引取一次 amount,400 万次随机读。全表扫描反而是顺序读,再加一张 2000 行的内存临时表,便宜得多。
优化器为什么会选这个慢的方案?从结果看,它低估了回表的代价,宁可回表也要省掉临时表(我没去翻 optimizer trace 的成本数字,这里只说现象)。EXPLAIN 里 Extra 一栏是空的,看起来比 Using temporary 干净,实际是最慢的那个。
改法 :把聚合要用的列也放进索引,做成覆盖索引(EXPLAIN 出现 Using index)。(shop_id, amount) 之后 0.54 秒,比全表扫描快一倍。
⚠️ 判断一个 GROUP BY 的索引好不好,不要只看
Using temporary有没有消掉,要看有没有Using index。Extra 为空、type 是index的,大概率在回表。
四、坑二:带时间范围的统计,索引顺序反了
报表几乎都带时间条件,比如"本月以来每个店铺的销售额":
sql
SELECT shop_id, SUM(amount) FROM orders
WHERE created_at >= '2026-10-01'
GROUP BY shop_id;
符合条件的有 40.6 万行,占全表一成。我依次加了几个索引,记录优化器每次的选择:
| 当时表上有的索引 | 优化器选的 | Extra | 耗时 |
|---|---|---|---|
| 没有索引 | 全表扫描 | Using where; Using temporary | 0.63 秒 |
(shop_id, amount) |
(shop_id, amount) |
Using where | 6.54~6.90 秒 |
再加 (created_at) |
(created_at) |
Using index condition; Using temporary | 0.80 秒 |
再加 (created_at, shop_id, amount) |
这个 | Using where; Using index; Using temporary | 0.13 秒 |
强制用 (shop_id, created_at, amount) |
这个 | Using where; Using index | 0.54 秒 |
几个结论:
- 第二行又是坑一 :优化器为了按
shop_id顺序分组,选了不含created_at的索引,每行回表判断时间,6.9 秒 - 只有
created_at单列索引时,比全表扫描还慢 :范围扫出 40 万行,每行回表取shop_id和amount,0.80 秒,全表扫描 0.63 秒 - 最快的是范围条件列放第一位、分组列和聚合列跟在后面 :
(created_at, shop_id, amount)只扫一成的索引,不回表,虽然还有 Using temporary,0.13 秒 (shop_id, created_at, amount)能消掉临时表,但时间条件用不上索引范围,要扫整个索引,0.54 秒
所以带范围条件的 GROUP BY,一般优先让 WHERE 的范围先把行数砍下来,临时表留着没关系,2000 个分组的临时表在内存里很快。两种顺序的索引都建上时,优化器自己选了快的那个。
五、坑三:分组数很多时,临时表落盘
按店铺分组只有 2000 组。换成按用户分组,有 49.6 万组:
sql
SELECT COUNT(*) FROM (
SELECT user_id, COUNT(*) c FROM orders GROUP BY user_id
) x;
外面包一层 COUNT 是为了不把 49.6 万行结果传给客户端,只测分组本身。没有 user_id 索引时耗时 7.75 秒,执行后看状态:
sql
SHOW SESSION STATUS LIKE 'Created_tmp_disk_tables';
-- Created_tmp_disk_tables = 1
临时表超过了内存上限,转到了磁盘上。MySQL 8 的内部临时表默认用 TempTable 引擎,按官方文档,从 8.0.28 起单张内存临时表的上限由 tmp_table_size 控制,默认 16 MB,超过后自动转成 InnoDB 磁盘临时表。我在会话里调大它再跑:
tmp_table_size |
Created_tmp_disk_tables |
耗时 |
|---|---|---|
| 16 MB(默认) | 1 | 7.75 秒 |
| 64 MB | 0 | 2.63 秒 |
| 256 MB | 0 | 3.01 秒 |
默认值 + 加 idx_user(user_id) 索引 |
0 | 0.45 秒 |
调大 tmp_table_size 让临时表留在内存,快了将近 3 倍,再往上调没有收益。但最有效的还是加索引,按索引顺序分组根本不需要临时表。
tmp_table_size 是每个查询每张临时表的上限,全局调大以后,并发的大分组查询会一起吃内存。我的建议是只在跑报表的会话里 SET SESSION,不要随手改全局。
本机 8.2.0 的相关默认值:
sql
SELECT @@tmp_table_size, @@temptable_max_ram, @@internal_tmp_mem_storage_engine;
-- 16777216 1073741824 TempTable
六、能快 700 倍的场景:每组取最大值
"每个店铺最后一笔订单的时间"这类查询,有一个特别的执行方式:
sql
SELECT shop_id, MAX(created_at) FROM orders GROUP BY shop_id;
有 (shop_id, created_at, ...) 这样的索引时,EXPLAIN 显示:
text
type=range key=idx_shop_created_amount rows=2002 Extra: Using index for group-by
Using index for group-by 就是松散索引扫描:每个店铺只需要跳到索引里这一组的最后一条,2000 个店铺读 2000 次左右,不用扫 400 万行。耗时 0.01 秒。把这个索引忽略掉,优化器退回 (shop_id, amount) 加回表,7 秒左右。
适用条件比较苛刻(据官方文档 GROUP BY Optimization 一节):只查一张表;GROUP BY 的列是索引的最左前缀;聚合函数只能是 MIN / MAX,或者 COUNT(DISTINCT)、SUM(DISTINCT) 这类去重聚合。普通的 COUNT(*)、SUM 用不上。
七、排行榜类查询:加索引不够就上汇总表
"销量前 10 的店铺"要按 COUNT 排序取前几名:
sql
SELECT shop_id, COUNT(*) c FROM orders
GROUP BY shop_id ORDER BY c DESC LIMIT 10;
LIMIT 10 帮不上分组的忙,不把每个店铺都数完,谁也不知道前 10 是谁。EXPLAIN 里 Using temporary; Using filesort 必然存在:没有索引 0.83 秒,用覆盖索引 0.37 秒,再往下就压不动了。
这类查询如果在首页、每次刷新都跑,常见做法是按天预聚合:
sql
-- 每个店铺每天一行
CREATE TABLE shop_daily_stats (
shop_id INT NOT NULL,
stat_date DATE NOT NULL,
order_cnt INT NOT NULL,
order_amount DECIMAL(14,2) NOT NULL,
PRIMARY KEY (stat_date, shop_id)
) ENGINE=InnoDB;
一年的数据汇总下来 72.7 万行(第一次全量生成用了 11 秒)。对比同样的排行榜:
| 查询范围 | 直接查 orders |
查 shop_daily_stats |
|---|---|---|
| 最近 30 天 TOP 10 | 0.08 秒 | 0.02 秒 |
| 全年 TOP 10 | 0.38 秒 | 0.19 秒 |
两边查出来的前三名一致(537、1377、873)。汇总表的收益随原表行数增长:订单表涨到几千万,直接查会线性变慢,汇总表只随"店铺数 × 天数"增长。
每天的增量用定时任务重算当天,用 ON DUPLICATE KEY UPDATE 覆盖。注意 MySQL 8 里 VALUES(col) 写法会报警告:
text
Warning 1287 'VALUES function' is deprecated and will be removed in a future release.
Please use an alias (INSERT INTO ... VALUES (...) AS alias) and replace VALUES(col)
in the ON DUPLICATE KEY UPDATE clause with alias.col instead
INSERT ... SELECT 的场景,把 SELECT 包成派生表,用别名引用:
sql
-- 重算 2026-11-06 当天,已存在的行覆盖
INSERT INTO shop_daily_stats
SELECT * FROM (
SELECT shop_id, DATE(created_at) d, COUNT(*) cnt, SUM(amount) amt
FROM orders
WHERE created_at >= '2026-11-06' AND created_at < '2026-11-07'
GROUP BY shop_id, DATE(created_at)
) AS s
ON DUPLICATE KEY UPDATE order_cnt = s.cnt, order_amount = s.amt;
我把其中一行的 order_cnt 先改成 0 再跑这句,跑完恢复成了 4,没有警告。
汇总表的代价是数据有延迟、多一个定时任务要维护,订单退款、改金额这类修改要能触发重算。只是后台偶尔看一眼的报表,不值得上。
八、两个 MySQL 8 的行为变化
GROUP BY 不再隐式排序。官方文档的说法是,5.7 及以前 GROUP BY 在某些条件下会隐式排序,8.0 起不再这样。实测:
sql
SELECT status, COUNT(*) FROM orders GROUP BY status;
-- 输出顺序:3, 5, 4, 2, 1
老代码依赖"GROUP BY 出来就是有序的",升级 8.0 以后列表顺序会乱,要显式加 ORDER BY。以前为了去掉排序写的 ORDER BY NULL,官方文档说 8.0 里已经不需要了,留着也不影响结果。
ONLY_FULL_GROUP_BY 默认开启。SELECT 里出现了既不在 GROUP BY 里、也没有被聚合的列,直接报错:
text
ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains
nonaggregated column 't.orders.user_id' which is not functionally dependent on columns
in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
按店铺分组,一个店铺有很多个 user_id,5.7 宽松模式下会随便返回其中一个,结果不确定。正确改法是想清楚要哪个:要任意一个就 ANY_VALUE(user_id),要最大的就 MAX(user_id),要每个都看就把它加进 GROUP BY。不建议把 sql_mode 里的 ONLY_FULL_GROUP_BY 去掉了事。
九、速查表
| 现象 | 原因 | 改法 |
|---|---|---|
type=ALL,Using temporary,分组数少 |
全表扫描 + 内存临时表 | 不一定要改;要改就建覆盖索引 |
type=index,Extra 为空,比全表扫描还慢 |
走分组列索引,每行回表 | 把聚合列加进索引,看到 Using index |
| 带范围 WHERE,走了分组列开头的索引,很慢 | 范围条件用不上,扫全索引 + 回表 | 建 (范围列, 分组列, 聚合列) |
分组数很多,Created_tmp_disk_tables 增加 |
临时表超过 tmp_table_size |
给分组列建索引;或报表会话里 SET SESSION 调大 |
| 每组取 MIN/MAX | --- | 索引 (分组列, MIN/MAX 列),看到 Using index for group-by |
| ORDER BY COUNT(*) DESC LIMIT N | 必须算完所有组 | 覆盖索引;还不够就按天汇总表 |
| 升级 8.0 后列表顺序乱了 | GROUP BY 不再隐式排序 | 显式写 ORDER BY |
| ERROR 1055 | ONLY_FULL_GROUP_BY | ANY_VALUE / 聚合 / 加进 GROUP BY |
常见问题
Q:Using temporary 一定要消掉吗?
不用。本文 2000 个分组的内存临时表开销很小,最快的那个方案(0.13 秒)也带着 Using temporary。要警惕的是临时表落盘(看 Created_tmp_disk_tables)和为了消掉它而引入的回表。
Q:优化器选错索引了怎么办?
先确认两个索引都建了,本文第四节两种顺序的索引都在时,优化器选对了。实在选错,可以用 FORCE INDEX,但它会把执行计划写死,数据分布变了不会跟着调整,用之前在代码里留个注释说明原因。
Q:生产库数据不在内存里,结论还成立吗?
方向一样,差距会更大。本文数据全部在 buffer pool 里,回表只是内存里的随机访问就慢了 6 倍;数据要从磁盘读时,随机读比顺序读的代价更高。具体倍数我没在磁盘场景下测。
Q:用 MySQL 5.7 有区别吗?
5.7 没有 TempTable 引擎,据 5.7 文档,内存临时表的上限是 tmp_table_size 和 max_heap_table_size 两者中较小的那个,只调一个不一定生效;GROUP BY 在某些条件下还会隐式排序。本文只在 8.2.0 上测过,5.7 的数字不能直接套用。
参考链接
- MySQL 8.0 GROUP BY 优化(含松散索引扫描条件):dev.mysql.com/doc/refman/...
- MySQL 8.0 内部临时表:dev.mysql.com/doc/refman/...
- MySQL 8.0 ONLY_FULL_GROUP_BY 处理:dev.mysql.com/doc/refman/...
- INSERT ... ON DUPLICATE KEY UPDATE:dev.mysql.com/doc/refman/...
你们项目里慢的 GROUP BY 是哪一类:带时间范围的报表、分组数很多的按用户统计,还是首页排行榜?EXPLAIN 里 Extra 是什么?评论区贴出来,我挑典型的补进 FAQ。
觉得有用的话点个赞、收个藏,下次排查慢查询时翻得到。