最可靠方法是用 ROW_NUMBER() 窗口函数按分组和时间排序取序号为1的行;老版本MySQL需用关联子查询;禁用 GROUP BY 直选非聚合字段;NULL 时间需显式处理。

用窗口函数 ROW_NUMBER() 最可靠
直接在分组内按时间字段排序并标记序号,再过滤出序号为 1 的行,是目前最通用、语义最清晰的做法。MySQL 8.0+、PostgreSQL、SQL Server、Oracle 都支持,且能正确处理时间相同的情况(比如多个记录同为最早时间,你明确只要一条)。
常见错误是用 GROUP BY + MIN(time_col) 再去 JOIN,但这样可能匹配到非原始记录(比如某条记录的 id 和另一条的 time_col 拼在一起,实际不存在)。
- 写法示例:
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY category ORDER BY created_at ASC) AS rn FROM orders ) t WHERE rn = 1;
-
PARTITION BY是你要分组的字段(如user_id、category),不是GROUP BY -
ORDER BY必须包含确定性排序依据;如果created_at可能重复,建议追加主键(如ORDER BY created_at, id)避免结果不稳定
MySQL 5.7 或更老版本只能靠相关子查询
这些版本不支持窗口函数,GROUP BY 本身又不能保证返回哪一行,所以得用子查询逐个找最小时间对应的完整记录。性能较差,数据量大时明显变慢,但逻辑直白、兼容性强。
容易踩的坑是子查询里漏了关联条件,导致查出全表最早时间而非每组最早时间。
- 正确写法:
SELECT o1.* FROM orders o1 WHERE o1.created_at = ( SELECT MIN(o2.created_at) FROM orders o2 WHERE o2.category = o1.category );
- 必须确保
WHERE中的关联字段(这里是o2.category = o1.category)和外层分组维度一致 - 如果存在多条同为最早时间的记录,这个写法会全部返回——和
ROW_NUMBER()行为不同,需提前确认业务是否允许
别误用 GROUP BY 直接选非聚合字段
像 SELECT category, id, MIN(created_at) FROM orders GROUP BY category 这种写法,在 MySQL 5.7 strict mode 下会报错,在其他数据库(如 PostgreSQL)根本无法执行。即使 MySQL 允许,id 的值也是未定义的——它不一定是最早那条记录的 id,可能是任意一条同分组里的 id。
这种写法看似省事,实则结果不可靠,线上环境出过不少数据错乱问题。
- MySQL 开启
ONLY_FULL_GROUP_BY时会直接拒绝执行 - 即使关掉该模式,也绝不要依赖服务器“随机”选出来的非聚合字段值
- 想省代码不如用窗口函数,想兼容老版本就用子查询,没第三条安全路径
时间字段为 NULL 时要主动处理
如果分组内某条记录的 created_at 是 NULL,默认排序时它通常排最前(MySQL、PostgreSQL),导致被误认为“最早”。业务上一般不把 NULL 当有效时间,得显式排除或调整排序逻辑。
- 推荐在
ORDER BY中写成:ORDER BY created_at ASC NULLS LAST(PostgreSQL 支持) - MySQL 不支持
NULLS LAST,可改用:ORDER BY (created_at IS NULL), created_at - 子查询方式也要在内层加
WHERE created_at IS NOT NULL,否则MIN()返回NULL,外层匹配失败
真正麻烦的不是语法怎么写,而是搞清“最早”在业务里是否允许 NULL、是否接受并列、以及目标数据库版本卡在哪——这些不确认清楚,再短的 SQL 也可能跑出错数据。

















