云计算百科
云计算领域专业知识百科平台

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

/* 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),同样的索引:

写法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 的,大概率在回表。


四、坑二:带时间范围的统计,索引顺序反了

报表几乎都带时间条件,比如"本月以来每个店铺的销售额":

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 万组:

    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 磁盘临时表。我在会话里调大它再跑:

    tmp_table_sizeCreated_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 的相关默认值:

    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 秒)。对比同样的排行榜:

    查询范围直接查 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) 写法会报警告:

    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。

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

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » MySQL GROUP BY 很慢怎么优化:加了索引反而从 1 秒变 7 秒,Using temporary 该不该消(MySQL 8.2 实测 400 万行)
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!