GROUP BY不能直接取每组第一条记录,因其仅用于聚合而非排序筛选,且不保证组内顺序;须用ROW_NUMBER()配合PARTITION BY和明确ORDER BY(含次级字段)在子查询中编号后筛选rn=1。

为什么GROUP BY不能直接取每组第一条记录
因为 GROUP BY 本身不保证组内顺序,且聚合函数(如 MAX()、MIN())只返回值,不保留对应行的其他字段。想取“第一条”,必须先定义“按什么排序算第一”——这恰恰是 ROW_NUMBER() 解决的问题。
常见错误现象:SELECT *, MAX(created_at) FROM orders GROUP BY user_id 看似想取每个用户最新订单,但 * 和 GROUP BY 混用会报错(MySQL 5.7+ 默认 SQL mode 下),或返回不可预测的非最新行(旧版 MySQL 允许但行为未定义)。
- GROUP BY 的语义是“聚合”,不是“筛选”
- 没有显式
ORDER BY子句时,ROW_NUMBER()的排序依据必须手动指定,否则结果不稳定 - 不同数据库对
ROW_NUMBER()的支持一致,但窗口函数语法细节略有差异(如 PostgreSQL 支持FILTER,SQL Server 不支持)
用ROW_NUMBER()实现“每组取第一条”的标准写法
核心思路:在子查询或 CTE 中用 ROW_NUMBER() 给每组内行编号,外层筛选 rn = 1。
SELECT user_id, order_id, amount, created_at
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY created_at DESC, order_id DESC
) AS rn
FROM orders
) t
WHERE rn = 1;
关键点说明:
-
PARTITION BY user_id定义分组维度,等价于GROUP BY user_id -
ORDER BY created_at DESC, order_id DESC决定“第一条”的含义:时间最新、时间相同时取 order_id 更大的(避免因时间精度相同导致随机取) - 必须用子查询或 CTE 包裹,因为窗口函数不能直接出现在
WHERE子句中 - 如果只需要去重而非“最新”,可改用
ORDER BY order_id ASC取最早插入的那条
替代方案对比:RANK() vs DENSE_RANK() vs ROW_NUMBER()
三者都可用于分组排序,但处理并列(tie)的方式不同,直接影响“第一条”是否唯一:
-
ROW_NUMBER():严格递增编号,即使created_at相同也分配不同序号(如 1,2,3),确保rn = 1只返回一行 -
RANK():并列时跳号(如 1,1,3),若多行并列第一,则rn = 1会返回多行——这不是“取第一条”,而是“取所有并列第一” -
DENSE_RANK():并列时不跳号(如 1,1,2),同样可能导致多行满足= 1
所以只要目标是“每组严格一条记录”,必须用 ROW_NUMBER(),且 ORDER BY 子句里要加入足够区分度的次级排序字段(如主键),否则数据库可能按任意顺序分配序号。
性能与索引注意事项
ROW_NUMBER() 是计算密集型操作,尤其数据量大时,执行计划常出现 WindowAgg 或 Sort 节点。优化关键在索引:
- 理想索引:复合索引
(user_id, created_at DESC, order_id DESC),能直接支持PARTITION BY + ORDER BY - 仅建
(user_id)单列索引效果有限,仍需额外排序 - MySQL 8.0+、PostgreSQL 13+、SQL Server 2019+ 对窗口函数有较好优化,但 Oracle 11g 等旧版本可能性能较差
- 如果只是简单去重(不关心哪条),且表有主键,
SELECT DISTINCT ON (user_id) *(PostgreSQL)或SELECT FIRST_VALUE(*)(部分引擎)可能更轻量,但可移植性差
真正容易被忽略的是:当 ORDER BY 字段存在大量 NULL 值时,不同数据库默认把 NULL 排在最前还是最后(如 PostgreSQL 默认 NULLS LAST,MySQL 默认 NULLS FIRST),会导致 ROW_NUMBER() 结果不一致——务必显式加上 NULLS LAST 或 NULLS FIRST 明确行为。

















