[小技巧28]MySQL 窗口函数详解:原理、用法与最佳实践

一、什么是窗口函数?

窗口函数(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 ROWUNBOUNDED

但我们重点看 相同值如何处理

执行过程(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 仅支持部分数值类型,常用 UNBOUNDEDCURRENT 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)实际应用rangerows

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,但没指定 ROWSRANGE,默认使用:

    sql 复制代码
    RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  • 如果你尝试写 RANGE BETWEEN 1 PRECEDING AND CURRENT ROW,而 ORDER BY 列是 INTMySQL 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 BYORDER BY 的列上建立合适索引(尤其是复合索引)。

可通过 EXPLAIN ANALYZE(MySQL 8.0.18+)观察窗口函数执行细节。

五、常见误区与注意事项

  1. 不能在 WHEREGROUP BYHAVING 中使用窗口函数

    → 因为其在 SELECT 阶段之后才计算(SQL 执行顺序:FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY)。

  2. ORDER BYOVER() 中 ≠ 查询最终排序

    → 如需最终排序,仍需在外层写 ORDER BY

  3. RANGE vs ROWS 语义差异巨大

    → 例如相同 salary 值的多行,在 RANGE 下会被视为"同一位置",影响累计计算结果。

  4. 窗口函数不能嵌套

    → 不能写 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 名(含并列)"的场景。
相关推荐
e***98572 小时前
MySQL数据可视化全流程解析
数据库·mysql·信息可视化
2301_765715142 小时前
数据可视化:MySQL管理的视觉助手
数据库·mysql·信息可视化
瀚高PG实验室2 小时前
使用安全版数据库开启ssl加密后jdbc写法
数据库·安全·ssl·瀚高数据库
熏鱼的小迷弟Liu2 小时前
【Redis】如何用Redis实现分布式Session?
数据库·redis·分布式
青~2 小时前
数据库备份
数据库
jason.zeng@15022072 小时前
基于数据库 + JWT 的 Spring Boot Security 完整示例
数据库·spring boot·oracle
橘橙黄又青2 小时前
【无标题】
mysql
DBA小马哥2 小时前
金仓数据库在时序数据迁移中的应用与改造工作量解析
数据库·mongodb
焦糖布丁的午夜2 小时前
数据库大王mysql---linux
linux·数据库·mysql