本篇题目
两道题共用同一张表 tb_user_log:
| id | uid | artical_id | in_time | out_time | sign_in |
|---|---|---|---|---|---|
| 1 | 101 | 0 | 2021-11-01 10:00:00 | 2021-11-01 10:00:42 | 1 |
| 2 | 102 | 9001 | 2021-11-01 10:00:00 | 2021-11-01 10:00:09 | 0 |
| 3 | 103 | 9001 | 2021-11-01 10:00:01 | 2021-11-01 10:01:50 | 0 |
| 4 | 101 | 9002 | 2021-11-02 10:00:09 | 2021-11-02 10:00:28 | 0 |
| 5 | 103 | 9002 | 2021-11-02 10:00:51 | 2021-11-02 10:00:59 | 0 |
| 6 | 104 | 9001 | 2021-11-02 11:00:28 | 2021-11-02 11:01:24 | 0 |
| 7 | 101 | 9003 | 2021-11-03 11:00:55 | 2021-11-03 11:01:24 | 0 |
| 8 | 104 | 9003 | 2021-11-03 11:00:45 | 2021-11-03 11:00:55 | 0 |
| 9 | 105 | 9003 | 2021-11-03 11:00:53 | 2021-11-03 11:00:59 | 0 |
| 10 | 101 | 9002 | 2021-11-04 11:00:55 | 2021-11-04 11:00:59 | 0 |
(uid-用户ID, artical_id-文章ID, in_time-进入时间, out_time-离开时间, sign_in-是否签到)
题目一:每篇文章同一时刻最大在看人数
题目:统计每篇文章同一时刻最大在看人数,如果同一时刻有进入也有离开时,先记用户数增加再记减少,结果按最大人数降序。
预期输出:
| artical_id | max_uv |
|---|---|
| 9001 | 3 |
| 9002 | 2 |
核心思路
把每篇文章的阅读情况想象成一个房间,用户进来就+1人,出去就-1人,找房间里同时最多有几个人。
关键点:同一时刻有进入也有离开时,先加后减(先+1再-1),才能捕捉到真实峰值。
解题流程
原始数据(一条记录有两个时间点) ↓ UNION ALL 拆成两类事件 ↓ 进入事件(+1, flag=1) + 离开事件(-1, flag=2) ↓ SUM(diff) OVER(PARTITION BY artical_id ORDER BY time, flag) 滚动累加 → 每一行 = 那一时刻的实时在线数 ↓ MAX(uv) → 每篇文章峰值完整 SQL
WITH-- 第一步:把每条记录拆成进入和离开两个事件T1 AS ( SELECT artical_id, in_time AS curr_time, 1 AS diff, 1 AS flag FROM tb_user_log WHERE artical_id != 0
UNION ALL
SELECT artical_id, out_time AS curr_time, -1 AS diff, 2 AS flag FROM tb_user_log WHERE artical_id != 0),
-- 第二步:窗口函数滚动累加,得到每一时刻的实时在线人数T2 AS ( SELECT artical_id, SUM(diff) OVER ( PARTITION BY artical_id ORDER BY curr_time, flag ) AS uv FROM T1)
-- 第三步:取每篇文章的峰值SELECT artical_id, MAX(uv) AS max_uvFROM T2GROUP BY artical_idORDER BY max_uv DESC;每一步详解
第一步 T1:为什么要用 UNION ALL 拆事件?
原始一条记录有两个时间字段 in_time 和 out_time,需要把它变成两行独立事件来逐个处理,一个 SELECT 无法做到行数翻倍,所以必须用 UNION ALL。
原始一条记录:uid=102, artical_id=9001, in_time=10:00:00, out_time=10:00:09
拆成两条 ↓
(9001, 10:00:00, diff=+1, flag=1) ← 进入事件(9001, 10:00:09, diff=-1, flag=2) ← 离开事件这里用 UNION ALL 而不是 UNION,因为每个进入/离开事件都是独立的,不能去重,用 UNION 会把相同时间的事件误删。
diff 和 flag 各自的作用:
| 字段 | 作用 |
|---|---|
diff | 告诉 SUM 该加多少(进入+1,离开-1),管计算 |
flag | 同一时刻进入=1优先,离开=2靠后,管顺序 |
第二步 T2:窗口函数滚动累加
SUM(diff) OVER(PARTITION BY artical_id ORDER BY curr_time, flag) 执行过程(以9001为例):
| curr_time | diff | flag | uv(累计) |
|---|---|---|---|
| 10:00:00 | +1 | 1 | 1 |
| 10:00:01 | +1 | 1 | 2 |
| 10:00:09 | +1 | 1 | 3 ← peak |
| 10:00:11 | -1 | 2 | 2 |
| 10:00:28 | +1 | 1 | 3 |
| … | … | … | … |
PARTITION BY artical_id:9001 和 9002 各自独立累计,互不影响ORDER BY curr_time, flag:按时间排序,同一时刻进入(flag=1)先于离开(flag=2)
为什么同一时刻要先加后减?
假设 A 在 10:00:11 离开,B 也在 10:00:11 进入:
先减后加(错误):原来2人 → -1=1人 → +1=2人 峰值记录到2先加后减(正确):原来2人 → +1=3人 → -1=2人 峰值记录到3 ✅因为在现实中这一秒内两人是同时在场的,必须先加再减才能捕捉到真实峰值,这就是 flag 字段存在的意义。
常见坑
写错了 UNION ALL 第二个 SELECT 的时间字段:
-- ❌ 错误:两个 SELECT 都用了 in_timeSELECT artical_id, in_time AS curr_time, -1 AS diff, 2 AS flag
-- ✅ 正确:离开事件必须用 out_timeSELECT artical_id, out_time AS curr_time, -1 AS diff, 2 AS flag离开事件用了 in_time,会导致同一时刻 +1 又 -1,uv 始终为 1,永远得不到正确峰值。
题目二:每天新用户的次日留存率
题目:统计2021年11月每天新用户的次日留存率(保留2位小数)
注:
- 次日留存率 = 当天新增用户中,第二天又活跃的用户数 / 当天新增用户总数
- 如果
in_time和out_time跨天了,在两天里都记为该用户活跃过
预期输出:
| dt | uv_left_rate |
|---|---|
| 2021-11-01 | 0.67 |
| 2021-11-02 | 1.00 |
| 2021-11-03 | 0.00 |
核心思路
每天新用户里,第二天还回来的占多少比例
需要知道两件事:
- 每天有哪些新用户(当天第一次出现的用户)
- 这些新用户第二天有没有活跃
解题流程
原始数据 tb_user_log ↓ ┌─────────────┐ ┌──────────────┐ │ new_users │ │ active_days │ │ 每个用户 │ │ 每个用户 │ │ 首次登录日 │ │ 每天活跃日 │ └──────┬──────┘ └──────┬───────┘ │ │ └─────── LEFT JOIN ─────┘ 条件:同一用户 且活跃日 = 注册日+1 ↓ GROUP BY 注册日 ↓ 次日活跃人数 / 当天新用户总数完整 SQL
WITH-- 第一步:找每个用户的首次登录日期(新用户定义)new_users AS ( SELECT uid, MIN(DATE(in_time)) AS reg_date FROM tb_user_log GROUP BY uid),
-- 第二步:找每个用户每天的活跃日期(含跨天处理)active_days AS ( SELECT uid, DATE(in_time) AS active_date FROM tb_user_log UNION SELECT uid, DATE(out_time) AS active_date FROM tb_user_log)
-- 第三步:LEFT JOIN关联,计算次日留存率SELECT n.reg_date AS dt, ROUND(COUNT(DISTINCT a.uid) / COUNT(DISTINCT n.uid), 2) AS uv_left_rateFROM new_users nLEFT JOIN active_days a ON n.uid = a.uid AND a.active_date = DATE_ADD(n.reg_date, INTERVAL 1 DAY)WHERE DATE_FORMAT(n.reg_date, '%Y-%m') = '2021-11'GROUP BY n.reg_dateORDER BY n.reg_date;每一步详解
第一步 new_users:找新用户
用 MIN(DATE(in_time)) 取每个用户最早出现的日期,GROUP BY uid 保证每人只有一行。
不需要加 DISTINCT,GROUP BY uid 已经保证了每个uid只有一行,再加是多余的。
第二步 active_days:找每人每天的活跃记录
一条记录里有两个时间字段,一个用户可能跨天活跃,所以要把 in_time 和 out_time 都纳入统计。
这里用 UNION 而不是 UNION ALL,因为只关心”某天有没有活跃”,不关心活跃了几次,需要对 uid + 日期组合去重。
也可以写成 SELECT DISTINCT uid, DATE(in_time),效果完全一样。
第三步 LEFT JOIN:关联两张表
ON 后面两个条件:
n.uid = a.uid:同一个用户才能拼在一起a.active_date = DATE_ADD(n.reg_date, INTERVAL 1 DAY):活跃日期必须是注册日的次日
DATE_ADD(n.reg_date, INTERVAL 1 DAY) 就是把日期加一天,这个条件是整道题”次日留存”逻辑的核心。
JOIN 之后的结果:
| n.uid | n.reg_date | a.uid | a.active_date |
|---|---|---|---|
| 101 | 11-01 | 101 | 11-02 ✅ 次日有活跃 |
| 102 | 11-01 | NULL | NULL ← 次日没活跃,填NULL |
| 103 | 11-01 | 103 | 11-02 ✅ 次日有活跃 |
| 104 | 11-02 | 104 | 11-03 ✅ 次日有活跃 |
| 105 | 11-03 | NULL | NULL ← 次日没活跃,填NULL |
常见坑详解
① 次日条件要写在 ON 里,不能写在 WHERE 里
这是 LEFT JOIN 最经典的陷阱。
-- ❌ 错误写法LEFT JOIN active_days a ON n.uid = a.uidWHERE a.active_date = DATE_ADD(n.reg_date, INTERVAL 1 DAY)执行顺序是先 JOIN 后 WHERE。JOIN 之后102那行 a.active_date 是 NULL,NULL 无法满足 WHERE 条件,这一行被直接删掉,LEFT JOIN 形同虚设,变成了 INNER JOIN 的效果,分母少了人,结果偏高。
写在 ON 里则是在 JOIN 阶段就判断,找不到匹配的填 NULL,行不丢失,分母始终正确。
规律:只要用了 LEFT JOIN 且需要保留没有匹配的行,关联条件就要写在 ON 里,不能挪到 WHERE。
② new_users 要放在左边
LEFT JOIN 的规则是左表的行一定保留,右表找不到匹配就填 NULL。
new_users 是分母,每个新用户都必须出现在结果里。如果把 active_days 放左边,102、105 在 active_days 里根本没有次日记录,反过来 JOIN 就直接丢失了,导致分母偏小,结果偏高。
规律:哪张表的数据不能丢,哪张表就放左边。
③ 不能用 INNER JOIN 替代 LEFT JOIN
| LEFT JOIN | INNER JOIN | |
|---|---|---|
| 102(次日无活跃) | 保留,a.uid = NULL | 直接消失 |
| 105(次日无活跃) | 保留,a.uid = NULL | 直接消失 |
| 11-01留存率 | 2/3 = 0.67 ✅ | 2/2 = 1.00 ❌ |
| 11-03留存率 | 0/1 = 0.00 ✅ | 该行消失 ❌ |
④ COUNT 里必须加 DISTINCT
假设用户101次日活跃了2次(看了2篇文章),JOIN 之后同一个用户出现两行:
n.uid a.uid a.active_date101 101 2021-11-02 ← 第一条活跃101 101 2021-11-02 ← 第二条活跃102 NULL NULL103 103 2021-11-02不加 DISTINCT:COUNT(a.uid) = 3 ← 101被数了两次,结果偏高!加了 DISTINCT:COUNT(DISTINCT a.uid) = 2 ← 101只数一次 ✅COUNT(DISTINCT a.uid) 同时还能自动跳过 NULL,102那行不会被计入分子。
两道题对比
| 题目一(峰值在线) | 题目二(次日留存) | |
|---|---|---|
| 核心操作 | 事件拆分 + 窗口函数累加 | 新用户表 LEFT JOIN 活跃表 |
| UNION 用法 | UNION ALL(每个事件独立,不去重) | UNION(只看有没有活跃,去重) |
| 关键字段 | diff管计算,flag管顺序 | DATE_ADD算次日,LEFT JOIN保留无活跃用户 |
| 最终聚合 | MAX(uv) | COUNT(DISTINCT a.uid) / COUNT(DISTINCT n.uid) |
总结
题目一:
- 核心:一条记录拆成两个事件,用
SUM(diff) OVER(...)滚动累加求峰值 - flag 字段:同一时刻进入优先于离开,保证峰值被正确捕捉
- 常见错误:离开事件的时间字段写成了
in_time而不是out_time
题目二:
- 核心:新用户表 LEFT JOIN 活跃表,条件写在 ON 里
- 跨天活跃:用
UNION把in_time和out_time两天都收进来 - ON vs WHERE:次日条件写在 ON 里,写在 WHERE 里会让 LEFT JOIN 失效
- COUNT(DISTINCT):防止同一用户因多条活跃记录被重复计数