找我排查慢SQL的同事里,十个有八个都栽在同一个需求上:在MySQL里按某个维度分组,取出每一组的最新一条记录。比如取每个用户最近一单、每台设备最新一次心跳、每个商品当前价格……这类MySQL查询优化问题在业务系统里太常见了,但写法五花八门,性能差距可以从毫秒级拉到分钟级。这篇文章就把“分组取最新记录”这个场景彻头彻尾讲透,我会给你列出6种主流写法、索引设计要点、EXPLAIN分析思路,以及一份千万级数据量的真实调优案例。不管你是刚接手报表SQL的新人,还是被线上慢查询折腾的DBA,都能从里面找到对应的解决方案。
1. 问题模型认识:为什么“取每组最新记录”容易写错
1.1 一个再常见不过的需求
先把这个需求翻译成数据库语言:有一张明细表,每条记录归属于某个分组(比如user_id、device_id、product_id),每条记录带一个时间字段或自增ID。现在要按分组维度,取每组中“最新”的那一条完整记录。
用学校的例子类比就是:全校几千个班,每个班几十个学生,现在要找出每个班分数最高的那个人的所有信息。听起来很简单,但难点在于你要的是“整行记录”,不是“每个班最高分这个数字”。
我说这是MySQL查询优化里最经典的“分组Top-N”问题,因为它其实由三步组成:分组(GROUP BY)、组内排序(ORDER BY)、取第一条(LIMIT 1 per group)。这三步单独拎出来都不难,组合在一起就会暴露很多性能和安全上的坑。常见的业务场景包括订单表取每个用户最近一笔订单、日志表取每台设备的最后一条心跳、库存表取每个SKU的当前价格、登录日志取每个账号的最后一次登录IP。这类查询在报表系统、数据看板、消息推送里几乎天天出现。
1.2 新手常见的三种错误写法
先说错误写法,因为我在评审代码时见得太多了,一旦理解了错误在哪,正确写法的价值就体现出来了。
错误一:直接GROUP BY + MAX,然后SELECT其他非聚合列。
SELECT user_id, MAX(order_time), order_amount FROM user_orders GROUP BY user_id;这条SQL的意图很明确:取每个用户的最新订单金额。但order_amount并没有被聚合,它跟MAX(order_time)没有任何关联关系。在MySQL 5.7及更早版本,执行后order_amount取的是该组内“碰巧被读到的那一行”的值,结果基本是随机的;在开启了ONLY_FULL_GROUP_BY的8.0版本,这条SQL会直接报错。所以这种写法本质上就是错的,它并不满足“取整行最新记录”的需求。
错误二:全局ORDER BY + LIMIT 1。
SELECT * FROM user_orders ORDER BY order_time DESC LIMIT 1;这个只能拿到全表最新的一条,不是每个分组的最新一条。但我在不少同事的代码里见过这种写法,原因是他们把“最新记录”想成了“一个表一个时间线,取最后一条”。如果业务确实只需要一条全局最新,那没问题;但需求说的是“每个分组”,这就南辕北辙了。
错误三:应用层循环查询。
SELECT * FROM user_orders WHERE user_id = ? ORDER BY order_time DESC LIMIT 1;这段SQL在单用户维度下确实是最优写法,但如果用户有100万个,你在应用层写一个for循环去执行100万次,就是经典的N+1问题。每次查询都有网络往返、SQL解析、执行计划生成,100万次下来十几分钟都跑不完。千万别这么干,数据库能一次算完的东西就不要搬到应用层反复烧网络。
这三种错误写法的共同点,是没有理解“取分组最新”本质上是一个需要在数据库内部完成集合运算的逻辑,而不是简单地把单条查询重复执行。
2. 六种主流SQL写法逐一拆解
2.1 关联子查询:最直观的“逐行询问”
第一种思路是关联子查询:对主表每一行,都去子查询里问一句“你这个组里,比当前时间更新的记录存在吗?不存在的话,这一行就是我要的”。
SELECT * FROM user_orders a WHERE a.order_time = ( SELECT MAX(b.order_time) FROM user_orders b WHERE b.user_id = a.user_id );这个写法理解成本最低,逻辑也直接:先在内层算出每个用户的最大下单时间,再在外层把时间等于这个最大值的整行拿出来。但它有个必须正视的性能问题:如果user_orders表没有合适的索引,MySQL会对主表的每一行都执行一次全表子查询,1000万行主表就是1000万次全表扫描,基本跑不出来。即使有(user_id, order_time)联合索引,MySQL仍然需要对外层每一行走索引查询,并发一高,资源消耗也很可观。它更适合分组数少、总行数可控、临时跑一次统计的场景。
另外,如果同一用户在同一秒下了两单,两条记录的order_time相同,这个写法会把两条都带出来。如果业务要求“同一时间只取一条”,就必须在排序键上补充ID,后面我会专门讲。
2.2 派生表JOIN:先找最大值,再回去配对账
第二种思路是先把每个分组的“最新时间”算出来,得到一张临时表,再把它跟原始表做JOIN,匹配出完整记录。
SELECT a.* FROM user_orders a INNER JOIN ( SELECT user_id, MAX(order_time) AS latest_time FROM user_orders GROUP BY user_id ) b ON a.user_id = b.user_id AND a.order_time = b.latest_time;这种写法比关联子查询更好理解,也更容易被优化器处理。MySQL 5.7之后引入了derived_merge(派生表合并)优化,很多时候不会真的物化成一张临时表,而是直接把子查询的GROUP BY逻辑跟外层JOIN融合在一起。但要注意:如果派生表比较复杂,或者数据量大到内存放不下,MySQL还是会物化成临时表并落盘,那就是一次额外IO开销。
有一个非常实用的变体值得记下来:如果业务上“最新”可以等同于“自增ID最大”,比如订单的ID是自增的、ID越大订单越晚,那么可以直接取MAX(id)而不是MAX(order_time)。
SELECT a.* FROM user_orders a INNER JOIN ( SELECT MAX(id) AS latest_id FROM user_orders GROUP BY user_id ) b ON a.id = b.latest_id;这个变体的JOIN条件从“user_id + order_time”两个字段变成了“主键id”一个字段,回表路径大大缩短,性能通常快一个量级。我在后面4.2的调优案例里会用到它。前提是你要跟业务确认清楚:自增ID的顺序确实等价于业务时间顺序,不能傻乎乎地拿ID做最新判断,结果业务上周五手工改了一条老数据,ID最大但时间不是最新,那就翻车了。
2.3 窗口函数:MySQL 8.0 的标准答案
如果你用的是MySQL 8.0,那窗口函数是最好的默认选择。核心语法是ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...),在分区内编号,我们只要编号为1的行。
SELECT id, user_id, order_amount, order_time FROM ( SELECT t.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time DESC) AS rn FROM user_orders t ) r WHERE r.rn = 1;这个写法的优势非常明显:第一,语义完全贴合需求——“按user_id分区,组内按order_time倒序排,取第一名”;第二,只需要扫描一遍数据,不像关联子查询那样反复执行子查询;第三,代码可读性极强,后续维护的人一眼就能看懂。
我知道有同事担心窗口函数性能不好,因为它涉及排序。实际上,只要在(user_id, order_time)上建了联合索引,MySQL可以在扫描索引的同时完成排序,EXPLAIN里不会出现Using filesort。MySQL 8.0也提供了RANK()和DENSE_RANK(),它们和ROW_NUMBER()的区别在于并列排名的处理。如果业务认为“同一时间的最新记录都算数”,那可以用RANK()=1;如果只想要唯一一条,就用ROW_NUMBER()并额外用ID打破并列。
2.4 其他实用写法:反连接、截断聚合、用户变量
除了上面三种常规方案,还有三个备选手段,特殊场景下能救急。
反连接写法(LEFT JOIN判空 / NOT EXISTS)。思路是找“组内没有比它更新记录”的行:
SELECT a.* FROM user_orders a LEFT JOIN user_orders b ON a.user_id = b.user_id AND a.order_time < b.order_time WHERE b.id IS NULL;如果b表里存在一条同用户、时间更晚的记录,说明a不是最新,JOIN会产生匹配,b.id不为NULL;只有当a是组内最新的时候,b才会是NULL。这种反连接在MySQL 8.0里会被优化成anti join,配合索引效果不错。它的问题是SQL理解门槛稍高,新人看到“JOIN自己”容易懵,而且当时间相等时需要用更复杂的排序键条件。
GROUP_CONCAT截断法。另一种取巧思路是用GROUP_CONCAT把组内ID按时间倒序拼成字符串,然后截取第一个:
SELECT user_id, CAST(SUBSTRING_INDEX( GROUP_CONCAT(id ORDER BY order_time DESC), ',', 1 ) AS UNSIGNED) AS latest_id FROM user_orders GROUP BY user_id;再用这个latest_id去JOIN主表。我偶尔在临时排查数据时用这一招,因为它一条SQL就能看到每个用户的最新订单ID,非常直观。但千万别用在生产环境,原因有两个:一是GROUP_CONCAT有默认长度限制(group_concat_max_len默认1024字节),用户订单一多就被截断,取到的ID根本不是最新;二是它返回的是TEXT类型,跟主表的INT主键JOIN时存在隐式转换,可能导致索引失效。
用户变量法(MySQL 5.7时代的过渡方案)。老版本没有窗口函数,有人用变量模拟ROW_NUMBER:
SET @prev_user := NULL, @rn := 0; SELECT id, user_id, order_amount, order_time FROM ( SELECT t.*, IF(@prev_user = user_id, @rn := @rn + 1, @rn := 1) AS rn, @prev_user := user_id FROM user_orders t ORDER BY user_id, order_time DESC ) tmp WHERE rn = 1;这段逻辑靠变量在扫描过程中记住“上一个user_id”,相同分组就累加编号,不同分组就重置为1。它在5.7时代确实帮很多人解决了问题,但我要明确建议:不要把这种写法搬到新项目。MySQL官方文档已经明确说明,用户变量的赋值顺序在SELECT表达式中是不保证的,依赖输出顺序的写法本质上是在跟优化器博弈;到了8.0,很多曾经能跑的变量写法会得到莫名其妙的结果。它只适合用来理解老代码,不适合作为新方案。
3. 索引设计与EXPLAIN实战
3.1 让查询跑起来的关键索引
无论上面选哪种写法,“分组取最新记录”这三个动作都绕不开一个核心索引:分区字段 + 排序字段。以本案例就是(user_id, order_time)。
ALTER TABLE user_orders ADD INDEX idx_user_time (user_id, order_time DESC);为什么把user_id放前面?因为我们的查询本质上是对user_id做等值或者分组匹配,对order_time做范围排序。联合索引的最左前缀原则决定了user_id必须在第一列,否则索引无法用于分区过滤。其次,ORDER BY order_time DESC如果想利用索引,order_time必须紧跟其后。MySQL 8.0支持降序索引,可以显式写DESC;5.7不支持降序索引,但优化器在ORDER BY DESC时通常可以通过反向扫描索引来完成,差别不大。
这里有个容易忽略的点:这个联合索引解决了“快速找到每组最新时间”的问题,但最终还要把整行记录拿出来。如果你SELECT的列里有order_amount等不在索引中的字段,MySQL需要回表去主键索引取数据,每次回表都是一次随机IO。如果这种查询非常频繁,可以考虑把常用字段也塞进索引,做成覆盖索引:
ALTER TABLE user_orders ADD INDEX idx_user_time_full (user_id, order_time DESC, order_amount);覆盖索引能让查询完全绕开回表,但会让索引体积变大、写入变慢,属于典型的空间换时间,用之前必须权衡。
3.2 EXPLAIN解读:不同方案在优化器眼里什么样
我见过太多人建了索引就说“怎么还慢”,然后一脸茫然。建完索引第一件事永远是EXPLAIN,看看优化器到底怎么执行。
EXPLAIN SELECT id, user_id, order_amount, order_time FROM ( SELECT t.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time DESC) AS rn FROM user_orders t ) r WHERE r.rn = 1;关键看这几个字段:type表示访问类型,从好到差大致是const、ref、range、index、ALL;key表示实际用到的索引;rows是估算扫描行数;Extra里如果出现Using filesort或Using temporary,说明排序或临时表开销没法避免。
我整理过一个简单的对比表,供不同方案之间快速参考:
| 方案 | 有没有索引时的主访问方式 | 扫描行数特征 | Extra常见风险 |
|---|---|---|---|
| 关联子查询 | 有索引时外层ALL、内层ref | 外层全表,内层每组一次 | 子查询反复执行 |
| 派生表JOIN | 有索引时内外都可能走ref/range | 先扫全表分组,再回表配对 | 派生表物化临时表 |
| 窗口函数 | 有索引时扫描索引排序 | 基本一次扫描全表 | Using filesort / 临时表 |
| NOT EXISTS | 有索引时anti join | 理论一次关联 | 写错条件会笛卡尔积 |
| GROUP_CONCAT | 有索引时range扫 | 全表分组一次 | Using temporary |
注意,EXPLAIN给出的rows是估算值,别把它当成精确行数。我见过EXPLAIN显示rows=100、实际跑了1000万的情况,尤其在使用窗口函数时,优化器对窗口计算的估算经常不准。所以正确姿势是:EXPLAIN看执行形态,实际运行看耗时,两者结合才能下结论。
3.3 数据分布对方案选择的影响
你可能会问:既然窗口函数是8.0标准答案,那其他方案是不是可以扔掉了?不是。性能问题永远要结合数据分布。
我总结了一套经验选择逻辑。如果总行数大、分组数也多、每组只有几条记录,窗口函数和派生表JOIN都不错,重点是用索引避免filesort。如果分组数很少、每组记录特别多,比如就100台设备、1亿条心跳日志,那“先按设备分组取MAX(id)”的JOIN方案往往更高效,因为窗口函数需要对1亿行做全排序,哪怕有索引辅助,排序缓冲区的压力也很大。如果业务需求是高频实时查询,比如前端页面每次加载都要取当前用户的最新订单,那你根本不该用“分组最新”的写法,而是直接走“user_id等于什么、ORDER BY order_time DESC LIMIT 1”的单行查询,这才是最优路径。
没有万能银弹,只有结合表结构和数据分布去选方案,才能得到真正的“最优”。
4. 千万级数据模拟与完整调优过程
4.1 准备一张1000万行的订单表
空谈理论没有感觉,我直接做一个可复现的压测环境。先建测试表:
CREATE TABLE user_orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, order_amount DECIMAL(10,2) NOT NULL, order_time DATETIME NOT NULL, KEY idx_user_time (user_id, order_time DESC) ) ENGINE=InnoDB;造数据的关键是快速生成1000万行。我习惯先建一张只含0~9的numbers表,通过多表CROSS JOIN生成100万用户:
CREATE TABLE numbers (n INT PRIMARY KEY); INSERT INTO numbers VALUES (0),(1),(2),(3),(4),(5),(6),(7),(8),(9); INSERT INTO users (id, name) SELECT n1.n + n2.n * 10 + n3.n * 100 + n4.n * 1000 + n5.n * 10000 + n6.n * 100000, CONCAT('user', n1.n + n2.n * 10 + n3.n * 100 + n4.n * 1000 + n5.n * 10000 + n6.n * 100000) FROM numbers n1 CROSS JOIN numbers n2 CROSS JOIN numbers n3 CROSS JOIN numbers n4 CROSS JOIN numbers n5 CROSS JOIN numbers n6;然后给每个用户生成10笔订单,时间随机落在2023年1月1日之后的约579天内:
INSERT INTO user_orders (user_id, order_amount, order_time) SELECT u.id, ROUND(RAND() * 1000, 2), TIMESTAMP('2023-01-01') + INTERVAL FLOOR(RAND() * 50000000) SECOND FROM users u CROSS JOIN numbers n WHERE n.n < 10;这条语句会把100万用户乘以10行,生成约1000万订单。如果感觉插入太慢,可以先SET autocommit=0,分批提交,或者把binlog关掉再做测试机数据初始化。测试环境操作完记得恢复设置。
4.2 从慢到快:一次完整调优记录
我拿这个表实测了一遍。第一次,我用最原始的关联子查询,而且故意不建联合索引,只留主键:
SELECT * FROM user_orders a WHERE a.order_time = ( SELECT MAX(b.order_time) FROM user_orders b WHERE b.user_id = a.user_id );结果是跑到快两分钟我直接kill了。EXPLAIN里清晰可见:外层ALL全表扫,内层ALL全表扫,估算行数看一眼就让人头皮发麻。这不是SQL语法问题,而是缺了索引之后,MySQL真的会对每一行做一次百万行级别的子查询。
接着加上idx_user_time联合索引,同一套SQL再跑,耗时降到了6秒左右。EXPLAIN里内层子查询变成了ref,每次都能通过索引迅速找到该用户的MAX(order_time)。这说明对于1000万行和100万分组这种“组内行数少、分组特别多”的结构,只要索引到位,关联子查询也能用,只不过6秒对于线上报表还是偏慢。
然后把SQL换成窗口函数:
SELECT id, user_id, order_amount, order_time FROM ( SELECT t.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time DESC) AS rn FROM user_orders t ) r WHERE r.rn = 1;这次跑了0.8秒左右。EXPLAIN显示主查询走的type是index,Extra里没有Using filesort,因为联合索引的第二个字段已经帮窗口函数完成了组内排序。这就很能说明问题:同样是1000万行,好的写法和好的索引配合,性能可以从“跑不动”变成“亚秒级”。
最后我试了MAX(id) + JOIN的杀手锏变体:
SELECT a.* FROM user_orders a INNER JOIN ( SELECT MAX(id) AS latest_id FROM user_orders GROUP BY user_id ) b ON a.id = b.latest_id;耗时大约0.4秒,比窗口函数还要快一倍。原因不复杂:子查询里只需要扫描(user_id, order_time)联合索引就能算出MAX(id),而且MAX(id)可以直接用主键跟外层JOIN,避免了按user_id + order_time两个字段配对回表。这印证了我前面说的:如果业务语义允许,用自增ID代表“最新”是性能最好的捷径。
4.3 MySQL 5.7 与 8.0 的写法兼容
如果你还在用MySQL 5.7,窗口函数这条路走不通,我的推荐顺序是:MAX(id) + JOIN(或MAX(order_time) + JOIN)排第一,数据量不大时关联子查询排第二,NOT EXISTS排第三,GROUP_CONCAT和用户变量只在临时排查里用。
从5.7升级到8.0时有一个容易踩的坑:8.0默认启用了ONLY_FULL_GROUP_BY,以前在5.7里能跑的“GROUP BY + 非聚合列”语句会直接报错。这其实是好事,逼着你把SQL写规范。另一个坑是8.0对索引的命名和可见性规则有变化,老脚本里如果存在重复的索引名,升级时可能要调整。我的建议是:升级后先跑一遍业务核心SQL,把报错和变慢的都揪出来,别等线上炸了再救火。
5. 高频避坑点与排查速查表
5.1 分组取最新常见问题排查表
我把这些年帮别人排查同类问题时遇到的高频症状,整理成了一张速查表。下次遇到类似问题,直接对着症状找原因:
| 症状 | 可能原因 | 解决思路 |
|---|---|---|
| 返回结果多出重复行 | 组内有并列最新时间,排序键不唯一 | 在ORDER BY里追加自增ID,或使用ROW_NUMBER |
| 查出的其他字段跟最新时间对不上 | SELECT了非聚合字段,GROUP BY语义混乱 | 改用子查询、窗口函数或MAX(id) JOIN |
| SQL超时或CPU打满 | 关联子查询无索引,或应用层循环查询 | 加(user_id, order_time)联合索引,改写为窗口函数 |
| GROUP_CONCAT结果丢数据 | group_concat_max_len默认只有1024字节 | SET SESSION group_concat_max_len = 1024000 |
| 加了索引但EXPLAIN还是Using filesort | 索引顺序不对,或排序方向不匹配 | 确认索引第二字段与ORDER BY方向一致 |
| 老版本迁移8.0后SQL直接报错 | ONLY_FULL_GROUP_BY默认开启 | 重写SQL,把非聚合列移入子查询 |
5.2 容易忽略的基础细节
第一,时间字段的选择会影响长远。尽量用DATETIME而不是TIMESTAMP,TIMESTAMP有2038年问题和时区转换隐患,DATETIME在8.0里还支持小数秒精度。如果你的业务存在跨时区场景,要明确存的是哪个时区的时间,否则“最新”很容易错乱。
第二,排序键的稳定性。如果同一时间可能有多条记录,必须在ORDER BY里加一个唯一维度来打破并列,比如(order_time DESC, id DESC)。不要想当然认为业务里不会出现同一秒两条数据,日志系统、批量导入、消息重试都可能导致时间完全一致。
第三,数据量大到一定程度,就别老想着“单条SQL优化到底”。如果分组维度稳定,而且业务能接受一定的延迟,可以建一张汇总表,定时任务每隔几分钟算一次“每组最新记录”,业务查询直接读这张小表。SQL再快,也不如不查那张大表来得快。这也是生产环境里很常见的架构手段。
第四,GROUP BY在MySQL里的隐藏排序行为。老版本中GROUP BY会产生内部排序,即使你不需要排序结果也可能有额外开销;8.0对这块做了不少优化,但并不意味着可以完全无视。遇到GROUP BY慢,先看EXPLAIN有没有Using temporary。
我把这些年的经验浓缩成一句话:处理“MySQL里取每组最新记录”,先别急着写SQL,先回答三个问题——业务里的“最新”到底以什么为准,同一时刻会不会多条,表的数据分布是什么样的。回答完这三个问题,再选择对应的写法,配合联合索引,性能基本不会差。我个人在8.0环境下的默认选择是窗口函数,在5.7环境下首选MAX(id) + JOIN。最后再分享一个工作习惯:任何慢SQL优化,都先用EXPLAIN看清楚执行计划,再拿真实数据量去验证,绝不在几万行的小表上拍板哪个方案最优。