“首次消费时间”指用户第一次产生消费行为的时间(首单时间),是cohort留存分析的唯一分组锚点;必须用窗口函数MIN(order_time) OVER(PARTITION BY user_id)预先计算,再按月截断为cohort,并确保时区统一、null值过滤及业务时间准确。

什么是cohort留存分析中的“首次消费时间”关键点
cohort留存分析的核心不是按订单时间分组,而是按每个用户**第一次产生消费行为的时间**(即首单时间)归入对应 cohort。如果直接用 ORDER BY order_time 或按自然月分组,会把同一个用户多次分散到不同 cohort,导致留存率虚高或失真。
必须先为每个用户计算出唯一的 first_order_date,再以此作为 cohort 锚点。这个字段不能来自聚合结果的别名(比如在 GROUP BY 外 SELECT),必须在子查询或 CTE 中预先算好。
用窗口函数 MIN() + PARTITION BY user_id 算出首单时间
这是最常用且兼容性好的做法,适用于 PostgreSQL、MySQL 8.0+、SQL Server、BigQuery 等主流引擎。注意不能用 GROUP BY user_id 后再 JOIN,那样会丢失原始订单粒度,无法计算后续月份是否复购。
- 先用
MIN(order_time) OVER (PARTITION BY user_id)为每条订单打上该用户的首单时间 - 用
DATE_TRUNC('month', first_order_date)(PostgreSQL/BigQuery)或DATE_FORMAT(first_order_date, '%Y-%m')(MySQL)统一 cohort 月粒度 - 避免用
TO_CHAR(first_order_date, 'YYYY-MM')这类字符串截取——它可能因时区或格式不一致导致分组错误
计算留存:用日期差判断“第 N 个月是否活跃”
留存的本质是:在 cohort 月之后的第 K 个月内,该用户是否还有至少一笔订单。不能只看订单数,而要看 COUNT(DISTINCT user_id) 是否大于 0。
- 用
EXTRACT(YEAR FROM order_time) * 12 + EXTRACT(MONTH FROM order_time)算出订单所在“年月序号”,减去 cohort 年月序号,得到month_diff - MySQL 可用
TIMESTAMPDIFF(MONTH, cohort_month, order_time),但要注意它对跨年计算是可靠的,而PERIOD_DIFF()在某些版本中不支持跨年 - 务必加
WHERE month_diff >= 0,否则会把 cohort 月之前的订单也计入(常见于数据延迟或时间字段误用)
容易被忽略的细节:null 值、时区、和 cohort 宽度选择
很多分析结果偏差,其实卡在三个隐形环节:
-
first_order_date为空的用户必须过滤掉——这些可能是测试账号、退单未支付用户,强行纳入会稀释分母 - 所有时间字段操作前,确认是否已转为统一时区(如
AT TIME ZONE 'UTC'或CONVERT_TZ()),否则上海用户和旧金山用户的“同一天”会被分到不同 cohort - cohort 宽度不一定是月;如果是高频服务(如 SaaS 登录),用周或日更合理,此时
DATE_TRUNC('week', ...)比硬套月逻辑更准
真正难的不是写 SQL,而是确保 user_id 在全链路唯一、order_time 是支付成功时间而非创建时间、以及 cohort 切片与业务目标对齐。这三个地方错一个,整个留存曲线就不可信。










