LEAD() 必须配合 PARTITION BY user_id 和 ORDER BY login_date 使用,否则结果不可靠;次日留存率需以首日新用户为分母,通过日期差判断是否为1天,而非仅检查LEAD()是否非空。

LEAD() 必须带 PARTITION BY 和 ORDER BY,否则结果不可靠
直接写 LEAD(login_date) 而不加 PARTITION BY user_id ORDER BY login_date,在 MySQL 8.0+ 会报错;PostgreSQL 虽允许,但行为未定义——可能跨用户取到下一行日期,导致“张三的登录日”被当成“李四的次日时间”。
常见错误写法:LEAD(login_date) OVER (ORDER BY login_date) —— 缺少分组,所有用户混在一起排,毫无业务意义。
- 必须按用户隔离:
PARTITION BY user_id - 必须严格时间先后:
ORDER BY login_date,若存在秒级并发或补录数据,建议追加, id防止排序不稳定 - 别依赖数据库默认排序:即使表有主键或索引,窗口函数仍需显式声明
ORDER BY
用 LEAD() 判断“是否次日留存”,不是看有没有下一行,而是看日期差是否为 1
LEAD() 只负责把下一次登录时间拎出来,它不判断“是不是次日”。真正决定留存的,是当前行和 LEAD() 返回值之间的日期差是否等于 1 天。
典型错误:直接 COUNT(*) WHERE next_login IS NOT NULL,这算的是“有后续行为的用户数”,不是“次日就回来的用户数”——比如用户第 1 天和第 3 天登录,LEAD() 会返回第 3 天,datediff(next_login, login_date) = 2,不该计入次日留存。
- MySQL 写法:
TIMESTAMPDIFF(DAY, login_date, next_login) = 1 - PostgreSQL/BigQuery:
next_login - login_date = INTERVAL '1 day'或DATE_DIFF(next_login, login_date, DAY) = 1 - 务必用数值型日期差,别用
DAYOFWEEK()或字符串截取,它们无法识别跨月/跨年场景
分母必须限定“首日落在观察期内的新用户”,不能直接 COUNT(DISTINCT user_id)
次日留存率 = 次日仍活跃的“首日新用户”数 ÷ 观察期内首次登录的用户总数。很多人漏掉“首日”这个前提,直接拿某天所有登录用户当分母,结果把老用户也塞进去,分母虚高、留存率偏低。
正确做法是先用窗口函数锚定每个用户的首次登录日:MIN(login_date) OVER (PARTITION BY user_id),再筛选出“首次登录日 ∈ [2026-05-01, 2026-05-07]”的用户作为分母基准。
- 如果原始数据含脏数据(如
login_date为'0000-00-00'或 NULL),MIN()会把它当真实值,建议前置过滤:WHERE login_date >= '2026-01-01' AND login_date IS NOT NULL - 别用子查询 JOIN 算首次登录日——性能差、可读性低;窗口函数一行搞定,且保持明细粒度,方便后续拼
LEAD() - 分母必须和分子在同一用户集合内:分子是“首日在观察期且次日也登录”的用户,分母只能是“首日在观察期”的全部用户
容易被忽略的“零留存”样本:没次日行为的用户在日志里根本不存在
原始日志只记录活跃行为。如果某用户 5 月 1 日首次登录,之后再没来过,他在 5 月 2 日的日志里完全不出现——这意味着你用 LEAD() + WHERE datediff = 1 只能拿到“留存成功”的人,却自动丢掉了“未留存”的人,导致分母缺失、结果偏高。
这不是 LEAD() 的问题,而是分析逻辑缺了一环:你需要构造“用户 × 时间”的完整空间,再打标。最轻量的做法是用 CTE 先生成首日用户清单,再 LEFT JOIN 自身(按 user_id 和 date + 1 天匹配)。
- 不要用
COALESCE(LEAD(), CURRENT_DATE)填充 NULL——未留存任务强行按“今天”算时长,会严重污染结果 - 也不要用
IFNULL(LEAD(), '9999-12-31')后再比日期差,逻辑彻底错乱 - 真正健壮的做法:分两步——先得首日用户集,再查他们中哪些人在次日有行为,用 LEFT JOIN + IS NULL 判断“未留存”










