连续登录问题与 ROW_NUMBER 差值法完整解析
一、题目背景
题目描述
计算每位用户连续登录的最长天数。这是一个经典的"连续区间问题",在实际业务中涉及:连续消费、连续中奖、连续缺勤、股价连续上涨等多种场景。
建表与测试数据
mysql
CREATE TABLE login_log (
user_id INT,
login_dt DATE
);
INSERT INTO login_log VALUES
(1,'2024-01-01'),(1,'2024-01-02'),(1,'2024-01-03'),
(1,'2024-01-05'),(1,'2024-01-06'),
(2,'2024-01-01'),(2,'2024-01-03'),(2,'2024-01-04'),
(2,'2024-01-05'),(2,'2024-01-06'),(2,'2024-01-07');
数据特点:
- 用户1在1月1-3日连续登录(3天),5-6日再连续登录(2天)
- 用户2在1月3-7日连续登录(5天),中间1月1日独立登录(1天)
二、核心思路:ROW_NUMBER 差值法
为什么这是"连续区间问题"
解决这类问题的关键不是"数数",而是用数学变换把连续的日期转化为相同的标记。
差值法的数学原理
观察这两组序列的特性:
- 连续的日期:
2024-01-01,2024-01-02,2024-01-03...(每天增加1) - 连续的序号:
1,2,3...(每个序号增加1)
关键规律 :如果日期是连续的,那么"日期 - 序号"会得到一个固定不变的常数。
用户1的差值法演示
| 日期 | ROW_NUMBER序号 | 日期 - 序号(差值) | 结论 |
|---|---|---|---|
| 2024-01-01 | 1 | 2023-12-31 | 第一段连续开始 |
| 2024-01-02 | 2 | 2023-12-31 | 同一段 |
| 2024-01-03 | 3 | 2023-12-31 | 同一段 |
| 2024-01-05 | 4 | 2024-01-01 | 差值变了,新的一段 |
| 2024-01-06 | 5 | 2024-01-01 | 新段的延续 |
发现:只要"日期 - 序号"得到的差值相同,这些行就属于同一个连续登录区间。这个差值就像是一个"分组标签",把属于同一段的记录都标记出来。
通俗解释:为什么连续时差值不变,断开时差值变化
想象你有两个步长固定的自动扶梯:
- 第一台(代表日期):每秒往上走1格
- 第二台(代表序号):每秒也往上走1格
如果两台扶梯是同步的,那么它们相对位置永不改变(差值固定)。
但一旦第一台扶梯断电了(日期跳跃),而第二台还在运行(序号继续递增),两者的相对位置就会立刻错位(差值突然变化)。
三、解题步骤拆解
第一步:给每个用户的登录日期生成序号
mysql
SELECT
user_id,
login_dt,
ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_dt) as rn
FROM login_log;
SQL解释:
ROW_NUMBER() OVER(...):这是一个窗口函数PARTITION BY user_id:为每个用户独立编号(用户A从1开始,用户B也从1开始)ORDER BY login_dt:按登录日期从早到晚排序,按这个顺序发号牌
执行结果:
| user_id | login_dt | rn |
|---|---|---|
| 1 | 2024-01-01 | 1 |
| 1 | 2024-01-02 | 2 |
| 1 | 2024-01-03 | 3 |
| 1 | 2024-01-05 | 4 |
| 1 | 2024-01-06 | 5 |
第二步:计算分组标记 grp(差值)
mysql
SELECT
user_id,
login_dt,
DATE_SUB(login_dt, INTERVAL ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_dt) DAY) AS grp
FROM login_log;
关键函数 DATE_SUB() 说明:
DATE_SUB(起始日期, INTERVAL 数字 单位)- 例如:
DATE_SUB('2024-01-05', INTERVAL 3 DAY)结果是2024-01-02 - 这里用它来计算"日期 - 序号"的结果
执行结果(部分):
| user_id | login_dt | grp |
|---|---|---|
| 1 | 2024-01-01 | 2023-12-31 |
| 1 | 2024-01-02 | 2023-12-31 |
| 1 | 2024-01-03 | 2023-12-31 |
| 1 | 2024-01-05 | 2024-01-01 |
| 1 | 2024-01-06 | 2024-01-01 |
重要观察 :用户1的前三条记录的 grp 都是 2023-12-31,这说明它们属于同一个连续段。第4、5条的 grp 是 2024-01-01,说明它们属于另一个连续段。
第三步:按分组标记统计每段连续天数
mysql
SELECT
user_id,
grp,
COUNT(*) AS consecutive_days
FROM (
SELECT
user_id,
DATE_SUB(login_dt, INTERVAL ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_dt) DAY) AS grp
FROM login_log
) t1
GROUP BY user_id, grp;
SQL解释:
- 将前面的子查询包裹起来,作为一个临时表 t1
GROUP BY user_id, grp:按用户和他们的连续段进行分组COUNT(*) AS consecutive_days:统计每个分组里有多少天
执行结果:
| user_id | grp | consecutive_days |
|---|---|---|
| 1 | 2023-12-31 | 3 |
| 1 | 2024-01-01 | 2 |
| 2 | 2024-01-01 | 5 |
| 2 | 2024-01-02 | 1 |
现在我们能看出:用户1有两个连续段(3天和2天),用户2有两个连续段(5天和1天)。
第四步:求每位用户的最长连续天数
mysql
SELECT
user_id,
MAX(consecutive_days) AS max_consecutive_days
FROM (
SELECT
user_id,
grp,
COUNT(*) AS consecutive_days
FROM (
SELECT
user_id,
DATE_SUB(login_dt, INTERVAL ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_dt) DAY) AS grp
FROM login_log
) t2
GROUP BY user_id, grp
) t3
GROUP BY user_id;
SQL解释:
- 在第三步的基础上,再套一层外查询
GROUP BY user_id:现在只按用户进行分组MAX(consecutive_days):从该用户所有的连续段中,挑出最长的那一段
最终结果:
| user_id | max_consecutive_days |
|---|---|
| 1 | 3 |
| 2 | 5 |
四、为什么会出现 only_full_group_by 报错
错误案例
mysql
SELECT *,
COUNT(*) AS consecutive_days
FROM (
SELECT *,
DATE_SUB(login_dt, INTERVAL ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_dt) DAY) AS grp
FROM login_log
) t1
GROUP BY user_id, grp;
报错信息:
Expression #2 of SELECT list is not in GROUP BY clause and contains
nonaggregated column 't1.login_dt' which is not functionally dependent
on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
通俗解释:为什么会报错
想象一个真实场景:你是班主任,有10个篮球爱好者。你说"按运动兴趣分组统计",然后要求"把每个小组里所有人的姓名、出生日期都列出来"。
数据库的困境是:对于"篮球组"这个分组,里面有10个人,每个人的生日都不一样。你让我用一行来表示这个组,那我应该显示第一个人的生日,还是第十个人的生日?我不知道,所以我拒绝执行。
具体错误原因分析
在你的子查询中包含了 user_id, login_dt, grp 等列。但在外层:
- 执行了
GROUP BY user_id, grp - 尝试用
SELECT *把所有列都显示出来 - 问题:对于同一个
user_id和grp组合,可能对应多个不同的login_dt
SELECT * 中的 login_dt 就成了"无法确定"的列。
聚合查询中 SELECT 的规则
在使用了 GROUP BY 的查询中,SELECT 后面能出现的列只有两种:
类型一:分组列 - 出现在 GROUP BY 后的列
mysql
SELECT user_id, -- 这是分组列,可以出现
grp -- 这也是分组列,可以出现
类型二:聚合列 - 被聚合函数包裹的列
mysql
SELECT COUNT(*), -- 聚合函数,可以出现
MAX(login_dt), -- 被 MAX 包裹的 login_dt,可以出现
MIN(login_dt) -- 被 MIN 包裹的 login_dt,可以出现
类型三:其他列 - 既不是分组列也不是聚合列的列
mysql
SELECT login_dt -- 不在 GROUP BY 中,也没被聚合 - 报错
正确的改法
如果想看每个连续段的开始和结束时间:
mysql
SELECT
user_id,
grp,
MIN(login_dt) AS start_date,
MAX(login_dt) AS end_date,
COUNT(*) AS consecutive_days
FROM (
SELECT
user_id,
login_dt,
DATE_SUB(login_dt, INTERVAL ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_dt) DAY) AS grp
FROM login_log
) t1
GROUP BY user_id, grp;
改进说明:
- 去掉了不必要的
SELECT * - 明确地只选择了三种合法的列
- 使用
MIN(login_dt)和MAX(login_dt)来获取该段的起止日期,这样就有了"通行证"
五、更推荐的写法:CTE + DISTINCT
完整的最终版本
mysql
WITH unique_log AS (
-- 第一步:去重,确保每个用户每天只有一条记录
SELECT DISTINCT user_id, login_dt
FROM login_log
),
base_groups AS (
-- 第二步:计算差值 grp
SELECT
user_id,
login_dt,
DATE_SUB(login_dt, INTERVAL ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_dt) DAY) AS grp
FROM unique_log
),
streak_counts AS (
-- 第三步:统计每个连续段的长度
SELECT user_id, grp, COUNT(*) AS days
FROM base_groups
GROUP BY user_id, grp
)
-- 第四步:汇总每个用户的最大连续天数
SELECT user_id, MAX(days) AS max_streak
FROM streak_counts
GROUP BY user_id;
为什么要先 DISTINCT
在真实业务中,一个用户在同一天可能会登录多次(产生多条日志记录)。
如果不去重会发生什么:
- 用户1在2024-01-01登录3次,出现3条日志
ROW_NUMBER()会给这三行分别标上1、2、3- 这会导致差值法失效,把"一天的多次登录"误算成"连续登录了3天"
去重的作用:
- 通过
DISTINCT user_id, login_dt,确保了统计的粒度是"按天"而不是"按次" - 保证了数据的准确性和业务逻辑的正确性
为什么 CTE 比嵌套子查询更好
传统嵌套子查询的问题:
- 代码像"洋葱",要从最核心往外一层层剥,难以阅读
- 括号多到数不清,容易出错
- 修改内层逻辑可能影响到多个依赖的外层
CTE (WITH 语句) 的优势:
- 代码像"流水线",第一步做什么、第二步做什么一目了然
- 逻辑呈线性排列,从上往下读,符合人类的思维习惯
- 每一步都有明确的命名,语义清晰
对比表:
| 维度 | 嵌套子查询 | CTE + WITH 语句 |
|---|---|---|
| 可读性 | 差(括号嵌套多) | 极佳(逻辑清晰) |
| 可维护性 | 难(修改影响范围广) | 易(每步独立命名) |
| 调试效率 | 低(难以观察中间步骤) | 高(可临时替换最后的SELECT) |
| 代码复用 | 需要重复写 | 可多次引用同一CTE |
| 性能 | 相似 | 相似(现代优化器处理效果接近) |
真实业务的优势
这种写法展示了三个专业特征:
- 健壮性:通过DISTINCT自动处理数据重复问题
- 可读性:CTE结构让任何人都能快速理解逻辑
- 可维护性:每步独立,日后若需修改某个逻辑易如反掌
六、相关 SQL 知识点整理
1. ROW_NUMBER() 窗口函数
定义:给每一行数据按指定规则标上唯一的序号。
语法:
mysql
ROW_NUMBER() OVER (PARTITION BY 分组列 ORDER BY 排序列)
参数说明:
PARTITION BY:为每个分组内独立编号(可选,省略则全局编号)ORDER BY:指定编号的排序依据
记忆:它像银行的取号机,按序列发号。
2. DATE_SUB() 日期函数
定义:从一个日期中减去指定的时间间隔。
语法:
mysql
DATE_SUB(起始日期, INTERVAL 数字 单位)
常见单位:
DAY:天MONTH:月YEAR:年
例子 :DATE_SUB('2024-01-05', INTERVAL 3 DAY) 得到 2024-01-02
3. GROUP BY 分组子句
作用:把相同属性的行合并为一行,通常配合聚合函数使用。
执行顺序:GROUP BY 发生在 SELECT 之前。
关键限制:
- SELECT 的列要么在 GROUP BY 中,要么被聚合函数包裹
- 不能直接显示未分组的明细列
4. 常见聚合函数
| 函数 | 作用 | 例子 |
|---|---|---|
| COUNT(*) | 统计行数 | COUNT(*) 计算分组内有多少行 |
| SUM() | 求和 | SUM(salary) 求总薪资 |
| AVG() | 平均值 | AVG(salary) 求平均薪资 |
| MAX() | 最大值 | MAX(login_dt) 求最晚登录日期 |
| MIN() | 最小值 | MIN(login_dt) 求最早登录日期 |
5. CTE (WITH 语句)
定义:公用表表达式,用来定义临时的、可重用的数据集。
语法:
mysql
WITH CTE名称 AS (
SELECT ... FROM ...
),
另一个CTE AS (
SELECT ... FROM ...
)
SELECT ... FROM CTE名称 ...
优点:
- 代码段化,逻辑清晰
- 可以定义多个CTE,后面的CTE可以引用前面的
- 便于调试,可快速修改最后的SELECT来观察中间结果
6. DISTINCT 去重
作用:从结果集中删除重复行,只保留唯一记录。
使用时机:
- 数据清洗阶段,确保数据质量
- 日期时间统计时,消除同日多次记录的重复
注意:对性能有一定影响,仅在必要时使用。
七、经验总结与通用模板
核心经验
**经验一:不要在聚合查询中乱用 SELECT ***
当你的SQL中出现 GROUP BY 时,立刻停止使用 SELECT *。有意识地列出你真正需要的列:
- 分组列必须列出
- 明细列不能出现(除非套上聚合函数)
经验二:连续问题按"天"统计时要先去重
实际业务中往往有脏数据。不管是因为重复登陆、日志重复上传还是其他原因,都要在处理之前进行 DISTINCT,确保数据的准确性。
经验三:SQL要考虑三个维度
不要只满足于"能跑",要看以下三个方面:
- 正确性:逻辑是否准确,有没有考虑边界情况
- 可读性:别人(或日后的你)能否快速理解代码意图
- 可维护性:需求变化时,是否容易修改
经验四:这个套路可以迁移
连续区间问题的差值法套路极具通用性,可以应用到:
- 连续消费天数
- 连续中奖次数
- 连续缺勤天数
- 股价连续上涨天数
- 任何需要统计"连续性"的场景
通用解题模板
对任何"连续区间"问题,都可以使用以下标准模板:
mysql
WITH cleaned_data AS (
-- 第一步:清洗数据(去重、排序等)
SELECT DISTINCT user_id, event_date
FROM your_table
),
grouped_data AS (
-- 第二步:计算分组标记
SELECT
user_id,
event_date,
DATE_SUB(event_date, INTERVAL ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_date) DAY) AS grp
FROM cleaned_data
),
streak_stats AS (
-- 第三步:统计每一段的长度
SELECT user_id, grp, COUNT(*) AS streak_length
FROM grouped_data
GROUP BY user_id, grp
)
-- 第四步:提取所需指标
SELECT user_id, MAX(streak_length) AS max_streak
FROM streak_stats
GROUP BY user_id;
模板说明
-
cleaned_data - 数据清洗层
- 使用 DISTINCT 去重
- 可添加 WHERE 过滤不需要的数据
- 用户自定义的过滤逻辑都在这一层完成
-
grouped_data - 数据标记层
- 计算分组标记(差值法的核心)
- 这一层产生的 grp 决定了哪些行属于同一个"连续段"
-
streak_stats - 统计聚合层
- 按分组标记进行 GROUP BY
- 计算每一段的长度(COUNT)
-
最终SELECT - 结果提取层
- 根据具体需求提取指标
- 可以是 MAX(最长)、MIN(最短)、AVG(平均)等
常见变种调整
需求变化时的调整方案:
-
如果需要看到每一段的起止日期:
mysqlSELECT user_id, grp, MIN(event_date) AS start_date, MAX(event_date) AS end_date, COUNT(*) AS streak_length FROM grouped_data GROUP BY user_id, grp; -
如果需要排除长度过短的连续段(如只显示>=3天的):
mysqlSELECT user_id, MAX(streak_length) AS max_streak FROM streak_stats WHERE streak_length >= 3 GROUP BY user_id; -
如果需要统计用户的总连续段数量:
mysqlSELECT user_id, COUNT(DISTINCT grp) AS total_streaks FROM grouped_data GROUP BY user_id;
快速检查清单
完成SQL后,可用以下清单检查:
- 是否使用了 DISTINCT 进行数据清洗?
- 是否用 CTE 组织了逻辑,而不是嵌套子查询?
- GROUP BY 的列都在 SELECT 中出现或被聚合了吗?
- 是否有考虑同一天重复记录的情况?
- 代码是否易于理解和维护?
附录:学习进阶建议
深入学习方向
-
掌握更多窗口函数
- RANK()、DENSE_RANK():处理排名问题
- LAG()、LEAD():处理同一行前后行的关系
- FIRST_VALUE()、LAST_VALUE():提取窗口的边界值
-
递归CTE
- 处理树形、层级结构的数据
- 计算路径查询、组织架构等复杂场景
-
性能优化
- 在大数据集上体验不同写法的性能差异
- 学习理解执行计划,了解数据库如何优化你的SQL
-
实战应用
- 在真实数据集中应用这些技巧
- 体会不同方案的优劣,形成自己的最佳实践
关键搜索词
- SQL CTE 公用表表达式
- SQL 窗口函数
- 连续区间问题
- PARTITION BY 工作原理