COUNT(DISTINCT)计算留存率和复购率的核心在于分母精准锁定新用户群或当月下单用户,分子须绑定用户历史行为标识,且需显式转浮点、统一时区与日期粒度。

COUNT(DISTINCT) 是计算留存率和复购率最核心的聚合函数,但直接套用会出错——关键不在“怎么数”,而在“对谁数、按什么条件数”。
分母必须锁定“新用户群”,不能是任意日期的活跃用户
常见错误是写成:COUNT(DISTINCT CASE WHEN login_date = '2026-09-01' THEN user_id END) / COUNT(DISTINCT user_id)。这算的是“当天活跃用户占全量用户的比率”,不是留存率。
正确做法是先明确分母:只取某天首次登录的用户(即 MIN(login_date) = '2026-09-01' 的那些 user_id),再查他们次日是否出现。
- 分母必须来自子查询或 CTE,例如:
(SELECT COUNT(DISTINCT user_id) FROM login WHERE user_id IN (SELECT user_id FROM login GROUP BY user_id HAVING MIN(login_date) = '2026-09-01')) - 如果业务要求“自然日留存”,分母里不能包含
login_date为 NULL 或远古时间(如'1970-01-01')的脏数据,得提前过滤 - MySQL 8.0+ 和 Spark SQL 支持 CTE,写法更清晰;Hive 旧版本建议用内联视图替代
分子要用条件聚合,而不是简单 COUNT
次日留存的分子不是“次日所有用户”,而是“首日新用户中,次日也登录过的那批人”。所以不能只写 COUNT(DISTINCT user_id),而要绑定首日身份:
- 推荐写法:
COUNT(DISTINCT CASE WHEN DATEDIFF(login_date, first_login_date) = 1 THEN user_id END),其中first_login_date来自子查询或窗口函数 - 避免用
WHERE login_date = DATE_ADD(first_login_date, INTERVAL 1 DAY)做 JOIN 条件——若某用户首日登录后次日完全没行为,这条记录就不存在,JOIN 会丢掉该用户,导致分母虚高、分子偏低 - 如果数据库支持
LEFT JOIN+IS NOT NULL判断,比CASE WHEN更易读,但性能略差
复购率的分母是“当月下单用户”,不是“历史总用户”
字节跳动面试题里明确定义:复购率 = 当月复购用户数 / 当月总下单用户数。这里“复购用户”指“当月下单 & 之前有过下单”的用户,不是“当月第二次下单”。
- 错误理解:
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date) > 1→ 这找的是“当月第二单及以上”,漏掉了首单在上月、本月只下一单的复购用户 - 正确逻辑:先标记每个用户的历史首单时间(用
MIN(order_date) OVER (PARTITION BY user_id)),再判断“当前订单日期 > 该用户首单日期” - 注意时区和日期截断:订单表若含时分秒,务必用
TRUNC(order_date)(Oracle/Redshift)或DATE(order_date)(MySQL/PostgreSQL)统一粒度
ROUND 和类型转换不处理,结果会是 0
SQL 中整数除整数默认向下取整,1 / 2 得 0,不是 0.5。留存率这种小数必须显式转浮点。
- 安全写法:
ROUND(COUNT(DISTINCT retained_users) * 1.0 / COUNT(DISTINCT cohort_users), 3) - 别依赖数据库隐式转换:Hive 默认用
DOUBLE,但 Presto 需要CAST(x AS DOUBLE),Spark SQL 推荐x * 1.0 - 如果结果列用于下游 BI 工具(如 Tableau、QuickSight),保留 3 位小数比 4 位更稳妥,避免展示异常(如
0.6666666666666666)
真正容易被忽略的是“时间边界对齐”:用户注册时间、登录时间、订单时间如果来自不同系统,可能跨小时甚至跨天(比如注册时间用 UTC,行为时间用本地时区)。不统一时区就直接算 DATE_DIFF,结果偏差会随时间推移放大。

















