对每位用户按订单时间升序编号取首行,需用row_number()配合partition by user_id和order by created_at,在子查询或cte中计算后过滤rn=1,注意处理null、索引优化及数据库版本兼容性。

用 ROW_NUMBER() 按用户分组排序取首行
直接答案:对每位用户按订单时间升序编号,取 ROW_NUMBER() 为 1 的记录。这是最常用也最稳妥的方式,兼容 MySQL 8.0+、PostgreSQL、SQL Server、Oracle、BigQuery 等主流引擎。
关键点在于必须同时指定 PARTITION BY user_id 和 ORDER BY created_at(或 order_time),否则编号会全局连续,无法定位“每位用户”的首笔。
-
ROW_NUMBER()保证严格递增且不重复,哪怕两个订单时间完全相同,也能强制分出先后 - 如果业务允许“同秒内多单算同一笔”,可用
RANK()或DENSE_RANK(),但通常不推荐——首笔订单语义上要求唯一确定性 - 注意字段名:
created_at、order_time、submit_time因表而异,务必确认实际时间字段名
WHERE 子句里不能直接用窗口函数
常见错误是写成 SELECT * FROM orders WHERE ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) = 1 —— 这会报错,因为窗口函数不能出现在 WHERE 中。
正确做法是用子查询或 CTE 先计算编号,再过滤:
SELECT user_id, order_id, created_at
FROM (
SELECT user_id, order_id, created_at,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS rn
FROM orders
) t
WHERE rn = 1;
MySQL 8.0+ 和 PostgreSQL 支持 CTE,可读性更好:
WITH ranked AS (
SELECT user_id, order_id, created_at,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS rn
FROM orders
)
SELECT user_id, order_id, created_at
FROM ranked
WHERE rn = 1;
NULL 时间值会导致结果异常
如果 created_at 字段存在 NULL,ORDER BY created_at 默认把 NULL 排在最前(MySQL、PostgreSQL)或最后(SQL Server),导致首笔被误判为 NULL 订单。
- 加
WHERE created_at IS NOT NULL预过滤,最简单可靠 - 或显式控制 NULL 位置:
ORDER BY created_at ASC NULLS LAST(PostgreSQL、Oracle 支持);MySQL 不支持NULLS LAST,只能用IFNULL(created_at, '9999-12-31')临时兜底 - 检查数据质量:首笔订单时间为空,大概率是埋点或同步问题,不能只靠 SQL 修复
性能要注意索引和数据量
窗口函数本身不走索引,但 PARTITION BY + ORDER BY 字段组合如果有联合索引,能显著加速排序过程。
- 建议建索引:
CREATE INDEX idx_user_time ON orders(user_id, created_at) - 千万级订单表慎用:若只需用户维度聚合结果(如首单时间、首单金额),可先用
GROUP BY user_id取MIN(created_at),再关联原表查完整订单——比全量窗口更轻量 - 某些旧版数据库(如 MySQL 5.7)不支持窗口函数,得用自连接或变量模拟,逻辑复杂且易出错,优先升级或换方案
真正难的不是写出这句 SQL,而是确认 user_id 是否存在跨系统不一致、created_at 是否包含时区偏移、测试环境订单时间是否被脚本篡改——这些细节不核对清楚,结果看着对,实际线上可能全错。










