MySQL Window Functions: RANGE vs. ROWS – 性能深度剖析
大家好,今天我们来深入探讨MySQL窗口函数中的 RANGE
和 ROWS
关键字,重点关注它们在性能方面的差异。窗口函数是MySQL 8.0 引入的强大特性,允许我们在查询结果的特定“窗口”或“分区”上执行计算,而无需像传统的聚合函数那样进行分组。 RANGE
和 ROWS
都是定义窗口框架的关键组成部分,但它们的工作方式截然不同,直接影响着查询的执行效率。
1. 窗口函数基础回顾
在深入研究 RANGE
和 ROWS
之前,我们先快速回顾一下窗口函数的基本概念。一个典型的窗口函数包含以下几个部分:
- 函数名称: 例如
ROW_NUMBER()
,RANK()
,SUM()
,AVG()
等。 - OVER() 子句: 定义窗口函数的行为。
- PARTITION BY 子句 (可选): 将结果集分成多个分区,窗口函数将在每个分区内独立计算。
- ORDER BY 子句 (可选): 定义每个分区内行的顺序。
- FRAME 子句 (可选): 定义当前行的窗口框架,决定了哪些行参与到当前行的计算中。这正是
RANGE
和ROWS
发挥作用的地方。
2. ROWS
关键字详解
ROWS
关键字用于基于物理行数定义窗口框架。它指定了当前行之前和之后多少行参与计算。ROWS
框架是基于行号的,也就是说,它关心的是行在结果集中的物理位置。
ROWS
子句的常见语法如下:
ROWS BETWEEN <start_boundary> AND <end_boundary>
其中 <start_boundary>
和 <end_boundary>
可以是以下值之一:
UNBOUNDED PRECEDING
: 窗口从分区的第一行开始。n PRECEDING
: 窗口从当前行之前的第 n 行开始。CURRENT ROW
: 窗口从当前行开始。n FOLLOWING
: 窗口从当前行之后的第 n 行开始。UNBOUNDED FOLLOWING
: 窗口到分区的最后一行结束。
例如,以下 SQL 语句使用 ROWS
计算每个员工及其前后各一个员工的薪资总和:
CREATE TABLE employees (
id INT PRIMARY KEY,
department_id INT,
salary DECIMAL(10, 2)
);
INSERT INTO employees (id, department_id, salary) VALUES
(1, 1, 50000.00),
(2, 1, 60000.00),
(3, 1, 70000.00),
(4, 2, 55000.00),
(5, 2, 65000.00),
(6, 2, 75000.00);
SELECT
id,
department_id,
salary,
SUM(salary) OVER (PARTITION BY department_id ORDER BY salary ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) AS salary_sum_rows
FROM
employees;
查询结果:
id | department_id | salary | salary_sum_rows |
---|---|---|---|
1 | 1 | 50000.00 | 110000.00 |
2 | 1 | 60000.00 | 180000.00 |
3 | 1 | 70000.00 | 130000.00 |
4 | 2 | 55000.00 | 120000.00 |
5 | 2 | 65000.00 | 195000.00 |
6 | 2 | 75000.00 | 140000.00 |
3. RANGE
关键字详解
RANGE
关键字则不同,它基于 ORDER BY
子句中指定的列的值来定义窗口框架。它指定了与当前行值在一定范围内的所有行都参与计算。 RANGE
框架是基于值的,它关心的是与当前行值相近的程度。
RANGE
子句的常见语法如下:
RANGE BETWEEN <start_boundary> AND <end_boundary>
其中 <start_boundary>
和 <end_boundary>
可以是以下值之一:
UNBOUNDED PRECEDING
: 窗口从分区的第一行开始。n PRECEDING
: 窗口从ORDER BY
列的值小于或等于当前行ORDER BY
列的值减去 n 的第一行开始。CURRENT ROW
: 窗口从ORDER BY
列的值等于当前行ORDER BY
列的值的行开始。n FOLLOWING
: 窗口到ORDER BY
列的值大于或等于当前行ORDER BY
列的值加上 n 的最后一行结束。UNBOUNDED FOLLOWING
: 窗口到分区的最后一行结束。
与 ROWS
相比,RANGE
的 <start_boundary>
和 <end_boundary>
中的 n
代表的是值的范围,而不是行数。
例如,以下 SQL 语句使用 RANGE
计算每个员工及其薪资上下浮动 10000 的员工的薪资总和:
SELECT
id,
department_id,
salary,
SUM(salary) OVER (PARTITION BY department_id ORDER BY salary RANGE BETWEEN 10000 PRECEDING AND 10000 FOLLOWING) AS salary_sum_range
FROM
employees;
查询结果:
id | department_id | salary | salary_sum_range |
---|---|---|---|
1 | 1 | 50000.00 | 110000.00 |
2 | 1 | 60000.00 | 180000.00 |
3 | 1 | 70000.00 | 130000.00 |
4 | 2 | 55000.00 | 120000.00 |
5 | 2 | 65000.00 | 195000.00 |
6 | 2 | 75000.00 | 140000.00 |
在这个例子中,RANGE BETWEEN 10000 PRECEDING AND 10000 FOLLOWING
意味着对于每一行,窗口框架包括所有薪资在 salary - 10000
到 salary + 10000
范围内的行。
重要限制:
- 在使用
RANGE
时,ORDER BY
子句必须只包含一列。这是因为RANGE
需要一个单一的排序依据来确定值的范围。
4. RANGE
vs. ROWS
:性能差异的根源
RANGE
和 ROWS
的性能差异主要源于它们定义窗口框架的方式。
-
索引利用: 当
ORDER BY
子句中的列上存在索引时,ROWS
通常能够更好地利用索引。因为ROWS
只需要根据行号来定位窗口框架内的行,索引可以帮助快速跳到目标行。 而对于RANGE
,即使ORDER BY
列上有索引,数据库也可能需要进行范围扫描,特别是当范围较大或者符合条件的行数较多时,索引的优势会减弱。 -
数据分布:
RANGE
的性能对数据分布非常敏感。如果ORDER BY
列的值分布不均匀,例如存在大量重复值,那么RANGE
可能会导致窗口框架包含大量的行,从而影响性能。ROWS
则不受数据分布的影响,因为它只关心行号。 -
计算复杂度: 在某些情况下,
RANGE
的计算复杂度可能高于ROWS
。 例如,当需要计算窗口框架内的平均值或总和时,RANGE
需要考虑所有符合条件的行,而ROWS
只需要考虑指定行数内的行。 -
并行性:
ROWS
在某些情况下更容易并行处理。因为每个分区的行数是固定的,可以更容易地将任务分配给多个线程或进程。RANGE
的并行性则受到数据分布的影响,可能需要更复杂的调度策略。
5. 性能测试:用实例说话
为了更直观地了解 RANGE
和 ROWS
的性能差异,我们进行一些简单的性能测试。
测试环境:
- MySQL 8.0
- 单机环境
- 测试数据:包含 100 万条记录的
orders
表,包含order_id
,customer_id
,order_date
,amount
等字段。order_date
列上有索引。
测试用例 1:计算每个订单及其前后 10 天订单的金额总和 (RANGE)
SELECT
order_id,
order_date,
amount,
SUM(amount) OVER (ORDER BY order_date RANGE BETWEEN INTERVAL 10 DAY PRECEDING AND INTERVAL 10 DAY FOLLOWING) AS amount_sum_range
FROM
orders;
测试用例 2:计算每个订单及其前后 10 行订单的金额总和 (ROWS)
SELECT
order_id,
order_date,
amount,
SUM(amount) OVER (ORDER BY order_date ROWS BETWEEN 10 PRECEDING AND 10 FOLLOWING) AS amount_sum_rows
FROM
orders;
测试结果 (仅供参考,实际结果可能因环境而异):
测试用例 | 执行时间 (秒) |
---|---|
RANGE | 15 |
ROWS | 8 |
在这个例子中,ROWS
的性能明显优于 RANGE
。 这是因为 ROWS
可以更好地利用 order_date
列上的索引,并且计算复杂度更低。
测试用例 3:计算每个订单及其前后 10 天订单的平均金额 (RANGE) – 数据倾斜
假设 orders
表中存在大量订单在同一天发生,例如促销日。
SELECT
order_id,
order_date,
amount,
AVG(amount) OVER (ORDER BY order_date RANGE BETWEEN INTERVAL 10 DAY PRECEDING AND INTERVAL 10 DAY FOLLOWING) AS amount_avg_range
FROM
orders;
测试用例 4:计算每个订单及其前后 10 行订单的平均金额 (ROWS)
SELECT
order_id,
order_date,
amount,
AVG(amount) OVER (ORDER BY order_date ROWS BETWEEN 10 PRECEDING AND 10 FOLLOWING) AS amount_avg_rows
FROM
orders;
测试结果 (数据倾斜情况下):
测试用例 | 执行时间 (秒) |
---|---|
RANGE | 30 |
ROWS | 8 |
在数据倾斜的情况下,RANGE
的性能急剧下降。 这是因为大量的订单落在同一天,导致窗口框架包含大量的行,增加了计算复杂度。 ROWS
则不受影响,因为它只关心行号。
结论:
通过以上测试,我们可以得出以下结论:
- 在数据分布均匀的情况下,
ROWS
通常比RANGE
具有更好的性能,因为它可以更好地利用索引,并且计算复杂度更低。 - 当数据倾斜时,
RANGE
的性能会受到严重影响,因为它需要处理大量的重复值。 ROWS
的性能相对稳定,不受数据分布的影响。
6. 如何选择 RANGE
和 ROWS
选择 RANGE
还是 ROWS
取决于具体的应用场景和数据特点。
- 如果需要基于值的范围来定义窗口框架,并且
ORDER BY
列上的值分布均匀,那么可以考虑使用RANGE
。 例如,计算某个时间段内的移动平均值,或者计算某个价格范围内的产品销量总和。 - 如果需要基于行数来定义窗口框架,或者
ORDER BY
列上的值分布不均匀,那么应该优先选择ROWS
。 例如,计算每个用户最近 10 次交易的平均金额,或者计算每个产品销量排名前 10 的用户的购买金额总和。 - 如果
ORDER BY
子句中存在多个列,那么只能使用ROWS
。RANGE
只能用于单个排序列。
一些建议:
- 在选择
RANGE
和ROWS
之前,先了解数据的分布情况。可以使用ANALYZE TABLE
命令来更新表的统计信息,以便优化器做出更合理的选择。 - 在性能敏感的场景中,对
RANGE
和ROWS
进行实际的性能测试,选择性能更好的方案。 - 考虑使用其他优化手段,例如索引优化、查询重写等,来提高窗口函数的整体性能。
7. 性能优化策略
除了选择合适的 RANGE
或 ROWS
之外,还可以采取以下措施来优化窗口函数的性能:
- 索引优化: 确保
ORDER BY
子句中的列上有合适的索引。 - 减少数据量: 尽量在窗口函数之前过滤掉不需要的数据,例如使用
WHERE
子句。 - 避免不必要的排序: 如果不需要排序,可以省略
ORDER BY
子句。 - 使用物化视图: 对于复杂的窗口函数,可以考虑使用物化视图来预先计算结果。
- 调整 MySQL 配置: 可以调整 MySQL 的配置参数,例如
sort_buffer_size
,read_rnd_buffer_size
等,来提高排序和读取性能。
8. 案例分析:电商订单分析
假设我们有一个电商平台,需要分析用户的订单数据。
需求 1:计算每个用户每个订单的累计消费金额。
SELECT
customer_id,
order_id,
order_date,
amount,
SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cumulative_amount
FROM
orders;
这里使用 ROWS
来计算每个用户的累计消费金额,窗口框架从用户的第一个订单开始,到当前订单结束。 因为关心的是累计的订单,所以用行数来定义窗口框架更合适。
需求 2:计算每个用户过去 30 天内的平均消费金额。
SELECT
customer_id,
order_id,
order_date,
amount,
AVG(amount) OVER (PARTITION BY customer_id ORDER BY order_date RANGE BETWEEN INTERVAL 30 DAY PRECEDING AND CURRENT ROW) AS avg_amount_30d
FROM
orders;
这里使用 RANGE
来计算每个用户过去 30 天内的平均消费金额,窗口框架包括过去 30 天内的所有订单。 因为关心的是时间范围内的订单,所以用值的范围来定义窗口框架更合适。
需求 3:计算每个用户购买金额排名前 10 的订单的金额总和。
SELECT
customer_id,
order_id,
order_date,
amount,
SUM(amount) OVER (PARTITION BY customer_id ORDER BY amount DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS top_10_amount_sum
FROM (
SELECT
customer_id,
order_id,
order_date,
amount,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC) AS rn
FROM
orders
) AS subquery
WHERE rn <= 10;
这个需求稍微复杂一些,首先使用 ROW_NUMBER()
函数计算每个用户每个订单的排名,然后筛选出排名前 10 的订单,最后使用 SUM()
函数计算这些订单的金额总和。 这里使用 ROWS
来定义窗口框架,因为需要根据排名来选择订单。
9. 根据实际场景选择合适的窗口框架
选择 RANGE
还是 ROWS
取决于你如何定义窗口框架:是基于物理行数,还是基于值的范围。 理解它们之间的差异以及数据分布的特点,才能做出最佳选择,并充分利用窗口函数的强大功能。通过实际测试和优化,可以进一步提高查询的性能,满足复杂的数据分析需求。