SQL 进阶3:连续登录问题与 ROW_NUMBER 差值法完整解析

连续登录问题与 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_idgrp 组合,可能对应多个不同的 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
性能 相似 相似(现代优化器处理效果接近)

真实业务的优势

这种写法展示了三个专业特征:

  1. 健壮性:通过DISTINCT自动处理数据重复问题
  2. 可读性:CTE结构让任何人都能快速理解逻辑
  3. 可维护性:每步独立,日后若需修改某个逻辑易如反掌

六、相关 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;

模板说明

  1. cleaned_data - 数据清洗层

    • 使用 DISTINCT 去重
    • 可添加 WHERE 过滤不需要的数据
    • 用户自定义的过滤逻辑都在这一层完成
  2. grouped_data - 数据标记层

    • 计算分组标记(差值法的核心)
    • 这一层产生的 grp 决定了哪些行属于同一个"连续段"
  3. streak_stats - 统计聚合层

    • 按分组标记进行 GROUP BY
    • 计算每一段的长度(COUNT)
  4. 最终SELECT - 结果提取层

    • 根据具体需求提取指标
    • 可以是 MAX(最长)、MIN(最短)、AVG(平均)等

常见变种调整

需求变化时的调整方案

  1. 如果需要看到每一段的起止日期:

    mysql 复制代码
    SELECT 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;
  2. 如果需要排除长度过短的连续段(如只显示>=3天的):

    mysql 复制代码
    SELECT user_id, MAX(streak_length) AS max_streak
    FROM streak_stats
    WHERE streak_length >= 3
    GROUP BY user_id;
  3. 如果需要统计用户的总连续段数量:

    mysql 复制代码
    SELECT user_id, COUNT(DISTINCT grp) AS total_streaks
    FROM grouped_data
    GROUP BY user_id;

快速检查清单

完成SQL后,可用以下清单检查:

  • 是否使用了 DISTINCT 进行数据清洗?
  • 是否用 CTE 组织了逻辑,而不是嵌套子查询?
  • GROUP BY 的列都在 SELECT 中出现或被聚合了吗?
  • 是否有考虑同一天重复记录的情况?
  • 代码是否易于理解和维护?

附录:学习进阶建议

深入学习方向

  1. 掌握更多窗口函数

    • RANK()、DENSE_RANK():处理排名问题
    • LAG()、LEAD():处理同一行前后行的关系
    • FIRST_VALUE()、LAST_VALUE():提取窗口的边界值
  2. 递归CTE

    • 处理树形、层级结构的数据
    • 计算路径查询、组织架构等复杂场景
  3. 性能优化

    • 在大数据集上体验不同写法的性能差异
    • 学习理解执行计划,了解数据库如何优化你的SQL
  4. 实战应用

    • 在真实数据集中应用这些技巧
    • 体会不同方案的优劣,形成自己的最佳实践

关键搜索词

  • SQL CTE 公用表表达式
  • SQL 窗口函数
  • 连续区间问题
  • PARTITION BY 工作原理
相关推荐
KhalilRuan2 小时前
Burst编译器的底层原理
java·开发语言
我是永恒2 小时前
PostgreSQL数据库安装配置连接Paperclip
数据库·postgresql
Zww08912 小时前
idea配置注释模板
java·ide·intellij-idea
Renhao-Wan2 小时前
Docker 核心原理详解:镜像、容器、Namespace、Cgroups 与 UnionFS
java·后端·docker·容器
一个天蝎座 白勺 程序猿2 小时前
踩坑生产后整理:KingbaseES表空间管理、auto_createtblspcdir参数深度解析与运维最佳实践
运维·数据库·kingbasees
Rsun045512 小时前
ScheduledExecutorService类作用
java
oG99bh7CK2 小时前
FastAPI + PostgreSQL 实战:从入门到不踩坑,一次讲透
数据库·postgresql·fastapi
Wait....2 小时前
MySQL事务知识复习
数据库·mysql
小钊(求职中)2 小时前
算法知识、常用方法总结
java·算法·排序算法·力扣