做用户运营分析时,一个绕不开的问题是:怎么知道每个用户第一次下单是什么时候?这个数据是计算新客占比、分析转化周期、做用户分层的基础。很多人第一反应是用MIN函数聚合,这确实能解决一部分问题,但如果还要拿到首单的金额、商品、渠道等完整信息,聚合写法就会变得笨重。本文介绍一种更通用、更优雅的方案:用ROW_NUMBER窗口函数排序定位,一条SQL就能取出每个用户的首单完整记录。

一、准备数据:订单表结构与测试数据
先明确场景。假设我们有一张订单表orders,核心字段包括订单ID、用户ID、下单时间、订单金额。真实业务中字段会更多,但分析思路完全一致。
CREATE TABLE orders (
order_id INT PRIMARY KEY,
user_id INT NOT NULL,
order_time DATETIME NOT NULL,
order_amount DECIMAL(10,2) NOT NULL
);
-- 插入测试数据
INSERT INTO orders VALUES
(1001, 1, '2024-03-15 10:20:00', 199.00),
(1002, 1, '2024-04-02 14:35:00', 89.00),
(1003, 2, '2024-03-20 09:10:00', 350.00),
(1004, 2, '2024-03-20 09:10:00', 120.00),
(1005, 3, '2024-05-01 20:00:00', 459.00),
(1006, 3, '2024-05-03 11:00:00', 60.00),
(1007, 1, '2024-06-10 08:30:00', 25.00);
注意测试数据里故意插入了两条时间完全相同的记录(1003和1004),这是为了后面讨论并列排序的问题。真实业务中同一用户同一时刻多笔订单的情况并不少见,比如秒杀场景,所以这个坑必须提前考虑到。
二、基础写法:GROUP BY加MIN聚合
如果只需要每个用户的首次下单日期,最简单的写法是对用户分组,取下单时间的最小值:
SELECT
user_id,
MIN(order_time) AS first_order_time
FROM orders
GROUP BY user_id;
这个写法在MySQL、PostgreSQL、SQL Server、Oracle里都能直接跑,性能也不错。但它有一个明显局限:只能拿到首单的时间,拿不到首单的其他字段。如果你尝试写成下面这样,在开启了严格模式的数据库中会直接报错,在宽松模式下则可能返回不确定的值:
-- 错误示范:非聚合列直接输出
SELECT
user_id,
MIN(order_time) AS first_order_time,
order_amount -- 问题所在
FROM orders
GROUP BY user_id;
原因是order_amount既没有参与聚合,也没有出现在GROUP BY里,数据库无法判断应该返回哪一行的金额。当然可以用子查询关联来绕过,比如先算出每个用户的最小时间,再回表匹配,但写法啰嗦,同一用户存在多条时间相同的订单时还可能出现重复行。所以当需求涉及首单的完整记录时,窗口函数是更好的选择。
三、核心方案:ROW_NUMBER排序定位首单
ROW_NUMBER是窗口函数的一种,作用是在分组内给记录连续编号。用它定位首单的思路分三步:按用户分区、按下单时间排序编号、取出编号为1的记录。因为窗口函数不能直接写在WHERE里,需要借助子查询或者CTE。
SELECT
user_id,
order_id,
order_time AS first_order_time,
order_amount AS first_order_amount
FROM (
SELECT
o.*,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY order_time ASC
) AS rn
FROM orders o
) t
WHERE rn = 1;
解读一下关键部分。PARTITION BY user_id表示按用户分组,每个用户的编号独立从1开始;ORDER BY order_time ASC定义组内排序规则,时间最早的排在最前面,编号自然就是1。外层过滤rn = 1,每个用户就只剩下最早的那一条订单,整行字段全部可用。
用CTE改写会更清晰,也方便在首单数据基础上做进一步统计,比如计算所有用户的平均首单金额:
WITH ranked AS (
SELECT
o.*,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY order_time ASC, order_id ASC
) AS rn
FROM orders o
)
SELECT
COUNT(*) AS first_order_users,
AVG(order_amount) AS avg_first_order_amount,
MIN(order_time) AS earliest_first_order
FROM ranked
WHERE rn = 1;
这里在排序条件里加了order_id ASC作为第二排序键,这不是可有可无的细节。回到前面埋的问题:用户2的两条订单时间完全相同,如果只按时间排序,两条记录的编号是不确定的,不同数据库、甚至同一数据库的不同执行计划都可能返回不同结果。加上一个确定性的第二排序键,可以保证结果稳定可复现,这在生产环境中非常重要。
四、常见坑点与进阶技巧
坑一:ROW_NUMBER与RANK的选择。如果用RANK或DENSE_RANK,遇到并列时间时会返回多条编号为1的记录,导致首单重复。ROW_NUMBER强制编号唯一,恰好符合取一单的需求。但如果业务上确实要求把同时间的多笔订单都算作首单,那就应该换成RANK,这是两者的本质区别。
坑二:NULL与脏数据。下单时间字段理论上不应为NULL,但如果上游写入有问题,NULL值在多数数据库的升序排序中会排在最前面,导致首单定位到一条脏数据。建议在排序前过滤order_time IS NOT NULL,或者用CASE WHEN把NULL排到最后。
坑三:数据量大时的性能。窗口函数需要先分区排序再编号,订单表达到千万级时,确保user_id, order_time上有联合索引能显著减少排序开销。另外避免用SELECT *,只取需要的列,能降低中间结果集的体积。
进阶一点,首单日期还可以直接作为新列回拼到用户维度表上,形成用户画像的一部分。比如配合用户注册时间,就能算出从注册到首次下单的间隔天数,这是衡量获客质量的核心指标之一:
WITH first_orders AS (
SELECT user_id, order_time AS first_order_time
FROM (
SELECT user_id, order_time,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time) AS rn
FROM orders
) t
WHERE rn = 1
)
SELECT
u.user_id,
u.register_time,
f.first_order_time,
DATEDIFF(f.first_order_time, u.register_time) AS days_to_first_order
FROM users u
LEFT JOIN first_orders f ON u.user_id = f.user_id;
兼容性方面,ROW_NUMBER窗口函数在MySQL 8.0以上、PostgreSQL、SQL Server、Oracle中均原生支持,语法基本一致。仍在使用MySQL 5.7的项目无法直接使用,可以退回子查询关联的写法,或考虑升级数据库版本来享受窗口函数带来的便利。
五、总结
统计用户首单,GROUP BY加MIN适合只取日期的场景,简单高效;需要首单完整记录时,ROW_NUMBER排序定位是标准解法,记住分区、排序、过滤三步走,并务必加上确定性的第二排序键避免并列时间带来的随机结果。掌握了这个模式,后续像取每个用户最近一笔订单、每个商品最新价格、每个部门最高绩效员工这类分组取值问题,都可以用同样的套路套用,一通百通。
ROW_NUMBERSQL窗口函数首次下单日期修改时间:2026-09-14 07:00:43