文 / STEM Career 编辑部
留存分析的 SQL 题,看起来经常只有几行查询,真正容易出错的却是分群、日期和分母。同一组事件,按日历日、滚动时间或不同活跃定义计算,结果可能不同。
本文用一组可以手算的原创数据,练习注册后第七个日历日的目标行为留存,并给出 SQLite 查询。先讲清口径,再写代码,面试时才能解释结果为什么成立。
先固定本题定义
用户按注册日期分群。目标行为是 action;第七日指注册日期加七个日历日,不包含注册当天;只有第七日已完整结束的用户进入分母。所有时间已按题设统一为 UTC,观测截止为 2026 年 10 月 10 日,不包含这一天。
因此,10 月 2 日注册的用户,第七日是 10 月 9 日,可以观察;10 月 3 日注册的用户,第七日是 10 月 10 日,尚未完整进入本次窗口,应排除。
这是一种教学定义,不是唯一留存标准。真实题目应先澄清目标行为、注册还是首次活跃分群、时间区间和成熟窗口。
原创用户与事件数据
| 用户 | 注册日 | 目标行为日期 |
|---|---|---|
| A | 10 月 1 日 | 10 月 8 日两次 |
| B | 10 月 1 日 | 10 月 7 日 |
| C | 10 月 2 日 | 10 月 9 日一次 |
| D | 10 月 2 日 | 无 |
| E | 10 月 3 日 | 本次观测未成熟 |
A 的两次事件只算一个留存用户;B 在第六日发生行为,不算本题第七日;C 符合;D 不符合;E 排除出分母。
手算结果应为 10 月 1 日组两人中一人,第七日留存 0.5;10 月 2 日组同样为 0.5。没有成熟窗口的分群不显示为零留存。
先建一张用户粒度的结果表
最重要的中间结果是每位成熟用户一行,标记是否在目标日发生行为。不要直接连接事件后用 COUNT(*) 计算人数,否则 A 的重复行为会扩大记录数。
没有行为的用户仍应保留,因此不能用只保留匹配事件的连接方式。先固定分母人群,再计算标记,查询会更容易解释与复核。
SQLite 日期函数文档说明了 date 等函数的用法。下方示例使用其方言;换到 PostgreSQL 或其他数据库时,需要按对应日期函数调整实现。
可运行的教学查询
WITH users(user_id, signup_day) AS (
VALUES ('A','2026-10-01'),('B','2026-10-01'),
('C','2026-10-02'),('D','2026-10-02'),
('E','2026-10-03')
),
events(user_id, event_day, event_type) AS (
VALUES ('A','2026-10-08','action'),
('A','2026-10-08','action'),
('B','2026-10-07','action'),
('C','2026-10-09','action')
),
per_user AS (
SELECT u.user_id, u.signup_day,
CASE WHEN EXISTS (
SELECT 1 FROM events e
WHERE e.user_id = u.user_id
AND e.event_type = 'action'
AND e.event_day = date(u.signup_day, '+7 days')
) THEN 1 ELSE 0 END AS retained
FROM users u
WHERE date(u.signup_day, '+7 days') < '2026-10-10'
)
SELECT signup_day, COUNT(*) AS eligible_users,
SUM(retained) AS retained_users,
1.0 * SUM(retained) / COUNT(*) AS retention
FROM per_user
GROUP BY signup_day
ORDER BY signup_day;
EXISTS 只回答是否存在符合条件的事件,避免把重复事件算成多位用户。成熟条件与观察截止采用严格小于,和前面的排除定义一致。实际数据库还要按数据类型和业务时区处理。
看结果前先做三个检查
先核对用户表是否一人一行。若同一用户有多个注册时间,需要先定义主记录,不能在查询末尾用 DISTINCT 试图掩盖来源问题。
再核对事件是否已按目标时区转为日期。UTC 午夜附近的事件,换到当地日期可能落在相邻一天。不要对混合时区的原始字符串直接截取日期。
最后确认观测数据是否完整。事件延迟到达时,某个日历日结束不代表数据已经齐全,成熟条件还可能需要考虑数据延迟。
变体一 滚动窗口会改变什么
如果题目要求注册后第 168 至 192 小时发生行为,用户注册时刻就重要了。仅有日期的教学数据不足以计算,需要原始时间戳与明确边界。
这时不能简单把 date 加七天的查询改个标题。应重新定义上下界、使用时间类型,并测试恰好落在边界的事件。对面试官说明缺少哪些字段,也是分析能力的一部分。
变体二 取消或删除用户怎样处理
如果某用户在第七日前取消账号,是否仍保留在原注册分群,要由业务定义决定。删除后直接从用户表消失,可能让历史分母变化,引入选择偏差。
可以保存用于分析的适当历史状态,并遵守数据管理要求。面试回答先明确问题,再讨论可用数据,不能为了得到更高留存而把不活跃用户排除。
变体三 某一组没有成熟用户
没有成熟观察窗口时,结果可以不出现该组,或显示人数为零、留存未知,取决于报告约定。未知与零是不同信息,需要在展示里让使用者看懂。
若业务需要连续日期,可以先生成日期骨架再连接结果,但不要把缺失率统一填零。还要处理分母为零,避免查询报错或产生错误解释。
业务结论要匹配小样本
本例每组只有两位成熟用户,无法据此判断产品或渠道长期表现。教学数据用于复核逻辑,真实分析还需观察样本结构、规模与不确定性。
如果某次改版后留存升高,先检查分群与行为口径是否变化、用户构成是否不同,再讨论可能原因。相关变化不能直接写成改版的因果效果。
可配合数据分析 SQL 与指标练习,或从产品面试的用户与指标继续研究业务含义。
把边界事件放进手算表
可以再加入三条事件:A 在 10 月 9 日发生 action,B 在 10 月 8 日发生 view,E 在 10 月 10 日发生 action。按本题定义,第一条不改变 A 的第七日标记;第二条不是目标行为;第三条所在观察日尚未完整纳入,因此 E 仍不进分母。
这三个变化检验日期、行为和成熟窗口是否分别正确。若代码结果改变,先检查筛选条件,而不是立即怀疑聚合函数。保留中间的一人一行表,通常更容易定位问题。
成熟窗口会影响渠道比较
假设一个渠道用户集中在最近几天,另一个渠道主要是较早注册。直接把所有注册用户作为分母,会让较新渠道看起来更低,原因却可能只是还没有观察机会。
应先保证比较对象都有相同窗口,再说明渠道、用户类型和样本结构。若要比较不同日期组的整体率,可以用成熟用户总数与留存用户总数计算加权结果,不能随意平均大小不同的分群百分比。
本例两个分群人数相同,平均率恰好也是 0.5;人数不同后就需要重新加权。面试时给一个三人组与一人组的反例,可以帮助说明为什么分母比最后显示的小数更重要。
再看一个加权反例:一个三人组留存两人,另一个一人组留存零人。整体是四人中两人,即 0.5;两个组率的简单平均约为 0.333。先明确要回答整体用户表现还是平均分群表现,再选择计算。
把查询解释成一分钟回答
先说分群与行为,再说成熟条件和一人一行的中间表,最后展示结果与边界。面试官应能沿着你的说明手算一条用户,而不只看到查询运行成功。
如果你写得出 SQL,却常在分母和日期追问中卡住,可以加微信说明目标数据岗位和最容易出错的题型。通过专项面试准备先补口径、手算与边界,再练更复杂查询,会让能力更稳定。
常见问题(FAQ)
重复事件会重复计算留存用户吗?
本题先按用户判断是否存在目标行为,一人只计一次。
最近注册组没有完整窗口算零吗?
应区分未成熟与零留存,按明确定义排除或显示未知。
各组留存率能直接平均吗?
要先明确问题;整体用户率通常需按人数计算。
- SQLite 日期函数文档· SQLite
- 对应日期函数· PostgreSQL
如果 SQL 能写出结果,但面试时解释不清口径,加微信说一下目标数据岗和最常出错的题型,可以先把专项训练范围定下来。
