窗口函数

窗口函数 vs 聚合函数

聚合函数(GROUP BY)的问题:它把多行折叠成一行,原始明细丢失了. 如果想"给每一行附上一个聚合计算值"(比如每行显示该行在同组中的排名),就需要窗口函数.

-- 聚合:每个城市的用户数(折叠为 4 行)
SELECT city, COUNT(*) FROM users GROUP BY city;

-- 窗口函数:每个用户保留,同时显示所在城市的用户数(仍然 8 行)
SELECT username, city,
COUNT(*) OVER (PARTITION BY city) AS city_user_count
FROM users;

类比:聚合像是把一组人拍成一张集体照(只留一个结果),窗口函数像是给每个人发一张标签"你在 XX 组里,这个组共 N 人".

一句话总结

窗口函数 = 不分组,不折叠,但能看到"窗口"内其他行的计算. 每行都保留,每行都得到一个计算结果.

OVER 子句语法

函数名(...) OVER (
[PARTITION BY 分组列] -- 可选:按什么分窗口
[ORDER BY 排序列 [ASC|DESC]] -- 可选:窗口内排序
[帧定义] -- 可选:计算范围
)
组成部分 作用 省略时
PARTITION BY 把数据切成多个"窗口"(类似 GROUP BY 但不折叠) 整张表视为一个窗口
ORDER BY 窗口内行的排序(决定排名顺序,累计方向) 窗口内行无序
帧定义 限制计算范围(前 N 行到当前行等) 取决于有无 ORDER BY

排名函数

函数 遇到并列时 示例结果
ROW_NUMBER() 强制不重复(随机分配序号) 1, 2, 3, 4, 5
RANK() 并列同号,后面跳号 1, 2, 2, 4, 5
DENSE_RANK() 并列同号,后面不跳号 1, 2, 2, 3, 4
-- 全部商品按价格降序排名
SELECT name, price,
ROW_NUMBER() OVER (ORDER BY price DESC) AS rn,
RANK() OVER (ORDER BY price DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY price DESC) AS drnk
FROM products;
-- 每个分类内,按价格降序排名
SELECT c.name AS category, p.name AS product, p.price,
RANK() OVER (PARTITION BY c.name ORDER BY p.price DESC) AS rank_in_cat
FROM products p
JOIN categories c ON c.id = p.category_id;

经典应用:取每组的 Top N

-- 每个分类中最贵的 2 个商品
SELECT * FROM (
SELECT c.name AS category, p.name AS product, p.price,
ROW_NUMBER() OVER (PARTITION BY c.id ORDER BY p.price DESC) AS rn
FROM products p
JOIN categories c ON c.id = p.category_id
) ranked
WHERE rn <= 2;

为什么不能直接 WHERE rn <= 2?

窗口函数在 SQL 执行顺序中位于 WHERE 之后. 所以必须先在子查询(或 CTE)中算出排名,再在外层 WHERE 过滤. 这是窗口函数使用中最重要的"套路".

偏移函数:LAG / LEAD

LAG 和 LEAD 让你在当前行"看到"同窗口内前一行或后一行的值:

函数 含义
LAG(col, n, default) 当前行往 第 n 行的 col 值(默认 n=1)
LEAD(col, n, default) 当前行往 第 n 行的 col 值
FIRST_VALUE(col) 窗口内第一行的值
LAST_VALUE(col) 窗口内最后一行的值(注意帧定义)
NTH_VALUE(col, n) 窗口内第 n 行的值
-- 每个用户的订单,显示上一单金额和环比变化
SELECT
u.username,
o.created_at::date AS order_date,
o.total,
LAG(o.total) OVER (PARTITION BY o.user_id ORDER BY o.created_at) AS prev_total,
o.total - LAG(o.total) OVER (PARTITION BY o.user_id ORDER BY o.created_at) AS diff
FROM orders o
JOIN users u ON u.id = o.user_id
ORDER BY u.username, o.created_at;
-- 月度收入环比增长率(用 LAG 改写上一篇的自连接方案)
WITH monthly AS (
SELECT TO_CHAR(created_at, 'YYYY-MM') AS month,
SUM(total) AS revenue
FROM orders
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
)
SELECT month, revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_revenue,
ROUND(
(revenue - LAG(revenue) OVER (ORDER BY month))
/ LAG(revenue) OVER (ORDER BY month) * 100, 1
) AS growth_pct
FROM monthly;

聚合作为窗口函数

SUM / AVG / COUNT / MIN / MAX 都可以加 OVER 变成窗口函数.最常见的用途:累计求和.

-- 按时间顺序显示订单的累计金额
SELECT id, created_at::date, total,
SUM(total) OVER (ORDER BY created_at) AS running_total
FROM orders
WHERE status = 'done'
ORDER BY created_at;
-- 每行显示该商品价格占同分类总价的百分比
SELECT c.name AS category, p.name, p.price,
SUM(p.price) OVER (PARTITION BY c.id) AS cat_total,
ROUND(p.price / SUM(p.price) OVER (PARTITION BY c.id) * 100, 1) AS pct
FROM products p
JOIN categories c ON c.id = p.category_id
ORDER BY category, pct DESC;
-- 每个用户的订单序号(第几单)
SELECT u.username, o.created_at::date, o.total,
ROW_NUMBER() OVER (PARTITION BY o.user_id ORDER BY o.created_at) AS order_seq
FROM orders o
JOIN users u ON u.id = o.user_id
ORDER BY u.username, order_seq;

窗口帧(Frame)

帧定义精确控制"窗口内参与计算的行范围".有 ORDER BY 时默认帧是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW(从窗口开头到当前行).

-- 帧语法
ROWS BETWEEN 起点 AND 终点

-- 常用起点/终点:
-- UNBOUNDED PRECEDING - 窗口第一行
-- N PRECEDING - 当前行往前 N 行
-- CURRENT ROW - 当前行
-- N FOLLOWING - 当前行往后 N 行
-- UNBOUNDED FOLLOWING - 窗口最后一行
-- 3 天移动平均(当前行 + 前 2 行)
WITH daily_revenue AS (
SELECT created_at::date AS day, SUM(total) AS revenue
FROM orders GROUP BY created_at::date
)
SELECT day, revenue,
ROUND(AVG(revenue) OVER (
ORDER BY day
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
), 2) AS moving_avg_3
FROM daily_revenue
ORDER BY day;
-- 累计到当前行的最大订单金额
SELECT id, created_at::date, total,
MAX(total) OVER (ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_max
FROM orders ORDER BY created_at;

ROWS vs RANGE

ROWS 按物理行号计算偏移;RANGE 按值范围计算(同值的行视为同一位置). 大多数场景用 ROWS 更直观可控. 如果排序列有重复值(如同一天有多条记录),RANGE 会把同值行全部包含在帧中.

命名窗口

多个窗口函数共用相同的 PARTITION BY / ORDER BY 时,用 WINDOW 子句避免重复:

SELECT username, city, balance,
RANK() OVER w AS balance_rank,
SUM(balance) OVER w AS running_balance,
balance - LAG(balance) OVER w AS diff_from_prev
FROM users
WINDOW w AS (PARTITION BY city ORDER BY balance DESC)
ORDER BY city, balance_rank;

实战模式

模式 1:去重保留最新一条

-- 每个用户只保留最近一笔订单
SELECT * FROM (
SELECT o.*,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM orders o
) t WHERE rn = 1;

模式 2:连续登录天数 / 连续事件检测

-- 思路:日期减去行号 = 同一个常数 → 说明是连续的
-- 假设有 login_log(user_id, login_date) 表
WITH numbered AS (
SELECT user_id, login_date,
login_date - (ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date))::int AS grp
FROM login_log
)
SELECT user_id, MIN(login_date) AS streak_start,
MAX(login_date) AS streak_end,
COUNT(*) AS streak_days
FROM numbered
GROUP BY user_id, grp
HAVING COUNT(*) >= 3; -- 连续登录 3 天以上

模式 3:占比与累计占比(帕累托分析)

-- 每个商品的销售额占比和累计占比
WITH product_revenue AS (
SELECT p.name, SUM(oi.quantity * oi.unit_price) AS revenue
FROM order_items oi
JOIN products p ON p.id = oi.product_id
GROUP BY p.id, p.name
)
SELECT name, revenue,
ROUND(revenue / SUM(revenue) OVER () * 100, 1) AS pct,
ROUND(SUM(revenue) OVER (ORDER BY revenue DESC) / SUM(revenue) OVER () * 100, 1) AS cum_pct
FROM product_revenue
ORDER BY revenue DESC;

练习

  1. ROW_NUMBER 排名 -- 给所有用户按余额降序编号,显示 username,balance,排名
  2. 窗口内聚合 -- 查询每个用户的订单,显示订单金额和该用户所有订单的平均金额
  3. DENSE_RANK -- 给商品按价格降序排名,观察并列时的行为
  4. 分组 Top-1 -- 每个分类内,找出价格最高的商品(窗口函数 + 外层过滤)
  5. LAG 环比 -- 显示每笔订单的金额,该用户的前一笔订单金额,以及金额差
  6. 累计求和 -- 计算按时间顺序的订单累计金额,只统计 status = 'done'
  7. PERCENT_RANK -- 计算每个商品在其分类内的价格百分比排名
  8. 移动平均 -- 对每个用户的订单,计算当前单和前 2 单的平均金额(3 单移动平均)
  9. 帕累托分析 -- 找出"消费金额排名前 50%"的用户(累计消费占总消费的前 50%)
参考答案
-- 1. 按余额排名
SELECT username, balance,
ROW_NUMBER() OVER (ORDER BY balance DESC) AS rank
FROM users;
-- eve(15000) #1, alice(5000) #2, grace(2200) #3 ...

-- 2. 每笔订单 + 用户平均
SELECT u.username, o.total,
ROUND(AVG(o.total) OVER (PARTITION BY o.user_id), 2) AS user_avg
FROM orders o
JOIN users u ON u.id = o.user_id
ORDER BY u.username, o.created_at;

-- 3. DENSE_RANK 排名
SELECT name, price,
DENSE_RANK() OVER (ORDER BY price DESC) AS drnk
FROM products;

-- 4. 每分类最贵商品
SELECT category, product, price FROM (
SELECT c.name AS category, p.name AS product, p.price,
ROW_NUMBER() OVER (PARTITION BY c.id ORDER BY p.price DESC) AS rn
FROM products p
JOIN categories c ON c.id = p.category_id
) t WHERE rn = 1;

-- 5. LAG 前一单金额
SELECT u.username, o.created_at::date, o.total,
LAG(o.total) OVER (PARTITION BY o.user_id ORDER BY o.created_at) AS prev_total,
o.total - LAG(o.total) OVER (PARTITION BY o.user_id ORDER BY o.created_at) AS diff
FROM orders o
JOIN users u ON u.id = o.user_id
ORDER BY u.username, o.created_at;

-- 6. 累计金额
SELECT id, created_at::date, total,
SUM(total) OVER (ORDER BY created_at) AS running_total
FROM orders
WHERE status = 'done'
ORDER BY created_at;

-- 7. 分类内价格百分比排名
SELECT c.name AS category, p.name, p.price,
ROUND(PERCENT_RANK() OVER (PARTITION BY c.id ORDER BY p.price)::numeric, 2) AS pct_rank
FROM products p
JOIN categories c ON c.id = p.category_id
ORDER BY category, pct_rank;

-- 8. 每用户 3 单移动平均
SELECT u.username, o.created_at::date, o.total,
ROUND(AVG(o.total) OVER (
PARTITION BY o.user_id
ORDER BY o.created_at
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
), 2) AS moving_avg_3
FROM orders o
JOIN users u ON u.id = o.user_id
ORDER BY u.username, o.created_at;

-- 9. 帕累托分析:消费前 50%
WITH user_spending AS (
SELECT user_id, SUM(total) AS total_spent
FROM orders WHERE status = 'done'
GROUP BY user_id
),
ranked AS (
SELECT u.username, us.total_spent,
SUM(us.total_spent) OVER (ORDER BY us.total_spent DESC) AS cum_spent,
SUM(us.total_spent) OVER () AS grand_total
FROM user_spending us
JOIN users u ON u.id = us.user_id
)
SELECT username, total_spent,
ROUND(cum_spent / grand_total * 100, 1) AS cum_pct
FROM ranked
WHERE cum_spent <= grand_total * 0.5
OR total_spent = (SELECT MAX(total_spent) FROM user_spending)
ORDER BY total_spent DESC;

第 2 题 PARTITION BY user_id 让 AVG 在每个用户的窗口内计算,不影响行数. 第 4 题"先排名再过滤"是最经典的窗口函数模式. 第 5 题 PARTITION BY user_id 确保 LAG 不跨用户. 第 8 题 ROWS BETWEEN 2 PRECEDING AND CURRENT ROW 精确控制帧范围. 第 9 题累计占比用 SUM OVER (ORDER BY ... DESC) 实现.