MySQL 8.0窗口函数性能优化实战:排序与聚合的执行计划分析

窗口函数如何改变复杂报表的编写方式

MySQL 8.0引入窗口函数(Window Functions)已有数年,但很多开发者仍习惯用GROUP BY + 子查询 + 临时表的方式处理复杂报表。窗口函数的核心价值在于:它能在不改变结果行数的前提下,为每行附加聚合计算结果。这意味着一次查询即可同时获取明细和汇总数据,避免了自连接和多层嵌套子查询带来的性能灾难。

窗口函数执行顺序与逻辑定位

SQL的执行顺序是理解窗口函数的关键。标准SQL的逻辑执行顺序为:

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

窗口函数在GROUP BY和HAVING之后执行,这意味着:

1. WHERE子句中不能引用窗口函数的结果——窗口函数尚未计算

2. 窗口函数的PARTITION BY可以使用GROUP BY后的列

3. 如果需要过滤窗口函数的结果,必须用外层查询包裹

-- 错误:WHERE中引用窗口函数
SELECT *, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
FROM employees
WHERE rn <= 3;  -- ERROR: Unknown column 'rn'

-- 正确:外层过滤
SELECT * FROM (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
  FROM employees
) t
WHERE rn <= 3;

排名函数对比:ROW_NUMBER vs RANK vs DENSE_RANK

三个排名函数在并列场景下行为不同:

| salary | ROW_NUMBER | RANK | DENSE_RANK |
|--------|-----------|------|-----------|
| 20000  | 1         | 1    | 1         |
| 18000  | 2         | 2    | 2         |
| 18000  | 3         | 2    | 2         |
| 15000  | 4         | 4    | 3         |

ROW_NUMBER始终递增,适合取每组Top N且不允许并列;RANK跳过并列名次后的位置(1,2,2,4);DENSE_RANK不跳过(1,2,2,3),适合生成连续的分组序号。大多数报表场景用的是DENSE_RANK。

执行计划分析:窗口函数的性能瓶颈在哪

通过EXPLAIN ANALYZE可以直观看到窗口函数的执行代价。以下查询获取每个部门薪资前3名:

EXPLAIN ANALYZE
SELECT * FROM (
  SELECT dept, name, salary,
         DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
  FROM employees
) t
WHERE rn <= 3;

典型输出:

-> Filter: (t.rn <= 3)  (cost=... rows=...)
   -> Table scan on t  (cost=... rows=...)
     -> Window: DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC)
        -> Sort: dept, salary DESC  (cost=... rows=100000)
           -> Table scan on employees  (cost=... rows=100000)

性能瓶颈在Sort阶段。MySQL需要对全表按PARTITION BY + ORDER BY排序,内存开销为O(N log N)。当employees表超过100万行,排序可能溢出到磁盘临时文件,查询耗时从毫秒级跳到秒级。

优化一:利用索引避免排序

如果employees表在(dept, salary)上有复合索引,MySQL可以直接利用索引的有序性,跳过排序步骤:

ALTER TABLE employees ADD INDEX idx_dept_salary (dept, salary DESC);

添加索引后执行计划变化:

-> Window: DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC)
   -> Index scan on idx_dept_salary  (cost=... rows=100000)

Sort节点消失,直接走索引扫描。实测100万行表,查询耗时从1.2秒降到80ms,效果显著。

但要注意:索引的排序方向必须与窗口函数的ORDER BY完全匹配。如果窗口函数是ORDER BY salary DESC而索引是(dept, salary ASC),MySQL无法利用索引的逆序扫描来避免排序(MySQL 8.0不支持降序索引的逆向扫描优化)。

优化二:减少窗口函数的扫描范围

如果只需要Top 3结果,没必要对全表执行窗口函数。可以在内层查询中先按PARTITION BY分组并利用索引限制扫描范围:

-- 当departments表较小时,逐部门查询
SELECT e.* FROM departments d
JOIN LATERAL (
  SELECT dept, name, salary
  FROM employees e
  WHERE e.dept = d.id
  ORDER BY e.salary DESC
  LIMIT 3
) e ON 1=1;

LATERAL JOIN让每个部门独立执行子查询,每个子查询走(dept, salary DESC)索引只扫描3行,总扫描行数=部门数×3。对比窗口函数的全表扫描+排序,当Top N远小于分组大小时,LATERAL JOIN的性能更优。

实测对比(100万行、50个部门、取Top 3):

| 方案            | 扫描行数 | 执行时间 | 内存峰值 |
|----------------|---------|---------|---------|
| 窗口函数+索引   | 1000000 | 80ms    | 120MB   |
| LATERAL JOIN   | 150     | 15ms    | 8MB     |
| 窗口函数(无索引)| 1000000 | 1200ms  | 350MB   |

优化三:滑动窗口聚合的帧范围控制

累计求和、移动平均等场景需要用到ROWS BETWEEN子句控制窗口帧:

-- 每日营收与7日移动平均
SELECT date, revenue,
       AVG(revenue) OVER (
         ORDER BY date
         ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
       ) AS moving_avg_7d,
       SUM(revenue) OVER (
         ORDER BY date
         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS cumulative_sum
FROM daily_revenue
ORDER BY date;

ROWS帧按物理行号定位,RANGE帧按逻辑值定位。对于日期列,RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW更准确(跳过缺失日期),但MySQL 8.0对RANGE帧的优化不如ROWS帧——RANGE帧需要全帧扫描,ROWS帧可以增量计算。

-- ROWS帧:增量计算,O(N)
SUM(revenue) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)

-- RANGE帧:每行重新扫描窗口内所有行,O(N×W)
SUM(revenue) OVER (ORDER BY date RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW)

数据连续无缺失时优先使用ROWS帧。只有当日志数据存在间断(如周末无交易)且需要精确的日历窗口时,才用RANGE帧。

生产环境排查:窗口函数慢查询定位

MySQL 8.0的performance_schema可以追踪窗口函数的内存排序溢出:

SELECT EVENT_ID, SQL_TEXT, TIMER_WAIT/1000000000 AS duration_sec
FROM performance_schema.events_statements_summary_by_digest
WHERE SQL_TEXT LIKE '%OVER%'
ORDER BY TIMER_WAIT DESC
LIMIT 10;

当Sort_merge_passes大于0时,说明排序溢出到磁盘,需要检查是否有可用索引或考虑减少PARTITION BY的分组数量。在分库分表环境下,窗口函数只能在每个分片内执行,跨分片的排名需要应用层汇总,这是窗口函数在分布式数据库中的根本局限。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/mysql80-chuang-kou-han-shu-xing-neng-you-hua-shi-zhan-pai/

(0)
小编小编
上一篇 18小时前
下一篇 18小时前

相关推荐