#48 SQL 中等

如何用 SQL 计算用户连续登录天数?最长连续天数怎么求?

连续登录窗口函数日期差分组Gaps and Islands

🎯 考察意图

考察候选人会不会把「连续日期」从明细里挖出来:先去重到人×日,再用窗口函数造一个不随日期走动的组键,最后按组计数。听的是分组思路,不是背一段 SQL。

📐 回答框架

  1. 先定口径:登录=当日有活跃事件;同一用户同一天多次登录先去重到 user_id + dt。
  2. 一句话点题:连续段 = 把相邻日期收进同一组,组内行数就是连续天数。
  3. 造组键:按用户排日期,用 dt 减去行号(或 RANK),连续日得到同一个 island_id。
  4. 聚合:按 user_id + island_id 计数,得到每段长度;再 MAX 得最长连续天数。
  5. 主动补边界:时区、漏记一天是否算断、要「当前仍在连」还是「历史最长」。

💬 参考答案(口述版)

一句话:先把登录压成「人×日」去重表,再给每个用户的日期编号;连续的日期减去行号后差值不变,这个差值就是连续段的组键,组内行数就是连续天数。

为什么能这么分组。 假设某用户活跃日是 10-01、10-02、10-03、10-05。按日期排完后行号是 1、2、3、4。日期减行号(用同一起点):
- 10-01 - 1 = 09-30
- 10-02 - 2 = 09-30
- 10-03 - 3 = 09-30
- 10-05 - 4 = 10-01
前三天差值相同,是一段;第五天差值变了,新开一段。这就是常说的 gaps and islands:缺口把岛屿切开。

口述级 SQL(历史最长连续天数):

WITH dau AS (
  SELECT user_id, dt
  FROM event
  GROUP BY user_id, dt          -- 当天多次登录先去重
),
marked AS (
  SELECT
    user_id,
    dt,
    DATE_SUB(dt, INTERVAL ROW_NUMBER() OVER (
      PARTITION BY user_id ORDER BY dt
    ) DAY) AS island_id
  FROM dau
),
streaks AS (
  SELECT user_id, island_id, COUNT(*) AS streak_days
  FROM marked
  GROUP BY 1, 2
)
SELECT user_id, MAX(streak_days) AS max_streak
FROM streaks
GROUP BY 1;

当前仍在连的天数不是 MAX 历史段,而是:只取每用户 MAX(dt) 那一段,且这段的最大日期必须等于「统计日」(或昨天,看口径)。历史最长和「此刻连了几天」是两个数,面试时先问清楚再写。

收尾:面试官听的是「去重 → 造不变的组键 → 按组计数」。组键怎么写可以变(日期减行号、日期减 RANK、和前一日差值再累加断点),判断句不变:连续的日期要能被收进同一组。

🔍 关键词

去重到人日日期减行号island_id最长连续当前连续

💭 面试官可能的追问

追问 1:不用日期减行号,还能怎么分组?

另一种常见写法:LAG(dt) 看是否等于前一日 + 1,不等就标 1 否则标 0,再按用户对这个断点标志 SUM() OVER (ORDER BY dt) 得到组号。和「日期减行号」等价,只是把「缺口」显式标出来。有的引擎日期减整数不方便,用 LAG 更直观。不要同时用两套组键,口径容易对不上。

追问 2:业务说「漏一天也算连续」,或者只要近 30 天窗口,怎么改?

允许间隔 1 天:判断连续的条件改成 dt - prev_dt <= 2,或把缺失日先用日历表补齐再按严格相邻切。近 30 天:先在 dau 里加 dt BETWEEN 统计日-29 AND 统计日,再分组;否则会把窗口外的尾巴拼进来。这两种都要先改口径再改 SQL,不要先改窗口再回头解释数字。

追问 3:用户量很大时,这套窗口怎么避免算爆?

先在上游落一张每日去重活跃表,不要对原始事件反复 GROUP BY user_id, dt。窗口按 user_id 分区,数据要按 user_id、dt 排好或分桶,减少 Shuffle。只要最长天数时,可以在 streaks 之后只留 MAX,不必把每一段都交给下游。统计日任务用分区裁剪,不要全历史重扫。

⚠️ 常见踩坑

  • 同一用户同一天多条登录没去重,连续天数被事件数撑爆
  • 用全局 ROW_NUMBER 而不是 PARTITION BY user_id,组键串用户
  • 把历史最长连续和「统计日仍在连」混成一个数
  • 日期减行号时行号从事件序来、不是从去重后的日期序来,中间有洞也会被收进同一段

🧠 记忆锚点

连续段靠「日期减行号」收进同一组;组内行数是天数,MAX 是历史最长,不是当前还在连。