窗口函数的使用

一、排名与去重类

这是窗口函数最基础、最高频的考点,常用于 TopN、去重、取最新记录等场景。


1. ROW_NUMBER、RANK、DENSE_RANK 有什么区别?

答案

三者都可以用于排名,但处理并列名次时规则不同。

函数是否允许并列是否跳过名次特点
ROW_NUMBER() 每行唯一编号
RANK() 并列后跳号
DENSE_RANK() 并列后不跳号

示例数据

namescore
A 100
B 90
C 90
D 80
SELECT
    name,
    score,
    ROW_NUMBER() OVER(ORDER BY score DESC) AS rn,
    RANK() OVER(ORDER BY score DESC) AS rnk,
    DENSE_RANK() OVER(ORDER BY score DESC) AS dense_rnk
FROM student;

结果

namescorernrnkdense_rnk
A 100 1 1 1
B 90 2 2 2
C 90 3 2 2
D 80 4 4 3

面试表达

ROW_NUMBER 适合取唯一一条记录,比如每个用户最近一次登录。
RANK 适合保留并列名次,但并列后会跳号。
DENSE_RANK 适合业务上的连续排名,比如查询“前 3 名且包含并列”。


2. 查询每个部门薪资排名前 3 的员工,包含并列第 3 名

考察点

是否知道“包含并列”时不能用 ROW_NUMBER(),通常应使用 RANK()DENSE_RANK()

推荐写法

WITH t AS (
    SELECT
        emp_id,
        emp_name,
        dept_id,
        salary,
        DENSE_RANK() OVER(
            PARTITION BY dept_id
            ORDER BY salary DESC
        ) AS salary_rank
    FROM employee
)
SELECT
    emp_id,
    emp_name,
    dept_id,
    salary
FROM t
WHERE salary_rank <= 3;

注意

如果业务说的是“薪资档位前 3”,用 DENSE_RANK() 更合适。

如果业务说的是“排名前 3,排名规则允许跳号”,可以用 RANK()

如果业务说的是“每个部门最多返回 3 个人”,才用 ROW_NUMBER()


3. 查询每个用户最近一次登录记录

考察点

典型的 Top1 问题,使用 ROW_NUMBER()

WITH t AS (
    SELECT
        user_id,
        login_time,
        device,
        ip,
        ROW_NUMBER() OVER(
            PARTITION BY user_id
            ORDER BY login_time DESC, id DESC
        ) AS rn
    FROM user_login
)
SELECT
    user_id,
    login_time,
    device,
    ip
FROM t
WHERE rn = 1;

为什么加 id DESC

如果同一个用户有多条登录记录的 login_time 完全一样,只按 login_time DESC 排序会导致结果不稳定。

因此要加一个唯一字段作为二级排序条件,例如:

ORDER BY login_time DESC, id DESC

这可以保证每次查询结果确定。


4. 删除重复数据,只保留每组 ID 最大的一条

假设表 orders 中,按照 user_id, order_no 判断重复,只保留 id 最大的一条。

查询重复数据

WITH t AS (
    SELECT
        *,
        ROW_NUMBER() OVER(
            PARTITION BY user_id, order_no
            ORDER BY id DESC
        ) AS rn
    FROM orders
)
SELECT *
FROM t
WHERE rn > 1;

删除重复数据

不同数据库写法略有不同。

MySQL 8+

DELETE FROM orders
WHERE id IN (
    SELECT id
    FROM (
        SELECT
            id,
            ROW_NUMBER() OVER(
                PARTITION BY user_id, order_no
                ORDER BY id DESC
            ) AS rn
        FROM orders
    ) t
    WHERE rn > 1
);

PostgreSQL

WITH t AS (
    SELECT
        id,
        ROW_NUMBER() OVER(
            PARTITION BY user_id, order_no
            ORDER BY id DESC
        ) AS rn
    FROM orders
)
DELETE FROM orders
WHERE id IN (
    SELECT id
    FROM t
    WHERE rn > 1
);

面试加分回答

相比 GROUP BY + MAX(id),窗口函数可以保留整行数据,并且逻辑更清晰,适合复杂去重场景。


5. 如果排序字段有 NULL,窗口函数如何处理?

答案

不同数据库对 NULL 的默认排序规则可能不同。

常见处理方式是显式指定:

ORDER BY salary DESC NULLS LAST

部分数据库支持 NULLS FIRST / NULLS LAST,如 PostgreSQL、Oracle。

MySQL 不直接支持该语法,可以使用表达式处理:

ORDER BY salary IS NULL, salary DESC

含义是:

salary IS NULL = 0

的非空数据排前面,NULL 排后面。

面试表达

为了保证窗口函数排序结果稳定,排序字段中如果可能有 NULL,应显式定义 NULL 的排序规则,避免依赖数据库默认行为。


二、偏移分析类:LAG / LEAD

主要用于时序分析,比如相邻订单间隔、环比、前后状态变化。


6. 不使用自连接,如何计算当前记录和上一条记录的差值?

示例:计算每日销售额与前一天销售额的差值

SELECT
    dt,
    sales,
    sales - LAG(sales) OVER(ORDER BY dt) AS diff
FROM daily_sales;

带默认值

SELECT
    dt,
    sales,
    sales - LAG(sales, 1, 0) OVER(ORDER BY dt) AS diff
FROM daily_sales;

解释

LAG(sales, 1, 0)

含义是:

当前行往前取 1 行的 sales,如果没有上一行,则返回默认值 0


7. 计算用户相邻两次下单的时间间隔

假设订单表为 orders(user_id, order_id, order_time)

WITH t AS (
    SELECT
        user_id,
        order_id,
        order_time,
        LAG(order_time) OVER(
            PARTITION BY user_id
            ORDER BY order_time
        ) AS prev_order_time
    FROM orders
)
SELECT
    user_id,
    order_id,
    order_time,
    prev_order_time,
    DATEDIFF(order_time, prev_order_time) AS interval_days
FROM t;

注意

不同数据库日期函数不同:

MySQL

DATEDIFF(order_time, prev_order_time)

PostgreSQL

order_time::date - prev_order_time::date

Spark SQL

datediff(order_time, prev_order_time)

面试加分点

如果一个用户一天内有多笔订单,排序最好加上订单 ID:

ORDER BY order_time, order_id

避免相同时间导致顺序不确定。


8. 计算每日销售额的环比增长率

WITH t AS (
    SELECT
        dt,
        sales,
        LAG(sales) OVER(ORDER BY dt) AS prev_sales
    FROM daily_sales
)
SELECT
    dt,
    sales,
    prev_sales,
    CASE
        WHEN prev_sales IS NULL OR prev_sales = THEN NULL
        ELSE (sales - prev_sales) * 1.0 / prev_sales
    END AS growth_rate
FROM t;

易错点

不能直接写:

(sales - LAG(sales)) / LAG(sales)

虽然有些数据库允许,但可读性差,而且没有处理 NULL 和除零问题。

面试表达

环比计算要注意两类边界:

  1. 第一行没有上一期数据。

  2. 上一期销售额为 0,不能直接作为分母。


9. 找出状态发生变化的记录

例如用户状态表:

user_iddtstatus
1 2024-01-01 active
1 2024-01-02 active
1 2024-01-03 inactive

查询状态变化的日期。

WITH t AS (
    SELECT
        user_id,
        dt,
        status,
        LAG(status) OVER(
            PARTITION BY user_id
            ORDER BY dt
        ) AS prev_status
    FROM user_status
)
SELECT
    user_id,
    dt,
    status,
    prev_status
FROM t
WHERE prev_status IS NOT NULL
  AND status <> prev_status;

考察点

LAG() 不只可以计算数值差值,也可以比较前后状态变化。


三、聚合窗口类:SUM / COUNT / AVG OVER

主要考察累计值、总计占比、滑动窗口、窗口帧。


10. SUM(salary) OVER()SUM(salary) OVER(PARTITION BY dept_id) 有什么区别?

示例

SELECT
    emp_id,
    dept_id,
    salary,
    SUM(salary) OVER() AS total_salary,
    SUM(salary) OVER(PARTITION BY dept_id) AS dept_total_salary
FROM employee;

区别

写法含义
SUM(salary) OVER() 全表总薪资,每行相同
SUM(salary) OVER(PARTITION BY dept_id) 每个部门总薪资,同部门相同
SUM(salary) OVER(ORDER BY hire_date) 按入职时间累计求和

11. 计算每个用户的累计消费金额

SELECT
    user_id,
    order_id,
    order_time,
    amount,
    SUM(amount) OVER(
        PARTITION BY user_id
        ORDER BY order_time, order_id
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cumulative_amount
FROM orders;

为什么建议显式写窗口帧?

因为默认窗口帧在不同数据库、不同排序字段下可能产生不符合预期的结果。

尤其当 ORDER BY 字段有重复值时,RANGEROWS 的结果可能不同。


12. 计算每个用户订单金额占其总消费金额的比例

SELECT
    user_id,
    order_id,
    amount,
    amount * 1.0 / SUM(amount) OVER(PARTITION BY user_id) AS amount_ratio
FROM orders;

面试表达

窗口函数可以在保留明细行的同时计算分组汇总值。

如果用 GROUP BY user_id,会把明细行聚合掉;而窗口函数不会减少行数。


13. 计算最近 7 天移动平均销售额

如果数据是每天一行:

SELECT
    dt,
    sales,
    AVG(sales) OVER(
        ORDER BY dt
        ROWS BETWEEN PRECEDING AND CURRENT ROW
    ) AS moving_avg_7d
FROM daily_sales;

重要陷阱

ROWS BETWEEN 6 PRECEDING AND CURRENT ROW 表示当前行以及前 6 行,不一定是真实的最近 7 个自然日。

如果数据缺日期,比如周末没有数据,那么它计算的是最近 7 条记录,而不是最近 7 天。


14. ROWS 和 RANGE 有什么区别?

答案

类型含义适合场景
ROWS 按物理行数计算窗口 最近 N 条记录
RANGE 按排序字段的值范围计算窗口 最近 N 天、金额范围、时间范围

示例

AVG(sales) OVER(
    ORDER BY dt
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
)

表示最近 7 行。

AVG(sales) OVER(
    ORDER BY dt
    RANGE BETWEEN INTERVAL '6' DAY PRECEDING AND CURRENT ROW
)

表示最近 7 个自然日。

面试表达

如果业务要求“最近 7 条记录”,用 ROWS
如果业务要求“最近 7 天”,更应该考虑 RANGE 或先补齐日期表后再用 ROWS


四、连续性问题:岛屿与间隙问题

这是中高级面试的重点,常见于连续签到、连续登录、连续上涨、连续异常等场景。


15. 找出连续登录 3 天及以上的用户

假设表为:

user_login(user_id, login_date)

同一用户一天可能登录多次,需要先去重。

WITH login_distinct AS (
    SELECT DISTINCT
        user_id,
        login_date
    FROM user_login
),
AS (
    SELECT
        user_id,
        login_date,
        ROW_NUMBER() OVER(
            PARTITION BY user_id
            ORDER BY login_date
        ) AS rn
    FROM login_distinct
),
AS (
    SELECT
        user_id,
        login_date,
        DATE_SUB(login_date, INTERVAL rn DAY) AS grp
    FROM t
)
SELECT
    user_id,
    MIN(login_date) AS start_date,
    MAX(login_date) AS end_date,
    COUNT(*) AS continuous_days
FROM g
GROUP BY user_id, grp
HAVING COUNT(*) >= 3;

解题思路

连续日期有一个特点:

login_date - row_number

结果是恒定的。

例如:

login_daternlogin_date - rn
2024-01-01 1 2023-12-31
2024-01-02 2 2023-12-31
2024-01-03 3 2023-12-31

所以可以按这个差值分组,得到连续区间。


16. 找出每个用户最长连续登录天数

WITH login_distinct AS (
    SELECT DISTINCT
        user_id,
        login_date
    FROM user_login
),
AS (
    SELECT
        user_id,
        login_date,
        ROW_NUMBER() OVER(
            PARTITION BY user_id
            ORDER BY login_date
        ) AS rn
    FROM login_distinct
),
AS (
    SELECT
        user_id,
        login_date,
        DATE_SUB(login_date, INTERVAL rn DAY) AS grp
    FROM t
),
periods AS (
    SELECT
        user_id,
        MIN(login_date) AS start_date,
        MAX(login_date) AS end_date,
        COUNT(*) AS continuous_days
    FROM g
    GROUP BY user_id, grp
),
AS (
    SELECT
        *,
        ROW_NUMBER() OVER(
            PARTITION BY user_id
            ORDER BY continuous_days DESC, start_date
        ) AS rn
    FROM periods
)
SELECT
    user_id,
    start_date,
    end_date,
    continuous_days
FROM r
WHERE rn = 1;

易错点

同一个用户一天多次登录,必须先 DISTINCT,否则连续天数会被重复登录次数干扰。


17. 查找股票连续上涨的区间

假设表为:

stock_price(stock_id, dt, price)

思路:

  1. LAG() 找出当天是否上涨。

  2. 只保留上涨的日期。

  3. ROW_NUMBER() 差值法找连续上涨区间。

WITH t AS (
    SELECT
        stock_id,
        dt,
        price,
        LAG(price) OVER(
            PARTITION BY stock_id
            ORDER BY dt
        ) AS prev_price
    FROM stock_price
),
up_days AS (
    SELECT
        stock_id,
        dt,
        price
    FROM t
    WHERE price > prev_price
),
AS (
    SELECT
        stock_id,
        dt,
        price,
        ROW_NUMBER() OVER(
            PARTITION BY stock_id
            ORDER BY dt
        ) AS rn
    FROM up_days
),
AS (
    SELECT
        stock_id,
        dt,
        price,
        DATE_SUB(dt, INTERVAL rn DAY) AS grp
    FROM r
)
SELECT
    stock_id,
    MIN(dt) AS start_date,
    MAX(dt) AS end_date,
    COUNT(*) AS up_days
FROM g
GROUP BY stock_id, grp;

面试追问

这里的 up_days 统计的是“上涨的交易日数量”,不是完整行情区间的自然天数。

如果要表达“连续上涨 3 天”,需要先明确:

  • 是连续 3 个自然日?

  • 还是连续 3 个交易日?

  • 是 3 次上涨?

  • 还是包含起点在内的 4 天价格序列?

这个澄清很加分。


18. 合并重叠的时间区间

假设有会议室预订表:

booking(room_id, start_time, end_time)

目标:合并重叠或相连的时间段。

WITH t AS (
    SELECT
        room_id,
        start_time,
        end_time,
        MAX(end_time) OVER(
            PARTITION BY room_id
            ORDER BY start_time, end_time
            ROWS BETWEEN UNBOUNDED PRECEDING AND PRECEDING
        ) AS prev_max_end_time
    FROM booking
),
flagged AS (
    SELECT
        room_id,
        start_time,
        end_time,
        CASE
            WHEN prev_max_end_time IS NULL
              OR start_time > prev_max_end_time
            THEN 1
            ELSE 0
        END AS new_group_flag
    FROM t
),
grouped AS (
    SELECT
        room_id,
        start_time,
        end_time,
        SUM(new_group_flag) OVER(
            PARTITION BY room_id
            ORDER BY start_time, end_time
        ) AS grp
    FROM flagged
)
SELECT
    room_id,
    MIN(start_time) AS merged_start_time,
    MAX(end_time) AS merged_end_time
FROM grouped
GROUP BY room_id, grp
ORDER BY room_id, merged_start_time;

核心思路

判断当前区间是否开启新组:

start_time > 前面所有区间的最大 end_time

如果是,则新开一个区间组;否则归入当前组。

易错点

不能只比较上一行的 end_time,因为可能存在嵌套区间。

例如:

start_timeend_time
09:00 12:00
10:00 10:30
11:00 13:00

第三行和第二行看似不重叠,但它和第一行重叠。因此要比较“前面所有区间的最大结束时间”。


五、分组取值与首尾值类

这一类经常考 FIRST_VALUELAST_VALUENTH_VALUE


19. 查询每个用户第一笔订单和最后一笔订单金额

SELECT
    user_id,
    order_id,
    order_time,
    amount,
    FIRST_VALUE(amount) OVER(
        PARTITION BY user_id
        ORDER BY order_time, order_id
    ) AS first_order_amount,
    LAST_VALUE(amount) OVER(
        PARTITION BY user_id
        ORDER BY order_time, order_id
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS last_order_amount
FROM orders;

重要陷阱

LAST_VALUE() 默认窗口帧通常截止到当前行,所以如果不写:

ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING

LAST_VALUE() 很可能返回当前行的金额,而不是分组内最后一行金额。

这是非常高频的坑。


20. 每个部门薪资最高员工的姓名

可以用 FIRST_VALUE()

SELECT DISTINCT
    dept_id,
    FIRST_VALUE(emp_name) OVER(
        PARTITION BY dept_id
        ORDER BY salary DESC, emp_id
    ) AS highest_salary_emp
FROM employee;

也可以用 ROW_NUMBER()

WITH t AS (
    SELECT
        *,
        ROW_NUMBER() OVER(
            PARTITION BY dept_id
            ORDER BY salary DESC, emp_id
        ) AS rn
    FROM employee
)
SELECT
    dept_id,
    emp_name,
    salary
FROM t
WHERE rn = 1;

面试建议

实际开发中更推荐 ROW_NUMBER(),因为它可以保留完整员工信息,并且逻辑更清晰。


六、分桶与百分位类

常见于用户分层、成绩分布、商品价格分档。


21. NTILE 是什么?如何把用户按消费金额分成 4 档?

SELECT
    user_id,
    total_amount,
    NTILE(4) OVER(
        ORDER BY total_amount DESC
    ) AS level
FROM user_amount;

含义

NTILE(4) 会尽量把数据均匀分成 4 组。

例如可以理解为:

level含义
1 高消费用户
2 中高消费用户
3 中低消费用户
4 低消费用户

易错点

NTILE(4) 是按照人数均分,不是按照金额区间均分。

也就是说,每档人数尽量接近,但金额范围不一定均匀。


22. PERCENT_RANK 和 CUME_DIST 有什么区别?

答案

函数含义
PERCENT_RANK() 当前行排名在整体中的相对位置
CUME_DIST() 小于等于当前值的行数占比

示例:

SELECT
    user_id,
    score,
    PERCENT_RANK() OVER(ORDER BY score) AS percent_rank_value,
    CUME_DIST() OVER(ORDER BY score) AS cume_dist_value
FROM user_score;

面试表达

PERCENT_RANK() 更关注排名位置,CUME_DIST() 更关注累计分布比例。

在用户分层、成绩分布、风控评分中比较常见。


七、执行顺序与 SQL 原理类

这是高阶岗位、大数据岗位、数仓开发岗位常问内容。


23. 窗口函数在 SQL 中的逻辑执行顺序是什么?

标准逻辑顺序

FROM
WHERE
GROUP BY
HAVING
SELECT
窗口函数
ORDER BY
LIMIT

更准确地说,窗口函数是在 WHEREGROUP BYHAVING 之后计算的,因此它不能直接写在 WHERE 条件中。


24. 为什么窗口函数不能直接写在 WHERE 里?

错误写法:

SELECT
    *,
    ROW_NUMBER() OVER(
        PARTITION BY user_id
        ORDER BY login_time DESC
    ) AS rn
FROM user_login
WHERE rn = 1;

这是错误的,因为 WHERE 执行时,rn 还没有被计算出来。

正确写法

WITH t AS (
    SELECT
        *,
        ROW_NUMBER() OVER(
            PARTITION BY user_id
            ORDER BY login_time DESC
        ) AS rn
    FROM user_login
)
SELECT *
FROM t
WHERE rn = 1;

或者:

SELECT *
FROM (
    SELECT
        *,
        ROW_NUMBER() OVER(
            PARTITION BY user_id
            ORDER BY login_time DESC
        ) AS rn
    FROM user_login
) t
WHERE rn = 1;

特殊情况

有些数据库支持 QUALIFY,例如 BigQuery、Snowflake、Databricks SQL 等:

SELECT
    *,
    ROW_NUMBER() OVER(
        PARTITION BY user_id
        ORDER BY login_time DESC
    ) AS rn
FROM user_login
QUALIFY rn = 1;

QUALIFY 的作用类似于专门过滤窗口函数结果的 WHERE


25. 窗口函数和 GROUP BY 有什么区别?

答案

对比项GROUP BY窗口函数
是否减少行数 不会
是否保留明细 不保留 保留
适合场景 分组汇总 明细 + 分组指标
示例 每个部门平均工资 每个员工工资与部门平均工资对比

示例

查询每个员工工资与本部门平均工资的差值:

SELECT
    emp_id,
    emp_name,
    dept_id,
    salary,
    AVG(salary) OVER(PARTITION BY dept_id) AS dept_avg_salary,
    salary - AVG(salary) OVER(PARTITION BY dept_id) AS diff_from_avg
FROM employee;

如果用 GROUP BY dept_id,就无法直接保留每个员工的明细信息。


26. 窗口函数和自连接相比有什么优势?

答案

窗口函数通常有三个优势:

  1. SQL 更简洁,可读性更好。

  2. 可以避免复杂的自连接逻辑。

  3. 在很多数据库执行引擎中,窗口函数可以复用排序和分区结果,性能更稳定。

示例

计算每条订单与上一条订单的间隔,用自连接会很复杂,而窗口函数只需要:

LAG(order_time) OVER(
    PARTITION BY user_id
    ORDER BY order_time
)

八、性能优化类

适合数仓开发、大数据开发、后端数据查询优化岗位。


27. 窗口函数的性能瓶颈通常在哪里?

答案

窗口函数的主要性能开销通常来自:

  1. PARTITION BY 带来的数据分区。

  2. ORDER BY 带来的排序。

  3. 大数据场景下的 Shuffle。

  4. 多个不同窗口定义导致多次排序。

  5. 分区倾斜导致单个分区过大。

面试表达

窗口函数本质上需要在分区内排序,所以性能瓶颈通常不是函数计算本身,而是排序、分区和数据重分布。


28. 一个 SQL 中有多个窗口函数,会不会重复排序?

例如:

SELECT
    user_id,
    order_time,
    amount,
    ROW_NUMBER() OVER(
        PARTITION BY user_id
        ORDER BY order_time
    ) AS rn,
    SUM(amount) OVER(
        PARTITION BY user_id
        ORDER BY order_time
    ) AS cum_amount
FROM orders;

答案

如果多个窗口函数的 PARTITION BYORDER BY 完全相同,很多主流数据库或执行引擎可以复用排序结果。

但如果窗口定义不同,例如:

PARTITION BY user_id ORDER BY order_time

和:

PARTITION BY product_id ORDER BY order_time

通常需要不同的分区和排序,性能开销会明显增加。


29. 如何优化窗口函数?

常见优化思路

优化方向说明
减少数据量 先用 WHERE 过滤无关数据
减少字段 避免 SELECT *
统一窗口定义 尽量让多个窗口函数复用同一组 PARTITION BY + ORDER BY
控制分区大小 避免某个 key 数据极度倾斜
建索引 OLTP 数据库中可对分区字段、排序字段建联合索引
预聚合 先把明细压缩到天、用户、商品等粒度
分桶/分区表 大数据场景下利用表分区、桶表、Z-Order 等

示例

低效:

SELECT
    *,
    ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY order_time) AS rn
FROM orders;

优化:

WITH filtered AS (
    SELECT
        user_id,
        order_id,
        order_time,
        amount
    FROM orders
    WHERE order_time >= '2024-01-01'
)
SELECT
    *,
    ROW_NUMBER() OVER(
        PARTITION BY user_id
        ORDER BY order_time, order_id
    ) AS rn
FROM filtered;

30. 窗口函数适合在哪些业务场景中使用?

答案

窗口函数适合“既要保留明细,又要计算分组或前后关系”的场景。

常见场景包括:

场景典型函数
TopN 排名 ROW_NUMBERRANKDENSE_RANK
去重 ROW_NUMBER
最近一次记录 ROW_NUMBER
环比同比 LAGLEAD
累计值 SUM OVER
移动平均 AVG OVER
用户分层 NTILE
连续登录 ROW_NUMBER 差值法
首尾值 FIRST_VALUELAST_VALUE

九、综合业务题

下面这些更接近真实面试题。


31. 查询每个用户消费金额最高的订单

WITH t AS (
    SELECT
        user_id,
        order_id,
        amount,
        ROW_NUMBER() OVER(
            PARTITION BY user_id
            ORDER BY amount DESC, order_id
        ) AS rn
    FROM orders
)
SELECT
    user_id,
    order_id,
    amount
FROM t
WHERE rn = 1;

如果要保留并列最高金额订单

WITH t AS (
    SELECT
        user_id,
        order_id,
        amount,
        RANK() OVER(
            PARTITION BY user_id
            ORDER BY amount DESC
        ) AS rnk
    FROM orders
)
SELECT
    user_id,
    order_id,
    amount
FROM t
WHERE rnk = 1;

32. 查询每个班级成绩排名第二的学生

如果只返回一个人

WITH t AS (
    SELECT
        class_id,
        student_id,
        score,
        ROW_NUMBER() OVER(
            PARTITION BY class_id
            ORDER BY score DESC, student_id
        ) AS rn
    FROM student_score
)
SELECT *
FROM t
WHERE rn = 2;

如果保留并列第二

WITH t AS (
    SELECT
        class_id,
        student_id,
        score,
        DENSE_RANK() OVER(
            PARTITION BY class_id
            ORDER BY score DESC
        ) AS rnk
    FROM student_score
)
SELECT *
FROM t
WHERE rnk = 2;

33. 查询每个商品最近 30 天销量排名前 10 的日期

WITH daily AS (
    SELECT
        product_id,
        dt,
        SUM(quantity) AS sales_qty
    FROM order_detail
    WHERE dt >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
    GROUP BY product_id, dt
),
AS (
    SELECT
        product_id,
        dt,
        sales_qty,
        ROW_NUMBER() OVER(
            PARTITION BY product_id
            ORDER BY sales_qty DESC, dt
        ) AS rn
    FROM daily
)
SELECT
    product_id,
    dt,
    sales_qty
FROM r
WHERE rn <= 10;

考察点

这道题结合了:

  1. 预聚合。

  2. 分组排名。

  3. TopN。

  4. 时间过滤。


34. 计算用户每笔订单是该用户的第几单

SELECT
    user_id,
    order_id,
    order_time,
    ROW_NUMBER() OVER(
        PARTITION BY user_id
        ORDER BY order_time, order_id
    ) AS order_seq
FROM orders;

扩展

找出用户首单:

WITH t AS (
    SELECT
        user_id,
        order_id,
        order_time,
        ROW_NUMBER() OVER(
            PARTITION BY user_id
            ORDER BY order_time, order_id
        ) AS order_seq
    FROM orders
)
SELECT *
FROM t
WHERE order_seq = 1;

找出用户复购订单:

WITH t AS (
    SELECT
        user_id,
        order_id,
        order_time,
        ROW_NUMBER() OVER(
            PARTITION BY user_id
            ORDER BY order_time, order_id
        ) AS order_seq
    FROM orders
)
SELECT *
FROM t
WHERE order_seq >= 2;

35. 计算每个用户从首单到当前订单的累计消费

SELECT
    user_id,
    order_id,
    order_time,
    amount,
    SUM(amount) OVER(
        PARTITION BY user_id
        ORDER BY order_time, order_id
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cumulative_amount
FROM orders;

十、面试回答模板

最后给你一套回答窗口函数题的通用模板。


1. 遇到 TopN 问题

可以这样说:

这个问题本质是分组内排序。
我会使用窗口函数,按照业务维度 PARTITION BY,按照排名指标 ORDER BY
如果只需要固定返回 N 条,用 ROW_NUMBER();如果要保留并列名次,用 RANK()DENSE_RANK()


2. 遇到最近一次记录问题

可以这样说:

这是典型的分组取最新记录问题。
我会用 ROW_NUMBER(),按照用户分区,按时间倒序排序。
为了保证结果确定性,如果时间字段可能重复,会补充主键作为二级排序条件。


3. 遇到环比问题

可以这样说:

环比问题需要获取当前行的上一期数据。
使用 LAG() 可以避免自连接。
同时需要处理第一行没有上一期数据,以及上一期指标为 0 的边界情况。


4. 遇到累计问题

可以这样说:

累计指标可以用聚合窗口函数实现。
关键是定义好分区、排序和窗口帧。
我通常会显式写出 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,避免默认窗口帧带来的歧义。


5. 遇到连续性问题

可以这样说:

连续性问题属于岛屿与间隙问题。
常见解法是先对日期去重,然后用 ROW_NUMBER() 编号。
对连续日期来说,日期 - 序号 是恒定值,因此可以按该值分组,得到连续区间。


高频易错点总结

易错点正确做法
ROW_NUMBER() 查并列 TopN 应使用 RANK()DENSE_RANK()
排序字段重复导致结果不稳定 增加主键作为二级排序字段
忽略 NULL 排序 显式指定 NULLS FIRST/LAST 或用表达式处理
直接在 WHERE 中过滤窗口函数 用 CTE、子查询或 QUALIFY
LAST_VALUE() 结果异常 显式指定完整窗口帧
移动 7 行误认为移动 7 天 区分 ROWSRANGE
连续登录未先去重 先按用户、日期去重
环比未处理除零 使用 CASE WHEN prev_value = 0 THEN NULL
多个窗口定义不一致 尽量复用相同 PARTITION BY + ORDER BY
大表直接窗口排序 先过滤、预聚合、减少字段

最后浓缩版背诵口诀

排名用 Rank,唯一用 Row。
并列要保留,别忘 Dense。
前后看 Lag Lead,累计看 Sum Over。
连续问题日期减序号。
WHERE 不能直接筛窗口,CTE 包一层。
排序要稳定,主键来兜底。
LAST_VALUE 要小心,窗口帧要写全。

posted @ 2026-06-30 20:09  GeneJiang  阅读(13)  评论(0)    收藏  举报