2250 字
11 分钟
3月30日刷题笔记--SQL峰值在线人数与次日留存率

本篇题目#

两道题共用同一张表 tb_user_log

iduidartical_idin_timeout_timesign_in
110102021-11-01 10:00:002021-11-01 10:00:421
210290012021-11-01 10:00:002021-11-01 10:00:090
310390012021-11-01 10:00:012021-11-01 10:01:500
410190022021-11-02 10:00:092021-11-02 10:00:280
510390022021-11-02 10:00:512021-11-02 10:00:590
610490012021-11-02 11:00:282021-11-02 11:01:240
710190032021-11-03 11:00:552021-11-03 11:01:240
810490032021-11-03 11:00:452021-11-03 11:00:550
910590032021-11-03 11:00:532021-11-03 11:00:590
1010190022021-11-04 11:00:552021-11-04 11:00:590

(uid-用户ID, artical_id-文章ID, in_time-进入时间, out_time-离开时间, sign_in-是否签到)


题目一:每篇文章同一时刻最大在看人数#

题目:统计每篇文章同一时刻最大在看人数,如果同一时刻有进入也有离开时,先记用户数增加再记减少,结果按最大人数降序。

预期输出

artical_idmax_uv
90013
90022

核心思路#

把每篇文章的阅读情况想象成一个房间,用户进来就+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_uv
FROM T2
GROUP BY artical_id
ORDER BY max_uv DESC;

每一步详解#

第一步 T1:为什么要用 UNION ALL 拆事件?#

原始一条记录有两个时间字段 in_timeout_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_timediffflaguv(累计)
10:00:00+111
10:00:01+112
10:00:09+113 ← peak
10:00:11-122
10:00:28+113
  • 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_time
SELECT artical_id, in_time AS curr_time, -1 AS diff, 2 AS flag
-- ✅ 正确:离开事件必须用 out_time
SELECT artical_id, out_time AS curr_time, -1 AS diff, 2 AS flag

离开事件用了 in_time,会导致同一时刻 +1 又 -1,uv 始终为 1,永远得不到正确峰值。


题目二:每天新用户的次日留存率#

题目:统计2021年11月每天新用户的次日留存率(保留2位小数)

  • 次日留存率 = 当天新增用户中,第二天又活跃的用户数 / 当天新增用户总数
  • 如果 in_timeout_time 跨天了,在两天里都记为该用户活跃过

预期输出

dtuv_left_rate
2021-11-010.67
2021-11-021.00
2021-11-030.00

核心思路#

每天新用户里,第二天还回来的占多少比例

需要知道两件事:

  1. 每天有哪些新用户(当天第一次出现的用户)
  2. 这些新用户第二天有没有活跃

解题流程#

原始数据 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_rate
FROM new_users n
LEFT 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_date
ORDER BY n.reg_date;

每一步详解#

第一步 new_users:找新用户#

MIN(DATE(in_time)) 取每个用户最早出现的日期,GROUP BY uid 保证每人只有一行。

不需要加 DISTINCTGROUP BY uid 已经保证了每个uid只有一行,再加是多余的。

第二步 active_days:找每人每天的活跃记录#

一条记录里有两个时间字段,一个用户可能跨天活跃,所以要把 in_timeout_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.uidn.reg_datea.uida.active_date
10111-0110111-02 ✅ 次日有活跃
10211-01NULLNULL ← 次日没活跃,填NULL
10311-0110311-02 ✅ 次日有活跃
10411-0210411-03 ✅ 次日有活跃
10511-03NULLNULL ← 次日没活跃,填NULL

常见坑详解#

① 次日条件要写在 ON 里,不能写在 WHERE 里#

这是 LEFT JOIN 最经典的陷阱。

-- ❌ 错误写法
LEFT JOIN active_days a ON n.uid = a.uid
WHERE 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 JOININNER 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_date
101 101 2021-11-02 ← 第一条活跃
101 101 2021-11-02 ← 第二条活跃
102 NULL NULL
103 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 里
  • 跨天活跃:用 UNIONin_timeout_time 两天都收进来
  • ON vs WHERE:次日条件写在 ON 里,写在 WHERE 里会让 LEFT JOIN 失效
  • COUNT(DISTINCT):防止同一用户因多条活跃记录被重复计数
3月30日刷题笔记--SQL峰值在线人数与次日留存率
https://www.yyylegend.com/posts/3月30日刷题笔记--sql次日留存率/
作者
YYYLEGEND
发布于
2026-03-30
许可协议
CC BY-NC-SA 4.0