一、什么是窗口函数?
窗口函数(Window Function) 是一种在一组相关行上执行计算的函数,但与聚合函数不同的是:
- 聚合函数会将多行"压缩"为一行(GROUP BY 后结果集行数减少);
- 窗口函数则保留原始行数,每行都返回一个基于"窗口"内数据的计算结果。
"窗口"是指通过
OVER()子句定义的一组行,这些行与当前行存在某种逻辑关系(如按某列排序后的前 N 行、相同分组内的所有行等)。
- 引入版本:MySQL 8.0(2018年4月发布)正式支持窗口函数。
- 不支持版本 :MySQL 5.7 及更早版本 不支持 窗口函数。若需类似功能,需通过自连接、变量模拟等方式实现,效率低且易出错。
- 因此,在使用窗口函数前,务必确认 MySQL 版本 ≥ 8.0。
二、窗口函数的分类
MySQL 支持以下几类窗口函数:
1. 聚合类窗口函数
复用传统聚合函数,但在窗口上下文中使用:
SUM(),AVG(),COUNT(),MAX(),MIN()
示例:
sql
SELECT
dept,
salary,
AVG(salary) OVER (PARTITION BY dept) AS avg_dept_salary
FROM employees;
每行显示该员工所在部门的平均工资,但不合并行。
2. 排名类窗口函数
用于生成排名:
ROW_NUMBER(): 连续唯一排名(1,2,3,...)RANK(): 相同值并列,跳过后续名次(1,1,3,...)DENSE_RANK(): 相同值并列,不跳过名次(1,1,2,...)
示例(部门内薪资排名):
sql
SELECT
name, dept, salary,
RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS rank_in_dept
FROM employees;
3. 偏移类(分析类)窗口函数
访问当前行前后行的数据:
LAG(expr, offset, default): 获取前第 offset 行的值LEAD(expr, offset, default): 获取后第 offset 行的值FIRST_VALUE(),LAST_VALUE(): 窗口中的首/末值NTH_VALUE(expr, n): 第 n 行的值
示例(计算员工薪资与上一名员工的差额):
sql
SELECT
name, salary,
salary - LAG(salary, 1, 0) OVER (ORDER BY salary DESC) AS diff_from_prev
FROM employees;
4. 分布类窗口函数(MySQL 8.0+ 支持有限)
PERCENT_RANK()CUME_DIST()NTILE(n):将分区分为 n 个桶
注意:MySQL 对
PERCENTILE_CONT/PERCENTILE_DISC等高级分布函数不支持,需自行实现。
三、窗口定义语法:OVER() 子句详解
完整语法:
sql
OVER (
[PARTITION BY expr, ...]
[ORDER BY expr [ASC|DESC], ...]
[window_frame_clause]
)
1. PARTITION BY
- 类似
GROUP BY,但不聚合,仅划分窗口。 - 若省略,则整个结果集为一个窗口。
2. ORDER BY
- 定义窗口内行的逻辑顺序。
- 对排名函数和偏移函数至关重要。
- 影响默认窗口帧(frame)范围。
3. 窗口帧(Window Frame)
定义窗口中哪些行参与计算。两种类型:
- ROWS :基于物理行数(如
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) - RANGE :基于值的范围(如
RANGE BETWEEN 100 PRECEDING AND CURRENT ROW)
默认帧规则(当有 ORDER BY 时):
text
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
3.1 核心区别一句话总结
ROWS:按物理行的位置来划定窗口(第几行到第几行)。RANGE:按ORDER BY 列的值的大小关系来划定窗口(值在什么范围内)。
⚠️ 注意:两者都依赖
ORDER BY子句(在OVER()中),否则没有"前后"概念。
3.2 ROWS:基于物理行偏移
** 含义**
- "前1行" = 结果集中排序后紧挨着的上一行(不管值是否相同)。
- 窗口大小由行数决定,与列的值无关。
示例数据
| id | salary |
|---|---|
| 1 | 5000 |
| 2 | 5000 |
| 3 | 6000 |
| 4 | 7000 |
查询:
sql
SELECT
id, salary,
AVG(salary) OVER (
ORDER BY salary
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
) AS avg_rows
FROM t;
执行过程(按 salary 排序后):
| id | salary | 窗口包含的行(ROWS) | avg_rows |
|---|---|---|---|
| 1 | 5000 | [5000] | 5000 |
| 2 | 5000 | [5000, 5000] | 5000 |
| 3 | 6000 | [5000, 6000] | 5500 |
| 4 | 7000 | [6000, 7000] | 6500 |
每次只看"前1行 + 当前行",共2行(除非开头不足)。
3.3 RANGE:基于值的范围
含义
- 包含所有满足
order_by_expr >= current_row.order_by_expr - 100
且 order_by_expr <= current_row.order_by_expr
的行。 - 如果
ORDER BY列有重复值 ,这些重复值全部被视为"同一位置",会一起被包含进窗口。
同样数据,改用 RANGE:
sql
SELECT
id, salary,
AVG(salary) OVER (
ORDER BY salary
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS avg_range
FROM t;
注意:MySQL 不支持
RANGE BETWEEN 100 PRECEDING ...这种带数值偏移的写法(会报错),除非你用的是非常新版本且类型匹配。所以通常用CURRENT ROW或UNBOUNDED。
但我们重点看 相同值如何处理。
执行过程(RANGE 默认行为):
| id | salary | 窗口包含的行(RANGE) | avg_range |
|---|---|---|---|
| 1 | 5000 | 所有 salary ≤ 5000 的行 → [5000, 5000] | 5000 |
| 2 | 5000 | 所有 salary ≤ 5000 的行 → [5000, 5000] | 5000 |
| 3 | 6000 | 所有 salary ≤ 6000 的行 → [5000,5000,6000] | 5333.33 |
| 4 | 7000 | 所有 salary ≤ 7000 的行 → 全部 | 5750 |
💡 关键点:id=1 和 id=2 的 salary 相同,所以在 RANGE 模式下,它们"共享"同一个窗口上下文。
3.4 对比总结表
| 特性 | ROWS |
RANGE |
|---|---|---|
| 依据 | 物理行位置(第几行) | ORDER BY 列的值大小 |
| 处理重复值 | 每行独立,即使值相同 | 所有相同值的行被视为一体 |
| 窗口稳定性 | 确定(每行窗口大小固定) | 动态(取决于值分布) |
| 适用场景 | 移动平均、滑动窗口 | 累计计算、同比分析 |
| MySQL 限制 | 支持完整语法 | PRECEDING/FOLLOWING 仅支持部分数值类型,常用 UNBOUNDED 或 CURRENT ROW |
3.5 实际应用场景举例
(1)用 ROWS:计算3日移动平均销售额
sql
AVG(sales) OVER (
ORDER BY sale_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
)
总是取最近3天(包括今天)的数据,不管销售额是否相同。
(2)用 RANGE:计算截至当前薪资等级的累计人数
sql
COUNT(*) OVER (
ORDER BY salary
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)
所有 ≤ 当前 salary 的人都被计入。如果多人 salary 相同,他们得到相同的累计值。
(3)实际应用range和rows
sql
mysql> show create table t\G
*************************** 1. row ***************************
Table: t
Create Table: CREATE TABLE `t` (
`id` int DEFAULT NULL,
`score` int DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
1 row in set (0.05 sec)
mysql> select * from t;
+------+-------+
| id | score |
+------+-------+
| NULL | 70 |
| NULL | 80 |
| NULL | 96 |
| NULL | 95 |
| NULL | 94 |
| NULL | 60 |
| NULL | 83 |
| NULL | 96 |
| NULL | 85 |
| NULL | 74 |
+------+-------+
10 rows in set (0.00 sec)
mysql> SELECT
-> id, score,
-> avg(score) OVER (ORDER BY score RANGE BETWEEN 20 PRECEDING AND CURRENT ROW) AS cnt_range,
-> avg(score) OVER (ORDER BY score ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS cnt_rows
-> FROM t;
+------+-------+-----------+----------+
| id | score | cnt_range | cnt_rows |
+------+-------+-----------+----------+
| NULL | 60 | 60.0000 | 60.0000 |
| NULL | 70 | 65.0000 | 65.0000 |
| NULL | 74 | 68.0000 | 68.0000 |
| NULL | 80 | 71.0000 | 74.6667 |
| NULL | 83 | 76.7500 | 79.0000 |
| NULL | 85 | 78.4000 | 82.6667 |
| NULL | 94 | 83.2000 | 87.3333 |
| NULL | 95 | 87.4000 | 91.3333 |
| NULL | 96 | 89.8571 | 95.0000 |
| NULL | 96 | 89.8571 | 95.6667 |
+------+-------+-----------+----------+
10 rows in set (0.00 sec)
对每一行,窗口包含所有满足score >= 当前行.score - 20 且 score <= 当前行.score的行。
举例验证(以 score = 80 为例):
- 当前行 score = 80
- 窗口下限 = 80 - 20 = 60
- 所有
score ∈ [60, 80]的行都会被包含 - 假设数据中有 60, 70, 74, 80 → 共 4 行
- 平均值 = (60 + 70 + 74 + 80) / 4 = 71.0 → 与你结果一致 ✅
再看 score = 96(最后一行):
- 下限 = 96 - 20 = 76
- 包含 score ≥ 76 且 ≤ 96 的行:74? ❌(74 < 76),所以从 80 开始
- 实际包含:80, 83, 85, 94, 95, 96, 96 → 7 行
- 平均 ≈ 89.8571 → 与你结果一致 ✅
3.6 重要提醒
在 MySQL 中:
-
如果你在
OVER()中写了ORDER BY,但没指定ROWS或RANGE,默认使用:sqlRANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -
如果你尝试写
RANGE BETWEEN 1 PRECEDING AND CURRENT ROW,而ORDER BY列是INT,MySQL 8.0 会报错:(MySQL 8.0.23+的支持,之前的不支持)"This version of MySQL doesn't yet support 'RANGE with offset on non-temporal columns'"
所以在 MySQL 中,
RANGE通常只用于:RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW(默认)- 或配合
DATE/DATETIME类型(部分支持)
因此,在 MySQL 实战中,ROWS 更常用、更可控 ;RANGE 主要用于理解"相同值应被同等对待"的语义。
总结
ROWS= "看前后几行" → 适合滑动窗口、时间序列。RANGE= "看前后多少值" → 适合按值分组累计,尤其处理重复值时行为不同。
四、典型应用场景
| 场景 | 使用函数 | 说明 |
|---|---|---|
| 部门内排名 | RANK(), DENSE_RANK() |
销售排行榜、绩效评估 |
| 累计求和 | SUM() OVER (ORDER BY date ROWS UNBOUNDED PRECEDING) |
财务流水累计、用户增长曲线 |
| 同比/环比 | LAG(), LEAD() |
时间序列分析 |
| 移动平均 | AVG() OVER (ORDER BY date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) |
平滑波动数据 |
| 分组占比 | salary / SUM(salary) OVER (PARTITION BY dept) |
计算个体在群体中的比重 |
优点:
- 逻辑清晰:避免复杂子查询或自连接。
- 可读性强:业务意图一目了然。
- 优化器友好:MySQL 8.0+ 的优化器对窗口函数有专门处理。
潜在性能问题:
- 若窗口过大(如无
PARTITION BY且数据量大),可能消耗大量内存。 ORDER BY+ 大窗口帧可能导致临时表或文件排序(Using filesort)。- 建议在
PARTITION BY和ORDER BY的列上建立合适索引(尤其是复合索引)。
可通过 EXPLAIN ANALYZE(MySQL 8.0.18+)观察窗口函数执行细节。
五、常见误区与注意事项
-
不能在
WHERE、GROUP BY、HAVING中使用窗口函数→ 因为其在
SELECT阶段之后才计算(SQL 执行顺序:FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY)。 -
ORDER BY在OVER()中 ≠ 查询最终排序→ 如需最终排序,仍需在外层写
ORDER BY。 -
RANGEvsROWS语义差异巨大→ 例如相同 salary 值的多行,在
RANGE下会被视为"同一位置",影响累计计算结果。 -
窗口函数不能嵌套
→ 不能写
ROW_NUMBER() OVER (ORDER BY ROW_NUMBER() OVER (...)),但可通过子查询实现。
六、总结
| 维度 | 内容 |
|---|---|
| 核心价值 | 在保留明细数据的同时进行跨行计算 |
| 关键语法 | func() OVER ([PARTITION BY ...] [ORDER BY ...] [frame]) |
| 必备版本 | MySQL ≥ 8.0 |
| 常用函数 | ROW_NUMBER, RANK, LAG, SUM() OVER, AVG() OVER |
| 最佳实践 | 合理分区、控制窗口大小、善用索引、避免滥用全表窗口 |
| 替代方案(<8.0) | 用户变量(@var)、自连接、临时表(不推荐,易错) |
七、面试题
面试题 1:
MySQL 中窗口函数和普通聚合函数(如 GROUP BY + SUM)有什么本质区别?请举例说明。
参考答案:本质区别在于是否改变结果集的行数。
- 聚合函数配合
GROUP BY会将多行合并为一行,结果集行数 ≤ 原始行数。 - 窗口函数对每一行都返回一个基于"窗口"的计算值,不减少行数。
例如:
sql
-- 聚合:每个部门一行
SELECT dept,SUM(salary)FROM empGROUPBY dept;
-- 窗口:每个员工一行,附带部门总薪资
SELECT name, dept, salary,SUM(salary)OVER(PARTITIONBY dept)FROM emp;
后者可用于计算"每个员工薪资占部门总薪资的比例",而前者无法做到。
面试题 2:
如何用窗口函数找出每个部门薪资最高的前 2 名员工?如果有多人并列第 2 名,是否都要返回?
**参考答案:**使用 DENSE_RANK() 可确保并列第 2 名全部返回:
sql
WITH rankedAS(
SELECT
name, dept, salary,
DENSE_RANK()OVER(PARTITIONBY deptO RDER BY salary DESC)AS drk
FROM employees
)
SELECT name, dept, salary
FROM ranked
WHERE drk<=2;
- 若用
ROW_NUMBER(),即使薪资相同也会被强制排序,可能漏掉并列者; - 若用
RANK(),虽然并列者都保留,但若第 1 名有 2 人,则下一个是第 3 名,可能跳过第 2 名; DENSE_RANK()最适合"取前 N 名(含并列)"的场景。