用 ROW_NUMBER() 窗口函数按组排序取 rn=1 行,或在不支持窗口函数时用子查询先算每组 MIN(created_at),再与原表按分组字段和时间双重关联。

用 MIN() 聚合时间戳后,怎么拿到对应那条完整记录?
直接对时间字段用 MIN(created_at) 只能拿到最小值,拿不到该时刻对应的 user_id、status 等其他字段——这是最常卡住的地方。
核心思路不是“先聚合再取行”,而是“先定位每组最早时间点,再回表匹配”。常见做法是用窗口函数或自连接:
- 推荐用
ROW_NUMBER() OVER (PARTITION BY group_col ORDER BY created_at),给每组按时间排序,取rn = 1的行 - 如果数据库不支持窗口函数(比如旧版 MySQL),可用
JOIN+ 子查询:先算出每组MIN(created_at),再和原表按group_col和时间双重关联 - 注意:若同一组内有多个记录时间相同且都是最小值,
ROW_NUMBER()会任意选一条;要稳定结果需加额外排序条件,比如ORDER BY created_at, id
MySQL 5.7 不支持窗口函数,怎么安全实现?
子查询 + 关联是最稳妥的兼容方案,但容易写出“关联爆炸”或漏数据。
关键在关联条件必须同时满足两组约束:
- 主表和子查询的分组字段要完全一致(比如都用
user_id) - 时间字段要严格等于子查询结果:
t1.created_at = t2.min_time,不能用或忽略 <code>NULL处理 - 如果
created_at允许为NULL,子查询里得加WHERE created_at IS NOT NULL,否则MIN()返回NULL,关联失效
示例片段:
SELECT t1.* FROM orders t1 JOIN ( SELECT user_id, MIN(created_at) AS min_time FROM orders WHERE created_at IS NOT NULL GROUP BY user_id ) t2 ON t1.user_id = t2.user_id AND t1.created_at = t2.min_time
PostgreSQL / ClickHouse 用 DISTINCT ON 更简洁?
是的,但只适用于 PostgreSQL(ClickHouse 不支持)。它比窗口函数少写几行,但行为固定:按指定字段去重,保留每组排序后的第一行。
用法要点:
- 必须配合
ORDER BY,且首字段要和DISTINCT ON一致,比如DISTINCT ON (user_id) ORDER BY user_id, created_at - 不能只写
DISTINCT ON (user_id)就完事,否则报错 - 如果想取最早记录,
created_at必须升序;降序就变成取“最后一次” - 性能上通常优于子查询,因为 planner 能更好优化扫描顺序
示例:
SELECT DISTINCT ON (user_id) * FROM orders WHERE created_at IS NOT NULL ORDER BY user_id, created_at
为什么用 GROUP BY + MIN() 直接选其他字段会出错?
因为标准 SQL 禁止在 SELECT 里出现未出现在 GROUP BY 中、又没被聚合函数包裹的列——MySQL 5.7+ 默认开启 ONLY_FULL_GROUP_BY 模式后,这种写法直接报错:ERROR 1055 (42000)。
即使关掉该模式,返回的非聚合字段值也是不确定的(可能来自任意一行),不是你想要的“首次记录对应那行”的数据。
- 典型错误写法:
SELECT user_id, MIN(created_at), status FROM orders GROUP BY user_id -
status值不可预测,不同执行可能返回不同结果 - 有些数据库(如 PostgreSQL)连语法都不通过,强制要求所有非聚合列进
GROUP BY
别图省事绕过规则,不确定性会在数据量变大或升级数据库后突然暴露。
真正难的不是写出能跑的语句,而是让“首次”这个业务含义,在各种边界情况(空时间、重复时间、并发写入)下依然稳定可解释。

















