求每个用户的最长连续登录天数。
Find the longest consecutive login streak for each user.
考察要点
这道题面试官想看你是否掌握在 SQL 中处理时间序列和连续性问题的能力,具体考察:
- 窗口函数(Window Functions):特别是
ROW_NUMBER()的使用,这是解决此类“分组内排序”问题的标准利器。 - “Gaps and Islands”问题:能否识别出这是经典的“间隙与孤岛”问题,并运用创造性的分组技巧来解决。
- SQL 抽象与逻辑拆解能力:能否将“连续登录”这个业务概念,转化为
(日期 - 连续天数)这个固定的差值,从而进行分组(GROUP BY)。
核心概念
本题的核心是识别出“连续”的日期序列。一个巧妙的方法是,如果我们将一个连续日期序列中的每个日期,都减去它在这个序列中的排名(天数),会得到一个恒定的值。这个恒定的值就可以作为这一段“连续”日期的唯一标识。
1. 核心思想:日期与行号的差值分组
假设一个用户 U1 的登录日期如下(已去重并排序):
| user_id | login_date |
|---|---|
| U1 | 2023-01-01 |
| U1 | 2023-01-02 |
| U1 | 2023-01-03 |
| U1 | 2023-01-06 |
| U1 | 2023-01-07 |
| U1 | 2023-01-09 |
我们使用窗口函数 ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) 来为每个用户的登录记录生成一个行号 rn。
| user_id | login_date | rn |
|---|---|---|
| U1 | 2023-01-01 | 1 |
| U1 | 2023-01-02 | 2 |
| U1 | 2023-01-03 | 3 |
| U1 | 2023-01-06 | 4 |
| U1 | 2023-01-07 | 5 |
| U1 | 2023-01-09 | 6 |
接下来,计算 login_date 减去 rn 天后的日期,我们称之为 group_flag:
| user_id | login_date | rn | login_date - INTERVAL rn DAY | group_flag |
|---|---|---|---|---|
| U1 | 2023-00-01 | 1 | 2023-01-01 - 1 day | 2022-12-31 |
| U1 | 2023-01-02 | 2 | 2023-01-02 - 2 days | 2022-12-31 |
| U1 | 2023-01-03 | 3 | 2023-01-03 - 3 days | 2022-12-31 |
| U1 | 2023-01-06 | 4 | 2023-01-06 - 4 days | 2023-01-02 |
| U1 | 2023-01-07 | 5 | 2023-01-07 - 5 days | 2023-01-02 |
| U1 | 2023-01-09 | 6 | 2023-01-09 - 6 days | 2023-01-03 |
观察:
- 前 3 条记录的
group_flag都是2022-12-31,它们构成了一个连续登录区间。 - 第 4、5 条记录的
group_flag都是2023-01-02,它们构成了另一个连续登录区间。 - 第 6 条记录自己构成一个区间。
这样,我们成功地将业务上的“连续”,转化为了数据上的“同组”。接下来只需要按 user_id 和 group_flag 分组计数,就能得到每个连续区间的长度,最后再取最大值即可。
2. 窗口函数 ROW_NUMBER()
- 语法:
ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) - 作用: 为分组(
PARTITION BY)内的记录,按照指定的排序(ORDER BY)生成一个从 1 开始的唯一、连续的整数排名。 - 与
RANK()/DENSE_RANK()的区别:ROW_NUMBER(): 始终是 1, 2, 3, 4...RANK(): 遇到相同排序值的行,排名相同,但会跳过后续排名(如 1, 2, 2, 4)。DENSE_RANK(): 遇到相同排序值的行,排名相同,且不跳过后续排名(如 1, 2, 2, 3)。 在本题中,由于login_date经过DISTINCT后是唯一的,三者效果等价。但ROW_NUMBER()语义最清晰。
多种解法
假设我们有表 login_records:
1CREATE TABLE login_records (2 user_id INT,3 login_date DATE4);5-- 插入一些示例数据6INSERT INTO login_records VALUES7(1, '2023-01-01'), (1, '2023-01-02'), (1, '2023-01-03'), (1, '2023-01-05'),8(1, '2023-01-06'), (2, '2023-02-10'), (2, '2023-02-11'), (2, '2023-02-13');
预期结果:
- 用户 1 的最长连续登录是 3 天(1/1-1/3)和 2 天(1/5-1/6),所以结果是 3。
- 用户 2 的最长连续登录是 2 天(2/10-2/11),所以结果是 2。
解法 1:窗口函数法(推荐,标准解法)
- 版本: ✅ MySQL 8.0+ / PostgreSQL / Oracle / SQL Server;❌ MySQL 5.7 不支持。
- 思路: 使用
ROW_NUMBER()制造分组标识,然后进行两次聚合。
1WITH user_distinct_dates AS (2 -- 步骤1: 对每个用户,获取去重且排序的登录日期3 SELECT DISTINCT user_id, login_date4 FROM login_records5),6user_date_with_rank AS (7 -- 步骤2: 使用窗口函数,为每个用户的登录日期打上排名8 SELECT9 user_id,10 login_date,11 ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_date) AS rn12 FROM user_distinct_dates13),14user_streaks AS (15 -- 步骤3: 计算日期和排名的差值,以此作为连续区间的标识,并计算每个区间的长度16 SELECT17 user_id,18 COUNT(*) AS streak_length19 FROM user_date_with_rank20 GROUP BY user_id, DATE_SUB(login_date, INTERVAL rn DAY) -- 核心:差值分组21)22-- 步骤4: 从每个用户的所有连续区间中,找出最长的那个23SELECT24 user_id,25 MAX(streak_length) AS max_consecutive_days26FROM user_streaks27GROUP BY user_id;
- 时间复杂度: ,主要开销在于
ROW_NUMBER()内部的排序(PARTITION BY ... ORDER BY)。 是去重后的记录总数。 - 空间复杂度: ,需要存储
CTE的中间结果。 - 索引依赖: 在
login_records(user_id, login_date)上建立复合索引,可以极大地加速DISTINCT和ROW_NUMBER()的排序过程,甚至可能实现覆盖索引,避免回表。
解法 2:用户变量法(兼容老版本 MySQL)
- 版本: ✅ MySQL 5.7 及以下版本;❌ MySQL 8.0+ 不推荐(官方不保证变量在复杂查询中的求值顺序)。
- 思路: 模拟过程式编程,在 SQL 查询中通过变量来跟踪上一个用户的 ID、上一个登录日期以及当前的连续天数。
1SELECT2 user_id,3 MAX(streak) AS max_consecutive_days4FROM (5 SELECT6 user_id,7 login_date,8 -- 核心逻辑:9 -- 如果用户ID和上一个相同,且日期是连续的,则连续天数+110 -- 否则,重置连续天数为111 @streak := IF(@prev_user_id = user_id AND DATEDIFF(login_date, @prev_date) = 1,12 @streak + 1,13 1) AS streak,14 -- 更新用于下一次比较的变量15 @prev_user_id := user_id,16 @prev_date := login_date17 FROM (18 -- 必须先对数据进行严格排序,保证变量逻辑的正确性19 SELECT DISTINCT user_id, login_date20 FROM login_records21 ORDER BY user_id, login_date22 ) AS sorted_logins,23 -- 初始化用户变量24 (SELECT @prev_user_id := NULL, @prev_date := NULL, @streak := 0) AS vars25) AS streaks_with_vars26GROUP BY user_id;
- 时间复杂度: ,主要开销在于子查询中的
ORDER BY。 - 空间复杂度: (不考虑排序的临时空间),因为变量是逐行计算的。
- 说明: 这种写法非常依赖于 MySQL 的执行顺序,可读性和可维护性差,是特定历史时期的“奇技淫巧”。在面试中可以提及,以展示知识广度,但一定要强调在现代数据库中应首选窗口函数。
常见低分回答
- 误用
LAG函数:尝试用LAG(login_date)获取上一行日期,然后判断DATEDIFF是否为 1。这种方法只能识别出连续的“点对”,但无法直接计算出整个连续区间的长度,后续处理会非常复杂,容易出错。 - 错误地聚合:
SELECT user_id, DATEDIFF(MAX(login_date), MIN(login_date)) + 1 FROM ...这种写法计算的是用户总的登录时间跨度,完全忽略了中间的“断档”,是典型的对题意理解不清。 - 忘记去重
DISTINCT:如果原始表一天内有多条登录记录,不先用DISTINCT或GROUP BY user_id, login_date进行预处理,会导致ROW_NUMBER()对同一天的多条记录生成不同行号,整个逻辑就错了。 - 窗口函数
PARTITION BY或ORDER BY错误:PARTITION BY忘记写user_id,导致在全局进行ROW_NUMBER;或者ORDER BY写了其他无关列,都会导致rn计算错误。 - 用户变量法中忘记
ORDER BY:用户变量法的正确性强依赖于数据的处理顺序。如果在子查询中不显式地ORDER BY user_id, login_date,结果将是不可预测且错误的。
工程实践与优化
- 数据量级与性能:当
login_records表达到亿级甚至百亿级时,直接在线查询(OLTP)执行此 SQL 会非常慢,可能导致数据库主库CPU飙升,甚至慢查询超时。 的排序成本是无法接受的。 - 生产方案:
- 离线计算:这是处理大规模数据的标准做法。将数据同步到数据仓库(如 Hive, Spark, ClickHouse)中,通过每日/每小时的批处理任务(如 Spark SQL)来计算。计算逻辑与解法 1 完全相同,但利用了分布式计算的水平扩展能力。
- 预聚合/物化视图:如果这个指标需要频繁查询,可以将计算结果(
user_id,max_consecutive_days)存入一张结果表或物化视图中。前端应用直接查询结果表,实现毫秒级响应。
- 执行计划分析 (
EXPLAIN):- 对于解法 1,关注
EXPLAIN结果。理想情况下,user_distinct_dates这一步应该能利用(user_id, login_date)索引,Extra字段显示Using index for group-by或Using index。 ROW_NUMBER()的计算通常会引入Window aggregate操作。如果排序键和分区键与索引匹配,性能会好很多。若不匹配,可能会出现filesort,这是性能瓶颈。
- 对于解法 1,关注
- 工具使用:
EXPLAIN ANALYZE(MySQL 8.0.18+):可以实际执行查询并显示每一步的真实耗时和行数,比EXPLAIN更精确。pt-query-digest:对于分析慢查询日志,定位这类复杂查询的性能问题非常有帮助。
常见追问与延伸题目
-
追问:如果数据量增长 100 倍,达到百亿级别,你的 SQL 还能用吗?怎么优化?
- 回答要点:
- 不能直接用:直接在生产主库上运行此 SQL 会导致灾难。百亿级别的
ORDER BY会消耗巨大的 CPU 和 IO 资源,并可能产生巨大的临时文件,严重影响数据库性能。 - 架构级优化:必须将计算任务从
OLTP数据库转移到OLAP系统。 - 具体方案:将登录日志实时或准实时地同步到数据湖(如 HDFS)或数据仓库(如 Hive/ClickHouse)。使用 Spark 或 Flink 等分布式计算引擎,编写批处理或流处理任务来执行相同的窗口函数逻辑。计算结果存储在专门的结果表中,供上层应用查询。这实现了计算和存储的分离,保证了主库的稳定。
- 不能直接用:直接在生产主库上运行此 SQL 会导致灾难。百亿级别的
- 回答要点:
-
追问:除了最长连续登录天数,如果要看“每个用户当前的连续登录天数”,该如何实现?
- 回答要点:
- 思路转变:这个问题关注的是“当下”的状态,而不是历史的最大值。
- 实现:可以修改解法 1 的逻辑。首先,判断用户今天是否登录。如果今天没登录,当前连续天数为 0。如果今天登录了,就计算从今天开始往前追溯的连续天数。
- SQL 方案:仍然可以使用“差值分组”法。计算出每个用户的每个连续区间的起止日期和长度。然后,对于每个用户,检查
CURDATE()是否落在他最新的那个连续区间内。如果是,该区间的长度就是当前连续天数。这比求最大值要多一些判断逻辑。
- 回答要点:
-
追问:如果定义“连续”为“登录间隔不超过1天”(即允许断一天)?如何求最长“活跃”天数?
- 回答要点:
- 问题变种:这是对“连续”定义的扩展,原先的
DATE_SUB(login_date, INTERVAL rn DAY)技巧失效了,因为日期间隔不再是严格的 1。 - 新思路:这需要一个新的分组标识。可以这样思考:当一个登录记录与上一条记录的间隔大于 2 天时,一个新的“活跃区间”开始了。
- SQL 实现:
- 使用
LAG(login_date) OVER (PARTITION BY user_id ORDER BY login_date)获取上一条登录日期。 - 计算
DATEDIFF(login_date, lag_date)。 - 当
DATEDIFF> 2 或者lag_date为 NULL 时,标记为新活跃区间的开始(例如,设置一个标志位为 1,否则为 0)。 - 使用
SUM(flag) OVER (PARTITION BY user_id ORDER BY login_date)这个累加和,来作为新的分组 ID。同一个活跃区间内的累加和是相同的。 - 后续步骤就和解法 1 类似了:按这个新的分组 ID 进行
GROUP BY,计算每个区间的长度(MAX(date) - MIN(date) + 1),最后取最大值。这展示了对窗口函数更深层次的组合运用能力。
- 使用
- 问题变种:这是对“连续”定义的扩展,原先的
- 回答要点: