在用户行为分析里,连续登录天数是衡量活跃度与留存的核心指标。所谓连续登录,是指用户在自然日维度上登录日期紧挨着、中间没有断开的日子序列。比如用户在一月一号、二号、三号都登录了,这算连续三天;如果四号没登、五号登了,那五号就只能算新的一段连续起点。我们要做的,就是针对每张用户登录记录表,算出每个人最长连续登录了多少天,或者有哪些连续区间。

一、自关联比对解法
最容易被想到的办法,是把登录表和自己按用户与日期差一天的条件做连接。这样每条记录都能找到它的前一天是否也登录了,从而判断是否属于连续段。这种方法逻辑非常直白,刚接触SQL的人也能看懂。
下面用一张简单的表 user_login(uid, login_date) 来演示。我们先把同一用户登录日期去重,再左联前一天的数据:
select a.uid, a.login_date, case when b.login_date is not null then 1 else 0 end as is_continue from ( select distinct uid, login_date from user_login ) a left join ( select distinct uid, login_date from user_login ) b on a.uid = b.uid and a.login_date = date_add(b.login_date, interval 1 day);
上面这段只能标出每一天是不是接在上一天后面。要算出连续长度,还得再做变量或子查询累计。自关联的缺点是,如果数据有上亿条,distinct加join会带来很高的临时表和排序开销,执行计划里常常能看到 Using temporary; Using filesort。
不过在小数据量或者临时查数场景下,它不用记复杂函数,改起来也方便。比如只想看某个人有没有连续登录满七天,直接加 where 过滤即可,不需要引入新语法。
二、日期减行号分组解法
这是一种巧妙的数学思路。先对每个用户按登录日期排序打出行号,再用登录日期减去行号对应的天数,连续日期算出来的值会相等。因为每过一天日期加一、行号也加一,相减就抵消了。断掉之后,日期往前跳但行号连续,差值就变了,自然分成不同组。
我们用 date_sub 和 row_number 实现,注意里面特殊字符都做了转义:
select
uid,
min(login_date) as start_date,
max(login_date) as end_date,
count(*) as continue_days
from (
select
uid,
login_date,
date_sub(login_date, interval row_number() over (
partition by uid order by login_date
) day) as grp
from (
select distinct uid, login_date from user_login
) t1
) t2
group by uid, grp;
</p>
<p>这个写法一次性给出了每段连续的起止日和天数。想拿最长连续,只需在外面再包一层求 max(continue_days)。它只需要两次扫描和一次窗口计算,比自关联少了很多比较。</p>
<p>要注意的是,login_date 必须是 date 类型而不是 datetime,否则相减会带时间片段导致分组错位。如果原表是时间戳,先用 date(ts) 处理。</p>
<h2>三、窗口函数直接求最长连续</h2>
<p>在支持窗口函数的数据库如 MySQL 8、PostgreSQL、Hive 中,上面第二种方案就是业界主流。我们可以再精简,直接求每个人最长连续天数:</p>
<pre class=brush:sql;toolbar:false>
select uid, max(continue_days) as max_continue
from (
select
uid,
count(*) as continue_days
from (
select
uid,
login_date,
date_sub(login_date, interval row_number() over (
partition by uid order by login_date
) day) as grp
from (select distinct uid, login_date from user_login) x
) y
group by uid, grp
) z
group by uid;
这种结构清晰,而且数据库优化器对 window function 有专门的向量化执行,在千万级数据上通常比自关联快数倍。它也是面试里公认的标准答案。
如果还要输出连续区间明细,把中间层留着即可,外层不聚合 uid 而只按 uid、grp 聚就能拿到所有段。可见同一套核心逻辑既能算最长也能算全量,复用度高。
四、临时表与标记法
有些老版本 MySQL 不支持窗口函数,可以用临时表加用户变量模拟行号与分组。思路是先建去重排序表,再读的时候用 @prev 和 @grp 变量判断断层:
create temporary table tmp_login as
select uid, login_date from user_login group by uid, login_date order by uid, login_date;
set @uid := '', @prev_date := null, @grp := 0;
select uid, grp, min(login_date) start_date, max(login_date) end_date, count(*) days
from (
select
uid, login_date,
@grp := if(@uid = uid and datediff(login_date, @prev_date) = 1, @grp, if(@uid := uid, @grp + 1, @grp + 1)) as grp,
@prev_date := login_date
from tmp_login
) t
group by uid, grp;
变量法在旧系统能跑,但可读性差,而且 MySQL 8 之后官方不推荐在查询里用用户变量做行级计算,容易和优化器冲突。所以新项目直接用窗口函数就好。
五、方案对比与选型
我们把四种解法从多个维度列出来:
| 方案 | 可读性 | 大数据性能 | 数据库要求 |
|---|---|---|---|
| 自关联 | 高 | 差 | 基础SQL |
| 日期减行号 | 中 | 好 | 需窗口函数 |
| 窗口函数最长 | 中高 | 好 | 需窗口函数 |
| 临时表变量 | 低 | 一般 | 旧版兼容 |
实际工作中,只要环境支持 row_number,就优先用日期减行号的分组思路。它既能应对日活报表里的连续登录,也能改造成近三十天最大连续这样的滑动需求。自关联只建议用在教学或极小的表上,避免给线上库添堵。
连续登录问题本质是把时间轴变成分组标签,抓住这一点,不管换什么 SQL 方言,都能很快写出对应实现。