找出连续两天都有登录、且第 2 天比第 1 天活跃时长多的用户。
Find users who logged in on two consecutive days and had a longer active duration on the second day
考察要点
这道题面试官想看你是否掌握处理序列数据的核心能力。具体来说,考察两点:
- 现代 SQL 特性:是否熟练使用窗口函数(
LAG/LEAD)来高效计算行与行之间的关系,这是衡量候选人 SQL 技能先进性的重要标志。 - 经典 SQL 思维:是否能用传统但通用的方法,如自连接(Self-Join),来解决同样的问题,并理解其与窗口函数的性能差异。
同时,这道题也附带考察了对日期函数的运用和索引优化意识。
核心概念
解决本题依赖于对以下概念的精准理解:
-
窗口函数 (Window Functions) 窗口函数对一组与当前行相关的表行(这个组称为窗口)执行计算。与聚合函数不同,它在返回计算结果的同时,不改变原始表的行数。
- 核心函数
LAG():LAG(expression, [offset], [default]) OVER (PARTITION BY ... ORDER BY ...)expression: 要访问的列,如此处的login_date和duration_minutes。offset: 偏移量,默认为 1,即上一行。default: 如果上一行不存在,返回的默认值。PARTITION BY user_id: 将数据按用户分组,窗口函数在每个用户内部独立计算,不会跨用户。ORDER BY login_date: 在每个用户分组内,按登录日期排序,确保LAG()获取的是前一天的数据。
示例: 假设有登录表
user_logins:sql1CREATE TABLE user_logins (2 user_id INT,3 login_date DATE,4 duration_minutes INT,5 PRIMARY KEY (user_id, login_date)6);7INSERT INTO user_logins VALUES8(1, '2023-10-01', 60),9(1, '2023-10-02', 80),10(2, '2023-10-01', 120),11(2, '2023-10-02', 100);使用
LAG()查询后,中间结果(CTE中)会像这样:user_id login_date duration_minutes prev_date prev_duration 1 2023-10-01 60 NULL NULL 1 2023-10-02 80 2023-10-01 60 2 2023-10-01 120 NULL NULL 2 2023-10-02 100 2023-10-01 120 - 核心函数
-
自连接 (Self-Join) 自连接是指将一张表与其自身进行连接。为了区分,必须为表使用两个不同的别名(如
t1和t2)。它适用于需要比较同一张表中不同行数据的场景。- 连接条件:
ON t1.user_id = t2.user_id保证是同一个用户。 - 逻辑条件:
ON ... AND t2.login_date = DATE_ADD(t1.login_date, INTERVAL 1 DAY)找到连续两天的记录。t1代表第一天,t2代表第二天。
- 连接条件:
-
日期函数
- MySQL:
DATE_ADD(date, INTERVAL expr unit)或DATE_SUB(...)。例如DATE_ADD(t1.login_date, INTERVAL 1 DAY)。 - PostgreSQL:
t1.login_date + interval '1 day'。 DATEDIFF(date1, date2): 计算两个日期之间的天数差。DATEDIFF(t2.login_date, t1.login_date) = 1也可以实现,但将函数应用于列可能影响索引效率,不如t2.login_date = ...的形式(SARGable)。
- MySQL:
多种解法
解法 1:使用窗口函数 LAG()(推荐)
这种方法思路清晰,性能优越,是现代 SQL 的首选方案。
- 版本: ✅ MySQL 8.0+ / PostgreSQL / Oracle / SQL Server;❌ MySQL 5.7 不支持窗口函数。
- SQL:
1WITH LoginWithPrev AS (2 SELECT3 user_id,4 login_date,5 duration_minutes,6 LAG(login_date, 1) OVER (PARTITION BY user_id ORDER BY login_date) AS prev_login_date,7 LAG(duration_minutes, 1) OVER (PARTITION BY user_id ORDER BY login_date) AS prev_duration8 FROM9 user_logins10)11SELECT12 user_id13FROM14 LoginWithPrev15WHERE16 -- 条件1: 保证是连续两天17 prev_login_date IS NOT NULL AND DATEDIFF(login_date, prev_login_date) = 118 -- 条件2: 第2天活跃时长更长19 AND duration_minutes > prev_duration;
注:DATEDIFF(login_date, prev_login_date) = 1 在这里是安全的,因为它作用于 CTE 的计算结果,不影响底层表的索引使用。也可以写成 login_date = DATE_ADD(prev_login_date, INTERVAL 1 DAY)。
- 复杂度:
- 时间复杂度: 。主要是
PARTITION BY ... ORDER BY产生的排序开销。如果数据已按(user_id, login_date)物理有序,则可降至 。 - 空间复杂度: ,需要存储
CTE的中间结果。
- 时间复杂度: 。主要是
- 索引依赖: 强烈建议在
(user_id, login_date)上建立复合索引。这能极大地加速PARTITION BY和ORDER BY操作,使其变为高效的索引扫描,而非全表扫描+文件排序。
解法 2:使用自连接 SELF JOIN
这是经典解法,兼容性好,在不支持窗口函数的老版本数据库中是唯一选择。
- 版本: ✅ 所有主流数据库版本。
- SQL:
1SELECT DISTINCT2 t2.user_id3FROM4 user_logins t15JOIN6 user_logins t2 ON t1.user_id = t2.user_id7WHERE8 -- 条件1: 关联上连续的后一天9 t2.login_date = DATE_ADD(t1.login_date, INTERVAL 1 DAY)10 -- 条件2: 第2天活跃时长更长11 AND t2.duration_minutes > t1.duration_minutes;
注:使用 DISTINCT 是为了防止 (user_id, login_date) 不唯一时产生重复的用户 ID。如果主键保证了其唯一性,则 DISTINCT 可省略。
- 复杂度:
- 时间复杂度: 理论上可达 ,但在有合适索引的情况下,通常为 或类似,其中 K 是每个用户的平均登录天数。数据库的 Join 优化器会选择高效的算法(如 Index Nested Loop Join)。
- 空间复杂度: ,其中 M 是满足 Join 条件的中间结果集大小,通常远小于 。
- 索引依赖: 复合索引
(user_id, login_date)至关重要。查询t2时,优化器可以利用该索引,通过t1.user_id和DATE_ADD(t1.login_date, ...)的值进行快速定位(ref或eq_ref访问)。
常见低分回答
-
在
WHERE子句中对列使用函数:sql1-- 反例2SELECT ... FROM user_logins t1 JOIN user_logins t2 ON t1.user_id = t2.user_id3WHERE DATE_ADD(t1.login_date, INTERVAL 1 DAY) = t2.login_date;这会导致
t2.login_date上的索引可能失效,因为优化器需要对t1的每一行计算DATE_ADD的结果,再去t2中查找,而不是直接使用t2的索引。应将条件写成t2.login_date = DATE_ADD(t1.login_date, INTERVAL 1 DAY),将函数计算放在非索引侧。 -
窗口函数忘记
PARTITION BY:sql1-- 反例2LAG(login_date, 1) OVER (ORDER BY login_date) AS prev_login_date这会导致窗口函数在所有用户数据上进行计算,把用户 A 的最后一天和用户 B 的第一天错误地关联起来。
-
误用
DATEDIFF作为连接条件:sql1-- 反例2FROM user_logins t1 JOIN user_logins t2 ON t1.user_id = t2.user_id AND DATEDIFF(t2.login_date, t1.login_date) = 1虽然逻辑正确,但
DATEDIFF函数包裹了t1和t2的两列,使得优化器很难同时高效利用(user_id, login_date)索引进行连接。t2.login_date = DATE_ADD(...)的形式是 SARGable(可作为搜索参数),对索引更友好。 -
使用相关子查询(Correlated Subquery):
sql1-- 性能较差的反例2SELECT u1.user_id FROM user_logins u13WHERE u1.duration_minutes > (4 SELECT u2.duration_minutes FROM user_logins u25 WHERE u2.user_id = u1.user_id AND u2.login_date = DATE_SUB(u1.login_date, INTERVAL 1 DAY)6)这种写法对于
u1的每一行,都需要执行一次子查询,性能通常远低于JOIN和窗口函数。
工程实践与优化
- 索引选择:
PRIMARY KEY (user_id, login_date)或UNIQUE INDEX (user_id, login_date)是最优解。它不仅保证了数据唯一性,还为JOIN和PARTITION BY提供了完美的覆盖索引,避免了回表和文件排序。 - 执行计划分析:
- 解法 1 (窗口函数):使用
EXPLAIN查看。在 MySQL 8.0+ 中,Extra字段会显示Using window function。关键是看它是否利用了(user_id, login_date)索引来避免Using filesort。 - 解法 2 (自连接):
EXPLAIN的输出至关重要。对t1的访问应该是type: index或ALL。对t2的访问类型应该是type: ref或eq_ref,key字段应显示(user_id, login_date)索引,ref字段会显示db.t1.user_id和一个func(代表DATE_ADD的结果)。这表明对t2的每次查找都是高效的索引查找。
- 解法 1 (窗口函数):使用
- 数据倾斜:如果少数超级用户的登录记录远超其他用户,
PARTITION BY可能会导致内存压力。在分布式环境(如 Spark, Flink)中,这可能导致单个任务节点成为瓶颈。此时需要考虑对这些大user_id进行特殊处理或增加资源。 - 时间戳与时区:如果列是
DATETIME或TIMESTAMP而非DATE,问题会复杂化。DATE(login_timestamp)会导致索引失效。正确的处理方式是使用范围查询:t2.login_timestamp >= DATE_ADD(DATE(t1.login_timestamp), INTERVAL 1 DAY)ANDt2.login_timestamp < DATE_ADD(DATE(t1.login_timestamp), INTERVAL 2 DAY)。同时必须注意时区问题,所有计算最好在统一时区(如 UTC)下进行。
常见追问与延伸题目
-
追问:如果数据量增长 100 倍(达到数十亿级别),这条 SQL 性能怎样?怎么优化?
- 回答要点 1 (瓶颈分析):窗口函数解法通常优于自连接。但当单个分区(单个用户的数据)过大无法完全载入内存时,排序会溢出到磁盘,性能急剧下降。自连接的性能则更依赖于 Join 算法和缓存命中率。在数十亿级别,两种方案都可能超时或占用过多数据库资源。
- 回答要点 2 (优化方案):
- 离线预计算:不应在
OLTP库上实时运行此查询。应使用数据仓库(如 Hive, ClickHouse)或大数据平台(如 Spark, Flink)进行离线计算。每天运行一个批处理作业,将结果存入一个结果表中,供业务方查询。 - 流式计算:如果需要近实时结果,可以使用 Flink 等流计算引擎。通过
keyedStream按user_id分区,并使用状态(State)来存储用户上一次的登录信息,当新事件到来时,直接与状态中的数据比较,完成计算并输出结果。
- 离线预计算:不应在
-
追问:如果要找的是“连续 3 天登录,且活跃度逐日递增”的用户呢?
- 回答要点 1 (窗口函数扩展):
LAG()函数非常适合扩展。我们可以同时获取前一天(offset=1)和前两天(offset=2)的数据。sql1-- 伪代码示意2WITH LoginWithPrev2 AS (3 SELECT ...,4 LAG(login_date, 1) OVER (PARTITION BY user_id ORDER BY login_date) AS prev1_date,5 LAG(duration, 1) OVER (...) AS prev1_duration,6 LAG(login_date, 2) OVER (PARTITION BY user_id ORDER BY login_date) AS prev2_date,7 LAG(duration, 2) OVER (...) AS prev2_duration8 FROM ...9)10SELECT ... WHERE DATEDIFF(login_date, prev1_date) = 111 AND DATEDIFF(prev1_date, prev2_date) = 112 AND duration > prev1_duration13 AND prev1_duration > prev2_duration; - 回答要点 2 (自连接扩展):需要进行两次自连接
t1 JOIN t2 JOIN t3,连接条件会变得更复杂,可读性和维护性下降,性能也可能更差。这凸显了窗口函数在处理复杂序列问题上的优越性。
- 回答要点 1 (窗口函数扩展):
-
追问:如果换成 ClickHouse 这种列式存储数据库,答案有什么不同?
- 回答要点 1 (利用列存特性):ClickHouse 极其擅长处理此类分析查询。虽然它也支持标准 SQL 的窗口函数和
JOIN,但更地道的解法是利用其特有的数组函数。 - 回答要点 2 (数组函数解法):可以先用
GROUP BY user_id将每个用户的登录日期和活跃时长分别聚合为两个数组(groupArray(login_date)和groupArray(duration_minutes)),并确保数组是按日期排序的。然后使用arrayDifference或arraySlice等函数对数组进行操作。例如,计算日期数组的差分,找到差值为 1 的位置,再对比对应位置的时长数组,整个过程在内存中批量向量化执行,性能极高。这种思路充分利用了列存和向量化计算的优势。
- 回答要点 1 (利用列存特性):ClickHouse 极其擅长处理此类分析查询。虽然它也支持标准 SQL 的窗口函数和