结论:必须用lead()配合partition by user_id order by login_date判断次日留存,lag()辅助识别首次登录;分母须为观察期内新用户,且需前置清洗脏数据。

直接说结论:用 LEAD() 判断“是否次日回来”,用 LAG() 辅助锚定首次行为,但两者都必须带 PARTITION BY user_id ORDER BY login_date,否则结果完全不可信。
为什么 LEAD(login_date) OVER (ORDER BY login_date) 是错的
这种写法会让所有用户混在一起排序,比如张三第 5 天登录,李四第 6 天登录,LEAD() 就可能把李四的日期塞给张三——业务上毫无意义。MySQL 8.0+ 甚至会直接报错;PostgreSQL 虽不报错,但结果随机,无法复现。
必须满足两个硬条件:
-
PARTITION BY user_id:确保只在同一个用户内找“下一次” -
ORDER BY login_date:显式声明顺序,不能依赖索引或主键;若存在秒级并发,建议追加id防止排序不稳定
怎么用 LEAD() 算次日留存,而不是“有没有下次登录”
LEAD() 只负责取值,不负责判断逻辑。常见错误是写成 WHERE next_login IS NOT NULL,这统计的是“有后续行为的用户”,不是“次日就回来的用户”。
真正要算次日留存,得做日期差计算:
- MySQL:用
TIMESTAMPDIFF(DAY, login_date, next_login) = 1 - 别用
DATE_ADD(login_date, INTERVAL 1 DAY) = next_login,它在跨月/跨年时易出错(如 1 月 31 日 +1 天 ≠ 2 月 1 日) - 更别用字符串截取或
DAYOFWEEK(),它们根本无法识别时间跨度
分母为什么不能直接 COUNT(DISTINCT user_id)
留存率的分母必须是“观察期内首次登录的新用户”,不是某天所有活跃用户。漏掉“首次”这个前提,就会把老用户也计入分母,导致留存率被严重稀释。
正确做法是先用窗口函数锚定每个用户的首次登录日:
-
MIN(login_date) OVER (PARTITION BY user_id)得到每个用户的first_login - 再筛选:
WHERE first_login BETWEEN '2026-05-01' AND '2026-05-07' - 原始数据含脏数据(如
'0000-00-00'或 NULL)时,务必前置过滤:WHERE login_date >= '2026-01-01' AND login_date IS NOT NULL
LAG() 在留存分析里不是可有可无的配角
它常被忽略,但实际有两个关键用途:
- 辅助识别“是否为首次登录”:对比
login_date和LAG(login_date) OVER (PARTITION BY user_id ORDER BY login_date),若为 NULL 或不等,说明是首行 → 即首次登录 - 做多日归因:比如算 7 日留存,可用
LAG(login_date, 6)拿到 7 天前的日期,再比对是否连续活跃 - 注意
LAG()默认返回 NULL,若想用 0 或其他占位符,得显式传第三个参数:LAG(login_date, 1, '1970-01-01')
最易被忽略的点:窗口函数本身不改变行数,但后续的 WHERE 或聚合会丢失明细。如果既要保留用户每次登录的完整轨迹,又要算留存率,得用子查询或 CTE 分层处理,不能指望一个 SELECT 全包下来。











