/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
导读: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:
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 倍
先看最简单的按店铺计数,没有索引时:
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),同样的索引:
| 不走索引(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 的,大概率在回表。
四、坑二:带时间范围的统计,索引顺序反了
报表几乎都带时间条件,比如"本月以来每个店铺的销售额":
SELECT shop_id, SUM(amount) FROM orders
WHERE created_at >= '2026-10-01'
GROUP BY shop_id;
符合条件的有 40.6 万行,占全表一成。我依次加了几个索引,记录优化器每次的选择:
| 没有索引 | 全表扫描 | 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 秒 |
几个结论:
所以带范围条件的 GROUP BY,一般优先让 WHERE 的范围先把行数砍下来,临时表留着没关系,2000 个分组的临时表在内存里很快。两种顺序的索引都建上时,优化器自己选了快的那个。
五、坑三:分组数很多时,临时表落盘
按店铺分组只有 2000 组。换成按用户分组,有 49.6 万组:
SELECT COUNT(*) FROM (
SELECT user_id, COUNT(*) c FROM orders GROUP BY user_id
) x;
外面包一层 COUNT 是为了不把 49.6 万行结果传给客户端,只测分组本身。没有 user_id 索引时耗时 7.75 秒,执行后看状态:
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 磁盘临时表。我在会话里调大它再跑:
| 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 的相关默认值:
SELECT @@tmp_table_size, @@temptable_max_ram, @@internal_tmp_mem_storage_engine;
— 16777216 1073741824 TempTable
六、能快 700 倍的场景:每组取最大值
"每个店铺最后一笔订单的时间"这类查询,有一个特别的执行方式:
SELECT shop_id, MAX(created_at) FROM orders GROUP BY shop_id;
有 (shop_id, created_at, …) 这样的索引时,EXPLAIN 显示:
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 排序取前几名:
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 秒,再往下就压不动了。
这类查询如果在首页、每次刷新都跑,常见做法是按天预聚合:
— 每个店铺每天一行
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 秒)。对比同样的排行榜:
| 最近 30 天 TOP 10 | 0.08 秒 | 0.02 秒 |
| 全年 TOP 10 | 0.38 秒 | 0.19 秒 |
两边查出来的前三名一致(537、1377、873)。汇总表的收益随原表行数增长:订单表涨到几千万,直接查会线性变慢,汇总表只随"店铺数 × 天数"增长。
每天的增量用定时任务重算当天,用 ON DUPLICATE KEY UPDATE 覆盖。注意 MySQL 8 里 VALUES(col) 写法会报警告:
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 包成派生表,用别名引用:
— 重算 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 起不再这样。实测:
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 里、也没有被聚合的列,直接报错:
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 优化(含松散索引扫描条件):https://dev.mysql.com/doc/refman/8.0/en/group-by-optimization.html
- MySQL 8.0 内部临时表:https://dev.mysql.com/doc/refman/8.0/en/internal-temporary-tables.html
- MySQL 8.0 ONLY_FULL_GROUP_BY 处理:https://dev.mysql.com/doc/refman/8.0/en/group-by-handling.html
- INSERT … ON DUPLICATE KEY UPDATE:https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html
你们项目里慢的 GROUP BY 是哪一类:带时间范围的报表、分组数很多的按用户统计,还是首页排行榜?EXPLAIN 里 Extra 是什么?评论区贴出来,我挑典型的补进 FAQ。
觉得有用的话点个赞、收个藏,下次排查慢查询时翻得到。
网硕互联帮助中心





评论前必须登录!
注册