登录日志接入:窗口函数是什么

增长团队接入 30 万行登录日志,第一个需求就让你愣住:「每条登录记录旁边,带上这个用户总共登录过几次。」--行不能被压掉,还得聚合。

学 45 min
练 60 min
盘 15 min
共 120 分钟

学 · 45 min

  1. 01OVER (PARTITION BY ... ORDER BY ...) 三个部分各自的作用

    场景「每条登录记录旁边带上该用户的登录总次数」--既不能丢行,又要有聚合值。

    公式:聚合/排名函数 + over (...) = 窗口函数。partition by user_id 把行按用户分堆(像 group by,但只是「围出计算范围」);order by 在堆内排序(排名、位移类函数必需);括号空着 = 全表一大堆。

    select user_id, login_at,
           count(*) over (partition by user_id) as 该用户登录总次数
    from user_logins
    limit 10;

    易错over() 不能省:聚合函数带 over 才是窗口函数,不带就是普通聚合(受 group by 管辖)。多写一个括号,语义两个世界。

  2. 02与 GROUP BY 的本质区别:不压缩行数

    场景用 group by 只能拿到「每个用户登录几次」4 行;需求要的是 30 万行明细、每行带个数。

    group by 把 N 行压成每组织一行;窗口函数 N 行进去 N 行出来,只是每行旁边多了几列计算结果。判断口诀:需求要「只看汇总」用 group by;要「明细和统计值同框」用窗口。以前用「group by 再 join 回去」实现的报表,全部可以一句话换成窗口。

    -- group by:1 万行(每用户一行)
    select user_id, count(*) from user_logins group by user_id;
    -- 窗口:30 万行(每条记录一行,旁边带总数)
    select user_id, login_at, count(*) over (partition by user_id) from user_logins;

    易错看到「group by 完又 join 回原表拿明细」的 SQL,一律是窗口函数的存量债。

  3. 03窗口函数在逻辑执行顺序中的位置(HAVING 之后、ORDER BY 之前)

    场景D6 背的九步顺序,今天要插进一个新步骤。

    窗口函数在 SELECT 阶段计算,位置在 HAVING 之后、DISTINCT / ORDER BY 之前。这意味着两件事:① 它看到的是 WHERE / HAVING 过滤之后的行(过滤条件影响了它的计算范围);② WHERE 执行时它还不存在(下一条的坑)。

    -- 先过滤(WHERE)再算窗口:count 数的是"过滤后"的行
    select user_id, login_at,
           count(*) over (partition by user_id) as n
    from user_logins
    where login_at >= current_date - 30;   -- 窗口只看到最近 30 天的行

    易错想要「全量统计 + 只显示部分行」时,别在 WHERE 里过滤--先窗口再在外层过滤(子查询包一层)。

  4. 04因此不能写在 WHERE / GROUP BY / HAVING 里

    场景你顺手写了 where count(*) over (...) > 5,报错 window functions are not allowed in WHERE。

    原因就是执行顺序:WHERE 是第 3 步,窗口值第 6 步才算出来,WHERE 执行时它根本不存在。标准修法:子查询里先算窗口,外层再过滤。这个「包一层」的动作本周要形成肌肉记忆,天天用。

    select *
    from (
      select user_id, login_at,
             count(*) over (partition by user_id) as n
      from user_logins
    ) t
    where t.n > 5;      -- 过滤窗口结果,必须在子查询外面

    易错同理 HAVING 里也不能用窗口函数;排序键里可以用(ORDER BY 在 SELECT 之后),但几乎没这个需求。

  5. 05WINDOW w AS (...) 复用窗口定义

    场景同一条 SQL 里三个函数要用同一个窗口,写三遍 partition by user_id order by login_at 太啰嗦。

    window w as (partition by user_id order by login_at) 定义一次,函数处写 over w 复用。改窗口定义只改一处,长的分析 SQL 里非常值得。

    select user_id, login_at,
           row_number()     over w as rn,
           lag(login_at)    over w as 上一条,
           sum(1)           over w as 累计条数
    from user_logins
    window w as (partition by user_id order by login_at)
    limit 10;

    易错WINDOW 子句只在同一个 SELECT 里生效,CTE / 子查询里得各自定义。

练 · 60 min

  1. 跑 seed.sql「W4」段导入 user_logins(30 万行)
    参考答案

    前提是 §B 的 users 已经灌过(§D 从 users 取全部 1 万个用户)。每个用户在自己的活跃窗口内散布 10~50 次登录,这个结构是后面「连续登录」题能查出答案的关键。跑完核对行数,别重复灌。

    -- 跑 seed.sql 的 §D 段(页面文案有时叫「W4」段):一条 INSERT,从 users 生成登录日志
    select count(*) from user_logins;   -- ≈30 万行
  2. 一条 SQL 同时输出每条登录记录和该用户的登录总次数
    参考答案

    窗口函数 N 行进 N 行出:30 万行明细一行不少,每行旁边多了该用户的总次数。partition by 只「围出计算范围」,不压缩行。

    select user_id, login_at,
           count(*) over (partition by user_id) as 该用户登录总次数
    from user_logins
    limit 10;
  3. 用 GROUP BY 实现「每用户登录次数」,对比两者返回行数
    参考答案

    行数差 30 倍就是两者的本质区别:GROUP BY 把行压成每组一行,窗口函数保留全部明细。要「只看汇总」用前者,要「明细和统计值同框」用后者。

    -- GROUP BY:每用户一行
    select user_id, count(*) as 次数
    from user_logins
    group by user_id;                                    -- ≈1 万行
    
    -- 窗口:每条登录记录一行,旁边带总次数
    select user_id, login_at,
           count(*) over (partition by user_id) as 次数
    from user_logins;                                    -- ≈30 万行
  4. 试着在 WHERE 里用窗口函数,记下报错;再用子查询包一层解决
    参考答案

    WHERE 是九步顺序的第 3 步,窗口值第 6 步(SELECT 阶段)才算出来。「子查询包一层」是本周天天要做的肌肉记忆动作。

    select user_id, login_at
    from user_logins
    where count(*) over (partition by user_id) > 5;
    -- ERROR: window functions are not allowed in WHERE
    
    -- 正确姿势:子查询里先算窗口,外层再过滤
    select *
    from (
      select user_id, login_at,
             count(*) over (partition by user_id) as n
      from user_logins
    ) t
    where t.n > 5;
  5. 用 WINDOW 子句定义一次窗口,被三个函数复用
    参考答案

    三个函数共享同一个窗口定义,改一处全部生效。注意:带了 order by 之后 sum(1) 变成「到当前行为止的累计条数」--默认框架在起作用,D25 专门拆它。

    select user_id, login_at,
           row_number()  over w as rn,
           lag(login_at) over w as 上一条,
           sum(1)        over w as 累计条数
    from user_logins
    window w as (partition by user_id order by login_at)
    limit 10;
过关标准 用一句话讲清窗口函数和 GROUP BY 的区别,并解释为什么 WHERE 里用不了。

今日复盘

  1. 60 分钟纸上手写 6 题(不许运行、不许查文档)--模拟白板环节
    参考答案

    白板规则:纸笔、不运行、不查文档,60 分钟 6 题平均每题 10 分钟。出题范围就是本周五种武器:① 子查询三个位置(标量 / 派生表 / 条件);② NOT IN 遇 NULL 的推导 + NOT EXISTS 写法;③ 递归 CTE(路径或日期序列);④ 多级 CTE 拆一个绕需求;⑤ LATERAL 的 Top-N per group。手写时最容易漏的是 with recursive 的 union all 和 lateral 的 on true--这两处白板上丢了整条就跑不通。

  2. D13 的最后一笔欠账:「每月新增用户数与当月下单用户数」--两段各自聚合,再对齐成一张表
    参考答案

    两个数字来自不同的表、不同的时间列,一条 SQL 聚不出来--必须先各自聚成「月份表」再对齐,这正是 CTE 存在的理由:给两段中间结果起名字,主查询只负责拼。为什么是 FULL JOIN:用户注册横跨两年、订单只有近 90 天,两边的月份集合互相都有对方没有的,LEFT 会丢掉只有订单的月、INNER 两头都丢。对齐后 coalesce 兜两处:月份本身(哪边有取哪边)和计数(没有就是 0)。

    with new_u as (                          -- 注册侧:按月数新增用户
      select date_trunc('month', created_at) as 月份, count(*) as 新增用户数
      from users
      group by 1
    ),
    act as (                                 -- 下单侧:按月数活跃用户(去重)
      select date_trunc('month', created_at) as 月份,
             count(distinct user_id) as 当月下单用户数
      from orders
      group by 1
    )
    select coalesce(n.月份, a.月份) as 月份,
           coalesce(n.新增用户数, 0) as 新增用户数,
           coalesce(a.当月下单用户数, 0) as 当月下单用户数
    from new_u n
    full join act a on a.月份 = n.月份
    order by 1;
  3. 自关联表:parent_id 指回本表
    易错点

    树的深度没有上限。任何「固定跳两层 JOIN」的写法都偷偷假设了「树只有两层」--需求一说「所有层级」就得换递归(D18)。