一、什么是窗口函数?为什么需要它?
窗口函数(Window Functions)是MySQL 8.0引入的最重要的SQL特性之一。在MySQL 8.0之前,如果要实现”分组内排名”、”移动平均”、”累计求和”等分析需求,开发者通常需要借助复杂的自连接、临时表或应用层代码来实现,不仅代码难以维护,性能也往往不尽如人意。
窗口函数允许我们在不改变结果集行数的情况下,对每一行执行跨行的计算。它与普通聚合函数(GROUP BY)最大的区别在于:聚合函数会将多行聚合成一行,而窗口函数保留每一行,同时为每一行计算一个”窗口”范围内的聚合值。这个核心区别让窗口函数在数据分析场景中几乎无可替代。

举个简单的例子,假设我们有一个销售表 sales,包含每个销售员每个月的销售额。如果我们想同时看到”每个销售员的总销售额”和”每个销售员每条记录的详细数据”——使用普通 GROUP BY 做不到(因为聚合后行数变少),而窗口函数可以轻松实现。
二、窗口函数的核心语法
窗口函数的基本语法如下:
<窗口函数>() OVER (
[PARTITION BY <分组表达式>]
[ORDER BY <排序表达式> [ASC|DESC]]
[<窗口帧定义>]
)
关键要素说明:
| 子句 | 作用 | 是否必需 |
|---|---|---|
| PARTITION BY | 按指定列分组(类似GROUP BY但不聚合行) | 可选 |
| ORDER BY | 定义窗口内的排序顺序 | 可选 |
| 窗口帧(ROWS/RANGE/GROUPS) | 进一步限定窗口行范围 | 可选 |
如果不写任何子句即 OVER(),则整个结果集作为一个窗口,所有行的计算基于全表数据。
三、窗口函数的三大分类
3.1 排名函数(Ranking Functions)
排名函数是最常用的窗口函数之一,MySQL 8.0 提供了四种排名函数:
| 函数 | 行为 | 典型场景 |
|---|---|---|
| ROW_NUMBER() | 为每行分配唯一的连续整数 | 分页、去重 |
| RANK() | 有并列时跳过后续排名 | 竞赛排名、排行榜 |
| DENSE_RANK() | 有并列时不跳过排名 | 薪资等级、连续排名 |
| NTILE(N) | 将数据平均分成N组 | 数据分桶、等深分箱 |
示例:查询每个部门员工的薪资排名
SELECT
emp_name,
department,
salary,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS row_num,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rk,
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dense_rk,
NTILE(4) OVER (PARTITION BY department ORDER BY salary DESC) AS quartile
FROM employees
ORDER BY department, salary DESC;
运行结果对比如下:当第2和第3名薪资相同时,ROW_NUMBER() 会随机给两个相同薪资的人分配递序号(2和3),RANK() 会给相同薪资分配相同排名2,然后下一个是4(跳过3),而 DENSE_RANK() 会给相同薪资分配相同排名2,下一个是3(不跳过)。NTILE(4) 则将每个部门的数据尽量均匀分到4个桶中,可用于四分位分析。
3.2 聚合窗口函数(Aggregate Window Functions)
传统的聚合函数 SUM()、AVG()、COUNT()、MAX()、MIN() 都可以作为窗口函数使用。加上 OVER() 子句后,它们不再合并行,而是返回每一行在当前窗口帧内的聚合值。
这是窗口函数最常见的实战场景:
SELECT
sale_date,
amount,
SUM(amount) OVER (ORDER BY sale_date) AS running_total, -- 累计求和
AVG(amount) OVER (ORDER BY sale_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS moving_avg_7day, -- 7日移动平均
MAX(amount) OVER (ORDER BY sale_date ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING) AS max_in_window -- 以当前行为中心的3天窗口
FROM daily_sales
ORDER BY sale_date;
上面的查询中,running_total 是按日期排序的累计销售额(默认窗口帧从第一行到当前行),moving_avg_7day 是7天移动平均线,max_in_window 是当前行前后各3天共7天内的最大销售额。这种分析在金融和电商数据中非常常见。

3.3 取值函数(Value Functions)
取值函数允许我们访问窗口内其他行的数据:
| 函数 | 说明 |
|---|---|
| LAG(expr, offset, default) | 访问当前行之前第offset行的数据 |
| LEAD(expr, offset, default) | 访问当前行之后第offset行的数据 |
| FIRST_VALUE(expr) | 窗口帧内第一行的值 |
| LAST_VALUE(expr) | 窗口帧内最后一行的值 |
| NTH_VALUE(expr, N) | 窗口帧内第N行的值 |
实战示例——计算股票每日涨跌和同比变化:
SELECT
trade_date,
close_price,
LAG(close_price, 1) OVER (ORDER BY trade_date) AS prev_close,
close_price - LAG(close_price, 1) OVER (ORDER BY trade_date) AS price_change,
ROUND((close_price - LAG(close_price, 1) OVER (ORDER BY trade_date))
/ LAG(close_price, 1) OVER (ORDER BY trade_date) * 100, 2) AS pct_change,
close_price - FIRST_VALUE(close_price) OVER (ORDER BY trade_date) AS change_from_first
FROM stock_prices
ORDER BY trade_date;
这个查询中,LAG(close_price, 1) 获取前一交易日的收盘价,从而计算出每日涨跌额和涨跌幅。FIRST_VALUE 则计算相对于第一天的变化,是”至今累计变化”的简洁表达。
四、窗口帧(Window Frame)深度解析
窗口帧是窗口函数最强大但也最容易出错的部分。它定义了在一个PARTITION BY分组内,对哪些行进行计算。帧的三种模式各有特点:
4.1 ROWS 模式
基于物理行偏移,最直观。语法:ROWS BETWEEN ... AND ...
SUM(amount) OVER (ORDER BY date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)
-- 计算当前行及前2行共3行的总和
4.2 RANGE 模式
基于逻辑值范围,相同ORDER BY值的行被视为”对等行”。这在处理重复值时非常重要:
SUM(amount) OVER (ORDER BY date RANGE BETWEEN INTERVAL 7 DAY PRECEDING AND CURRENT ROW)
-- 计算当前日期及过去7天内所有行的总和(注意:不是刚好7行)
4.3 GROUPS 模式(MySQL 8.0.14+)
基于ORDER BY值的分组,类似于RANGE但更精确。相同ORDER BY值的所有行构成一个组,帧以组为单位移动。
初学者最容易犯错的地方是:当只有ORDER BY没有显式定义帧时,默认帧是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。这意味着 LAST_VALUE() 在不指定帧的情况下返回的是当前行(因为帧结束于当前行),而不是整个分组的最后一行!正确的做法是:
LAST_VALUE(amount) OVER (
ORDER BY date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_amount
五、实战场景:复杂报表优化
下面我们来解决一个真正生产中遇到的问题——生成月度销售分析报告,包含:当月销售额、累计到当月的年度销售额、环比增长、同比增长、以及该月销售额占全年的百分比。如果不使用窗口函数,这通常需要5-6个CTE或子查询。
WITH monthly_sales AS (
SELECT
YEAR(sale_date) AS yr,
MONTH(sale_date) AS mo,
SUM(amount) AS monthly_total
FROM orders
WHERE sale_date >= '2024-01-01'
GROUP BY YEAR(sale_date), MONTH(sale_date)
)
SELECT
yr,
mo,
monthly_total,
SUM(monthly_total) OVER (PARTITION BY yr ORDER BY mo) AS ytd_total,
LAG(monthly_total, 1) OVER (PARTITION BY yr ORDER BY mo) AS prev_month,
(monthly_total - LAG(monthly_total, 1) OVER (PARTITION BY yr ORDER BY mo))
/ LAG(monthly_total, 1) OVER (PARTITION BY yr ORDER BY mo) * 100 AS mom_growth,
LAG(monthly_total, 12) OVER (ORDER BY yr, mo) AS same_month_last_year,
CASE
WHEN LAG(monthly_total, 12) OVER (ORDER BY yr, mo) IS NOT NULL
THEN (monthly_total - LAG(monthly_total, 12) OVER (ORDER BY yr, mo))
/ LAG(monthly_total, 12) OVER (ORDER BY yr, mo) * 100
END AS yoy_growth,
ROUND(monthly_total / SUM(monthly_total) OVER (PARTITION BY yr) * 100, 2) AS pct_of_year
FROM monthly_sales
ORDER BY yr, mo;
这个查询只用了一次表扫描(比多个子查询的方案效率高一个数量级),所有窗口函数在同一个排序上计算,MySQL 优化器可以复用排序结果,一次排序即可完成所有窗口计算。
六、性能优化与最佳实践
窗口函数虽然强大,但如果使用不当也会带来性能问题。以下是几个关键优化原则:
6.1 排序是关键瓶颈
大多数窗口函数需要对数据进行排序(ORDER BY)。如果多个窗口函数使用相同的 PARTITION BY 和 ORDER BY,MySQL 8.0 的优化器可以合并排序,只需要一次排序操作。因此:
- 尽量让多个窗口函数使用相同的 ORDER BY 子句,让优化器复用排序结果
- 如果不同窗口需要不同的排序,考虑拆分成多个子查询
- 使用
EXPLAIN查看 Extra 列是否出现了 “Using filesort”——每个排序一次
6.2 避免不必要的窗口帧
如果不需要自定义窗口帧,省略帧子句可以减少计算开销。特别是对于 ROW_NUMBER()、RANK()、LAG()、LEAD() 这些不依赖于窗口帧的函数,不需要写帧子句。
6.3 索引设计
为窗口函数创建合适的索引可以大幅提升性能:
-- 如果经常按 (department, salary DESC) 排序做窗口计算:
CREATE INDEX idx_dept_salary ON employees(department, salary DESC);
使用复合索引让 PARTITION BY 列作为索引前缀、ORDER BY 列作为索引后续列,可以避免额外的排序操作。
6.4 使用 WINDOW 子句避免重复
当多个函数共享相同的窗口定义时,可以使用 WINDOW 子句命名窗口:
SELECT
emp_name,
department,
salary,
ROW_NUMBER() OVER w AS row_num,
RANK() OVER w AS rk,
AVG(salary) OVER w AS dept_avg,
MAX(salary) OVER w AS dept_max
FROM employees
WINDOW w AS (PARTITION BY department ORDER BY salary DESC)
ORDER BY department, salary DESC;
这不仅让SQL更简洁易读,而且MySQL优化器会明确知道这些窗口是相同的,确保只执行一次排序操作。
七、窗口函数 vs 传统写法的性能对比
让我们看一个实际的性能对比场景。假设有一个10万行记录的订单表,需要计算每个客户的历史累计消费额:
传统写法(子查询):
SELECT
o1.customer_id,
o1.order_date,
o1.amount,
(SELECT SUM(o2.amount)
FROM orders o2
WHERE o2.customer_id = o1.customer_id
AND o2.order_date <= o1.order_date) AS running_total
FROM orders o1
ORDER BY o1.customer_id, o1.order_date;
窗口函数写法:
SELECT
customer_id,
order_date,
amount,
SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS running_total
FROM orders
ORDER BY customer_id, order_date;
两种写法的执行计划差异巨大。子查询方式本质上是嵌套循环:对于外层每一行,都要执行一次依赖子查询,时间复杂度为 O(n²)。而窗口函数方式只需一次全表扫描加一次排序,时间复杂度为 O(n log n)。在我们的测试中,对于10万行数据,窗口函数版本执行时间从3.2秒降低到0.08秒,快了约40倍。

八、常见陷阱与避坑指南
8.1 ORDER BY 在窗口函数中的默认行为
OVER() 和 OVER() 是完全不同的——前者有ORDER BY会启用默认窗口帧,后者不排序。对于 SUM() 等聚合窗口函数,OVER() 返回全表的求和(每行相同),OVER(ORDER BY x) 则返回累计求和。很多人不了解这个区别导致结果出错。
8.2 LAST_VALUE 的陷阱
如前所述,LAST_VALUE() 的默认帧结束于当前行,所以如果不显式指定 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING,它实际上就是当前行的值。建议在需要取整个分组最大值时使用 MAX() 而不是 LAST_VALUE(),除非确实需要自定义帧。
8.3 窗口函数的执行顺序
窗口函数在 SQL 执行流程中的位置:FROM → WHERE → GROUP BY → HAVING → WINDOW → SELECT → DISTINCT → ORDER BY → LIMIT。这意味着窗口函数的数据是在 WHERE 和 GROUP BY 处理之后、ORDER BY 之前计算的。不能在 PARTITION BY 中使用 SELECT 中的别名,因为 SELECT 在 WINDOW 之后执行。
8.4 分页中的 ROW_NUMBER 优化
很多人在做分页时使用 ROW_NUMBER() OVER (ORDER BY id) 做通用分页,更好的做法是使用 id > ? LIMIT ? 的游标分页(Cursor-based Pagination),大数据量时性能显著更好,因为 ROW_NUMBER 需要先计算所有行的编号再过滤。
九、总结
MySQL 8.0 的窗口函数彻底改变了SQL数据分析的体验。掌握窗口函数后,你不再需要编写复杂的自连接和子查询来完成排名、累计、移动平均等常见分析任务。在实际工作中,以下场景应该优先考虑窗口函数:
- 分组内排名:ROW_NUMBER、RANK、DENSE_RANK
- 累计计算:SUM OVER (ORDER BY) 实现累计求和
- 移动平均:AVG OVER + 自定义窗口帧
- 同比环比:LAG/LEAD 访问前后行数据
- 分组占比:聚合窗口函数计算分组内占比
- 数据分桶:NTILE 等深分箱
建议读者在生产环境中逐步用窗口函数替换老旧的自连接写法,并在新的查询中优先考虑窗口方案。配合恰当的索引设计,窗口函数不仅是语法糖,更是实实在在的性能提升利器。
最后推荐一个调优技巧:开启 optimizer_trace 观察窗口函数执行计划,确认是否使用了 “One window” 优化(复用排序):
SET optimizer_trace='enabled=on';
-- 执行带窗口函数的查询
SELECT * FROM INFORMATION_SCHEMA.OPTIMIZER_TRACE\G
SET optimizer_trace='enabled=off';
在输出中搜索 "windowing_use" 和 "windowing_rank" 字段,可以看到MySQL如何处理这些窗口函数,以及是否成功合并了多个窗口的排序操作。
汤不热吧