MySQL 8.0+中GROUP BY + ORDER BY无法实现组内排序,必须用ROW_NUMBER()等窗口函数:先PARTITION BY分组、ORDER BY排序编号,再WHERE rn = 1取每组首行。

MySQL 8.0+ 必须用窗口函数,GROUP BY + ORDER BY 组合无效
直接说结论:SELECT * FROM t GROUP BY x ORDER BY y 不会先按 y 排序再分组,而是先分组(每组随机留一行),再对这“一行/组”的结果按 y 排序。这是执行顺序决定的,不是配置或模式能改的。
常见错误现象是:明明写了 ORDER BY created_at DESC,但每组取出来的 order_id 或 amount 却不是最新那条的值——因为分组时 MySQL 自行选了一行,和你写的 ORDER BY 毫无关系。
-
ROW_NUMBER()是最常用解法:按分组字段PARTITION BY,再按目标排序字段ORDER BY ... DESC编号 - 外层必须加
WHERE rn = 1过滤,不能只靠子查询里写ORDER BY - 如果存在并列(如两个相同最大时间),需改用
RANK()或DENSE_RANK(),否则ROW_NUMBER()会强行给唯一序号
MySQL 5.7 及更早版本只能用关联子查询,IN (SELECT ...) 是性能陷阱
老版本不支持窗口函数,但很多人误用 SELECT *, MAX(created_at) FROM t GROUP BY user_id,这会导致非聚合字段(如 id、status)的值来自任意匹配行,完全不可控。
安全写法是显式找出“每组中满足最大时间条件的完整行”:
SELECT o1.* FROM orders o1 WHERE o1.created_at = ( SELECT MAX(o2.created_at) FROM orders o2 WHERE o2.user_id = o1.user_id );
- 这个结构能正确返回每组最新记录,且支持多行并列(比如同一天两条订单)
- 若只要一条,可在子查询里加
LIMIT 1并配合ORDER BY id DESC做二次确定 - 务必在
(user_id, created_at)上建联合索引,否则子查询会触发全表扫描 - 避免写成
id IN (SELECT id FROM ...)—— MySQL 5.7 对这类写法优化极差,容易生成临时表和文件排序
用变量模拟 ROW_NUMBER() 在低版本中风险高,仅限临时分析
有人用 @row_num := @row_num + 1 或带分组的双变量技巧来“手动编号”,例如:
SELECT id, user_id, created_at,
@row_num := IF(@prev = user_id, @row_num + 1, 1) AS rn,
@prev := user_id
FROM orders
CROSS JOIN (SELECT @row_num := 0, @prev := '') AS _
ORDER BY user_id, created_at DESC;这种写法实际依赖执行顺序,MySQL 不保证 ORDER BY 在变量赋值前完成,尤其在有 JOIN、LIMIT 或并行查询时极易出错。
- 官方文档明确指出:在同一个语句中对用户变量赋值并读取,结果未定义
- 即使当前测试通过,升级小版本或换执行计划就可能崩
- 只建议在单表、无并发、数据量小的离线分析中临时使用,生产环境禁用
真正容易被忽略的点:时间字段类型和 NULL 值处理
很多问题不是逻辑写错,而是数据本身埋了坑:
-
created_at是DATETIME还是TIMESTAMP?后者受时区影响,跨库同步时可能偏差 - 字段含
NULL时,MAX(created_at)会忽略它,导致“最新记录”被跳过;要用COALESCE(created_at, '1970-01-01')或前置清洗 - 子查询中比较
created_at = MAX(...)时,如果该字段有微秒精度(DATETIME(6)),而索引没包含精度,可能走不上索引 - 用窗口函数时,
ORDER BY created_at DESC若字段允许 NULL,默认排在最前(NULLS FIRST),但 MySQL 不支持显式控制,得提前WHERE created_at IS NOT NULL


















