应使用窗口函数按用户分组排序标记首次和末次购买:row_number() over (partition by user_id order by order_date asc, order_id asc) as rn_first 和 desc 版本 as rn_last,再用 where rn_first=1 or rn_last=1 过滤;需预过滤 order_date is not null,明确“购买”指支付成功(paid_at)而非下单(created_at),并注意时间精度与null处理。

用窗口函数标记首次和末次购买时间
直接查 MIN(order_date) 和 MAX(order_date) 只能拿到全局最早/最晚时间,没法绑定到每个用户。得用窗口函数按用户分组排序,再取每组第一行或最后一行。
常见错误是写成 GROUP BY user_id 后套 ROW_NUMBER(),但没加 ORDER BY order_date —— 这会导致排序随机,首次/末次结果不可靠。
-
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date ASC)标记首次购买(值为 1 的那条) -
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date DESC)标记末次购买(值为 1 的那条) - 注意:如果同一用户同一天有多笔订单,
ASC/DESC默认不稳定,建议加二级排序,比如ORDER BY order_date ASC, order_id ASC
WHERE 子句里过滤出首次/末次订单
别在聚合后硬拼 MIN() 和原始表 JOIN,容易因多订单同天导致重复或漏行。更稳的方式是先用子查询或 CTE 打上标记,再过滤。
示例(PostgreSQL / MySQL 8.0+):
WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date ASC, order_id ASC) AS rn_first,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date DESC, order_id DESC) AS rn_last
FROM orders
)
SELECT user_id, order_date, amount
FROM ranked
WHERE rn_first = 1 OR rn_last = 1;
注意:rn_first = 1 和 rn_last = 1 是两个独立条件,用 OR 才能同时抓出首尾;若用 AND,只有单笔订单的用户才会命中。
处理 NULL 订单时间或异常数据
真实数据里 order_date 为空很常见,ROW_NUMBER() 会把 NULL 排在最前(ASC)或最后(DESC),导致“首次购买”变成空值记录。
- 加
WHERE order_date IS NOT NULL预过滤,比在窗口里处理更可控 - 如果业务允许把 NULL 当作未知时间,可用
CASE WHEN order_date IS NULL THEN '9999-12-31' ELSE order_date END统一兜底,避免排序错乱 - 某些旧版 MySQL 不支持窗口函数,得用自连接或变量模拟,但性能差、易出错,建议升级或换方案
区分“首次购买”和“首次下单”的语义差异
用户注册时间和首次付款时间常不同——很多系统里“下单”不等于“支付成功”。如果表里只有 created_at(下单时间)而没 paid_at(支付时间),用它算出的“首次购买”其实是首次下单,可能包含大量取消或未支付订单。
- 确认业务定义:财务口径要的是
paid_at,运营拉新看的是created_at - 检查字段是否可为空:
paid_at IS NOT NULL必须作为前置条件,否则MIN(paid_at)会跳过无效记录 - 时间精度影响判断:如果
order_date只到天级,同天多单无法区分先后,得依赖order_id或created_at微秒级字段补位
真正麻烦的不是语法,而是搞清“购买”在你系统里到底指什么动作、哪个字段承载这个含义。字段含义模糊时,再漂亮的 SQL 也救不了业务逻辑偏差。











