快手:分析直播观看时长,求每个用户最长一次直播观看时长以及对应主播。
Kuaishou: For each user, find their longest single livestream watch duration and the
考察要点
这道题是典型的 "Top N per Group" 问题,面试官想考察你是否掌握了解决此类问题的核心武器——窗口函数。同时,也希望看到你是否了解其他传统解法(如关联子查询、自连接),并能清晰地分析和对比不同方案在性能、可读性和场景普适性上的优劣。这背后反映了你对 SQL 语义的理解深度和性能优化意识。
核心概念
解决本题的核心是为每个用户的观看记录进行分组排序。涉及的关键概念如下:
-
窗口函数 (Window Functions): 与
GROUP BY这种将多行聚合成一行的函数不同,窗口函数会为结果集中的每一行计算一个值,该计算基于与当前行相关的行的集合(即“窗口”)。- 语法结构:
FUNCTION_NAME() OVER (PARTITION BY ... ORDER BY ...) PARTITION BY user_id: 定义窗口的范围。这里表示按user_id将数据分成不同的“桶”,函数在每个桶内独立计算。ORDER BY watch_duration_minutes DESC: 定义桶内数据的排序规则。这里按观看时长降序排列,最长的排在最前面。- 核心排序函数:
ROW_NUMBER(): 在每个分区内,从 1 开始为每一行分配一个唯一的连续排名。即使值相同,排名也不同。RANK(): 值相同时排名相同,但后续排名会跳跃。例如:1, 2, 2, 4。DENSE_RANK(): 值相同时排名相同,但后续排名是连续的。例如:1, 2, 2, 3。 对于本题“求最长一次”,使用ROW_NUMBER()是最精准且无歧义的选择,因为它能确保即使有时长并列的情况,也只选出其中一条记录。
- 语法结构:
-
公共表表达式 (
CTE- Common Table Expression): 使用WITH关键字定义的临时命名结果集,可以提高 SQL 的可读性和模块化。CTE在单个查询中可以被引用一次或多次。它是使用窗口函数的最佳拍档。sql1WITH RankedWatches AS (2 SELECT3 user_id,4 anchor_id,5 watch_duration_minutes,6 ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY watch_duration_minutes DESC) as rn7 FROM watch_log8)9SELECT user_id, anchor_id, watch_duration_minutes10FROM RankedWatches11WHERE rn = 1; -
关联子查询 (Correlated Subquery): 一种子查询,其执行依赖于外部查询提供的值。它会为外部查询的每一行都执行一次。
sql1-- 对于外部查询的每一行 w1,内部查询都会找到该用户(w1.user_id)的最大时长2SELECT user_id, anchor_id, watch_duration_minutes3FROM watch_log w14WHERE watch_duration_minutes = (5 SELECT MAX(watch_duration_minutes)6 FROM watch_log w27 WHERE w2.user_id = w1.user_id8);在没有合适索引的情况下,性能通常较差,接近 。
多种解法
假设我们有如下的直播观看日志表 watch_log:
| user_id | anchor_id | watch_duration_minutes |
|---|---|---|
| 101 | 201 | 30 |
| 101 | 202 | 60 |
| 101 | 203 | 60 |
| 102 | 201 | 45 |
| 102 | 204 | 90 |
| 103 | 202 | 10 |
期望输出:
| user_id | anchor_id | watch_duration_minutes |
|---|---|---|
| 101 | 202 | 60 |
| 102 | 204 | 90 |
| 103 | 202 | 10 |
| (注:user_id=101 有两条 60 分钟的记录,返回任意一条即可) |
解法 1:窗口函数 ROW_NUMBER()(最优解)
这是最现代、最高效、最推荐的解法。思路是先对每个用户的观看记录按时长降序排名,然后筛选出排名第一的记录。
- 适用版本: ✅ MySQL 8.0+ / PostgreSQL / Oracle / SQL Server
- SQL 代码:
sql1WITH RankedWatches AS (2 SELECT3 user_id,4 anchor_id,5 watch_duration_minutes,6 -- 对每个用户(PARTITION BY user_id)的观看记录按时长倒序(ORDER BY)编号7 ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY watch_duration_minutes DESC) AS rn8 FROM9 watch_log10)11SELECT12 user_id,13 anchor_id,14 watch_duration_minutes15FROM16 RankedWatches17WHERE18 rn = 1;
- 复杂度: 时间复杂度主要取决于排序操作,为 ,其中 N 是总记录数。空间复杂度为 ,需要存储中间结果。
- 索引依赖: 在
(user_id, watch_duration_minutes DESC)上建立复合索引可以极大地加速PARTITION BY和ORDER BY的过程,数据库可能能利用索引顺序避免显式排序。
解法 2:JOIN 聚合子查询
这是一种更传统的解法,兼容性好,但逻辑稍显迂回。思路是先用 GROUP BY 找出每个用户的最大观看时长,然后将原表与这个聚合结果进行 JOIN,找出匹配的记录。
- 适用版本: ✅ 所有主流数据库版本
- SQL 代码:
注意:最后的sql1SELECT2 w1.user_id,3 -- 如果有多个主播达到最大时长,GROUP BY会只返回一个,但具体是哪个不确定4 -- 为了结果确定性,可以加一个聚合函数,比如 MIN()5 MIN(w1.anchor_id) AS anchor_id,6 w1.watch_duration_minutes7FROM8 watch_log AS w19INNER JOIN (10 -- 步骤1: 找出每个用户的最大观看时长11 SELECT12 user_id,13 MAX(watch_duration_minutes) AS max_duration14 FROM15 watch_log16 GROUP BY17 user_id18) AS w219ON20 w1.user_id = w2.user_id AND w1.watch_duration_minutes = w2.max_duration21GROUP BY22 w1.user_id, w1.watch_duration_minutes;
GROUP BY和MIN(anchor_id)是为了处理时长并列的情况,确保每个用户只返回一行。 - 复杂度: 涉及一次
GROUP BY(通常是 的 Hash Aggregate 或 的 Sort Aggregate)和一次JOIN。整体性能通常也很好,接近 。 - 索引依赖: 在
(user_id, watch_duration_minutes)上建立复合索引,对子查询的GROUP BY和外部JOIN的条件都有加速效果。
解法 3:关联子查询
这种方法直观地表达了“查找时长等于该用户最大时长的记录”,但在老版本的 MySQL 或无索引的情况下性能堪忧。
- 适用版本: ✅ 所有主流数据库版本
- SQL 代码:
sql1SELECT2 w1.user_id,3 w1.anchor_id,4 w1.watch_duration_minutes5FROM6 watch_log AS w17WHERE8 w1.watch_duration_minutes = (9 SELECT MAX(watch_duration_minutes)10 FROM watch_log AS w211 WHERE w2.user_id = w1.user_id12 );13-- 注意:此方法在时长并列时会返回多行,需要额外处理14-- 例如用 DISTINCT ON (PostgreSQL) 或再包一层 GROUP BY
- 复杂度: 如果没有索引,对于
watch_log中的每一行,子查询都要执行一次,复杂度接近 。 - 索引依赖: 必须在
(user_id, watch_duration_minutes)上建立复合索引。有了这个索引,子查询的MAX()操作可以非常快地完成(索引扫描),性能会大幅提升,接近 。
常见低分回答
- 错误的
GROUP BY用法:这是最常见的错误,尤其对于刚从旧版 MySQL(ONLY_FULL_GROUP_BY关闭)过来的开发者。sql1-- 错误!anchor_id 是一个非聚合列,它的值是随机的,不一定对应最大时长2SELECT3 user_id,4 anchor_id,5 MAX(watch_duration_minutes)6FROM7 watch_log8GROUP BY9 user_id; - 忽略时长并列问题:解法 2 和 3 如果不加额外处理(如
GROUP BY或LIMIT 1),在最大时长有多条记录时会返回多行,这可能不符合“求最长一次”的题意。面试官会认为你考虑不周。 - 滥用
HAVING:试图用HAVING子句来代替WHERE进行行级过滤。sql1-- 逻辑错误,HAVING 用于对 GROUP BY 的聚合结果进行过滤2SELECT user_id, MAX(watch_duration_minutes)3FROM watch_log4GROUP BY user_id5HAVING watch_duration_minutes = MAX(watch_duration_minutes); -- watch_duration_minutes 在此作用域不可用 - 对
RANK()和ROW_NUMBER()不加区分:如果使用RANK()或DENSE_RANK()并在外层WHERE rank = 1,在时长并列时同样会返回多行。虽然有时业务需求如此,但若题目要求唯一行,则ROW_NUMBER()是更准确的选择。
工程实践与优化
- 索引是性能的关键:对于千万级以上的表,必须在
(user_id, watch_duration_minutes DESC)上建立复合索引。这个索引对于三种解法都有巨大提升:- 窗口函数:可以直接利用索引的有序性,极大减少排序开销。
JOIN子查询:GROUP BY和JOIN条件都能高效利用该索引。- 关联子查询:子查询的
MAX()操作可以变成一个快速的索引定位操作。
- 执行计划分析:使用
EXPLAIN/EXPLAIN ANALYZE查看执行计划。- 窗口函数解法:关注是否出现
Window: row_number,以及之前的Sort操作成本是否高。如果索引生效,可能看不到filesort。 JOIN解法:关注子查询是否走了Index Only Scan或Loose Index Scan,以及JOIN的类型是否为高效的Hash Join或Index Nested Loop Join。- 关联子查询解法:检查
Extra字段,避免出现Dependent subquery导致的高成本重复执行。
- 窗口函数解法:关注是否出现
- 处理数据倾斜:如果某个
user_id(如平台测试账号)有海量的观看记录,会导致PARTITION BY user_id时单个分区过大,拖慢整个查询。在离线数仓场景中,可以对这类 key 进行特殊处理(如单独计算或加随机盐打散)。 - ** tie-breaking(结果确定性)**:在生产环境中,结果必须是确定的。如果时长并列,业务上需要明确一个第二排序规则,比如“选择最近观看的”或“选择主播 ID 最小的”。这可以在
ORDER BY子句中体现。sql1-- 增加 session_time 作为第二排序键,保证结果唯一2ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY watch_duration_minutes DESC, session_time DESC) AS rn
常见追问与延伸题目
-
追问:如果题目改成“求每个用户观看时长 Top 3 的主播和时长”,你的 SQL 怎么修改?
- 回答要点:这正是窗口函数的优势所在。只需将解法 1 中的
WHERE rn = 1修改为WHERE rn <= 3即可。而对于JOIN或关联子查询的解法,实现起来会变得异常复杂和低效,这凸显了窗口函数在解决 Top-N 问题上的优越性和扩展性。
- 回答要点:这正是窗口函数的优势所在。只需将解法 1 中的
-
追问:如果这张
watch_log表有上百亿行,在 MySQL 上跑这个实时查询太慢了,有什么优化思路?- 回答要点:这考察的是数据架构能力。
- 离线批处理:将数据同步到大数据平台(如 Hive, Spark, MaxCompute)。每天或每小时运行一次批处理任务,将结果(每个用户的最长观看记录)计算好,存入一个轻量的结果表(如 MySQL 或 Redis)中,供线上业务查询。这是典型的数据仓库/数据湖架构。
- 实时计算:如果要求结果的实时性非常高,可以使用流处理引擎(如 Flink, Spark Streaming)。实时消费观看日志流,在内存或状态后端中为每个用户维护一个“当前最大观看时长和对应主播”的状态。当有新的观看记录时,更新这个状态。
- 预聚合/物化视图:在数据库层面,可以创建一个聚合表
user_max_watch_stats,通过定时任务或触发器(不推荐高频写入场景)来更新。查询时直接查这张结果表,避免对原始大表进行复杂计算。
- 回答要点:这考察的是数据架构能力。
-
追问:刚才你提到了
ROW_NUMBER、RANK和DENSE_RANK,请具体说明它们在本题场景下的差异。- 回答要点:
ROW_NUMBER(): 无论watch_duration_minutes是否相同,都会给出连续的排名(1, 2, 3, 4...)。使用WHERE rn = 1能确保每个用户严格只返回一行,即使存在时长并列的情况(会根据物理存储或额外排序键随机选择一个)。这符合题目“最长一次”的字面要求。RANK(): 时长相同时排名相同,但会跳过后续排名。例如,两个60分钟的记录都会被标记为rank=1,下一个时长(如45分钟)的记录会被标记为rank=3。使用WHERE rnk = 1会将所有最大时长的记录都返回。DENSE_RANK(): 与RANK()类似,时长相同时排名相同,但不会跳过后续排名。两个60分钟的记录是rank=1,下一个45分钟的记录是rank=2。使用WHERE drnk = 1同样会返回所有最大时长的记录。- 总结:如果产品需求是“找出最长的那一次观看”,用
ROW_NUMBER。如果需求是“找出所有时长并列为最长的观看记录”,则用RANK或DENSE_RANK。面试时清晰地指出这一点,会体现你的严谨性。
- 回答要点: