MySQL GROUP BY 很慢怎么优化:加了索引反而从 1 秒变 7 秒,Using temporary 该不该消(MySQL 8.2 实测 400 万行)

导读: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 秒

几个结论:

  1. 第二行又是坑一 :优化器为了按 shop_id 顺序分组,选了不含 created_at 的索引,每行回表判断时间,6.9 秒
  2. 只有 created_at 单列索引时,比全表扫描还慢 :范围扫出 40 万行,每行回表取 shop_id 和 amount,0.80 秒,全表扫描 0.63 秒
  3. 最快的是范围条件列放第一位、分组列和聚合列跟在后面 :(created_at, shop_id, amount) 只扫一成的索引,不回表,虽然还有 Using temporary,0.13 秒
  4. (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 的数字不能直接套用。


参考链接


你们项目里慢的 GROUP BY 是哪一类:带时间范围的报表、分组数很多的按用户统计,还是首页排行榜?EXPLAIN 里 Extra 是什么?评论区贴出来,我挑典型的补进 FAQ。

觉得有用的话点个赞、收个藏,下次排查慢查询时翻得到。

相关推荐
数据库小学妹1 小时前
MySQL大字段治理:行溢出机制、覆盖索引与拆表
数据库·mysql·性能优化
这个DBA有点耶3 小时前
MySQL 8.0不可见索引:删索引怕出事,不删又拖慢写入——中间态怎么用?
数据库·mysql·代码规范
噜哥带你玩转 AI 大模型5 小时前
面试加分项!CASE WHEN 两种写法、CTE 公共表表达式、四大类窗口函数,老哥一次讲透
mysql·窗口函数·cte·sql 进阶
bksczm6 小时前
InnoDB 内存结构总览:Change Buffer、AHI 与 Log Buffer
数据库·mysql
招风的黑耳6 小时前
SpringBoot+Vue+MySQL 民宿在线预定平台 完整可运行版(源码+文档)
vue.js·spring boot·mysql
.痞老板.8 小时前
mysql初阶--数据库设计
数据库·mysql·oracle
Decay__10 小时前
SQL Server 数据库全栈运维实战系列 — 合集索引(24 篇)
运维·数据库·sql·mysql·架构
Thanks_ks10 小时前
SQL 的 GROUP BY 与聚合函数:从会写 SELECT 到会做统计
数据库·sql·mysql·group by·数据统计·聚合函数·having
Decay__18 小时前
12_MySQL InnoDB 内存与锁调优 —— Buffer Pool、redo log、隔离级别与锁等待
运维·数据库·sql·mysql·架构