ROWS BETWEEN 与 RANGE BETWEEN 有什么区别?滑动窗口怎么写?
What's the difference between ROWS BETWEEN and RANGE BETWEEN? How to write a sliding window?
考察要点
这道题面试官想看你是否深入理解了 SQL 窗口函数的核心——窗口帧(Window Frame)。具体来说,考察以下三点:
- 语义区分能力:你是否能清晰阐述
ROWS(物理行)和RANGE(逻辑值)在定义窗口范围时的根本区别。 - 边界处理意识:你是否知道在
ORDER BY的列存在重复值(Ties)时,ROWS和RANGE的行为会产生巨大差异,这是区分两者的关键场景。 - 实践应用能力:你是否能熟练运用
... BETWEEN ... PRECEDING AND ... FOLLOWING语法来解决滑动平均、累计求和等常见的业务问题。
核心概念
窗口函数中的 Frame 子句(即 ROWS BETWEEN 或 RANGE BETWEEN)定义了在当前分区的有序数据中,一个以当前行为基准的、用于计算的子集。
ROWS 和 RANGE 的核心区别在于它们定义这个子集的方式:
-
ROWS:基于物理行偏移量 它根据当前行在分区中的物理位置,向前或向后数固定的行数来定义窗口帧。它不关心ORDER BY列的值具体是什么,只关心行的位置。 -
RANGE:基于逻辑值偏移量 它根据当前行在ORDER BY列上的值,加上或减去一个偏移量来定义一个值的范围,所有落入这个值范围内的行都会被包含在窗口帧中。关键在于:当ORDER BY的列有重复值时,所有重复的行被视为一个整体。
示例对比
假设我们有一张员工销售记录表 sales,我们要计算每个员工每笔销售及其前一笔销售的平均额。
原始数据 (sales):
| employee | sale_date | amount |
|---|---|---|
| Alice | 2023-01-01 | 100 |
| Alice | 2023-01-02 | 120 |
| Alice | 2023-01-03 | 150 |
| Alice | 2023-01-03 | 50 |
| Alice | 2023-01-04 | 200 |
注意 2023-01-03 有两笔销售,这是测试 ROWS 和 RANGE 区别的关键。
1. 使用 ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
这个子句的含义是:“窗口帧包含当前行和它物理位置上的前一行”。
1SELECT2 employee,3 sale_date,4 amount,5 AVG(amount) OVER (6 PARTITION BY employee7 ORDER BY sale_date, amount -- 使用 amount 排序以保证行序唯一8 ROWS BETWEEN 1 PRECEDING AND CURRENT ROW9 ) AS avg_last_2_rows10FROM sales;
输出结果:
| employee | sale_date | amount | avg_last_2_rows | 计算逻辑 |
|---|---|---|---|---|
| Alice | 2023-01-01 | 100 | 100.0000 | (100)/1 |
| Alice | 2023-01-02 | 120 | 110.0000 | (100+120)/2 |
| Alice | 2023-01-03 | 50 | 85.0000 | (120+50)/2 (前一行是 01-02 的 120) |
| Alice | 2023-01-03 | 150 | 100.0000 | (50+150)/2 (前一行是 01-03 的 50) |
| Alice | 2023-01-04 | 200 | 175.0000 | (150+200)/2 |
分析:ROWS 严格遵守物理行顺序。对于 2023-01-03 的两行,计算是独立的,每一行的窗口只包含它自己和它紧邻的前一行。
2. 使用 RANGE BETWEEN 1 PRECEDING AND CURRENT ROW
这个子句的含义是:“窗口帧包含 ORDER BY 列的值在 [current_value - 1, current_value] 范围内的所有行”。对于日期类型,1 代表 1 天。
1SELECT2 employee,3 sale_date,4 amount,5 AVG(amount) OVER (6 PARTITION BY employee7 ORDER BY sale_date8 RANGE BETWEEN INTERVAL '1' DAY PRECEDING AND CURRENT ROW9 ) AS avg_range10FROM sales;
注意:对于日期/时间类型,RANGE 的偏移量需要指定单位,如 INTERVAL '1' DAY。对于数字类型,可以直接写 1。
输出结果:
| employee | sale_date | amount | avg_range | 计算逻辑 |
|---|---|---|---|---|
| Alice | 2023-01-01 | 100 | 100.0000 | (100)/1 (范围: 01-01 ~ 01-01) |
| Alice | 2023-01-02 | 120 | 110.0000 | (100+120)/2 (范围: 01-01 ~ 01-02) |
| Alice | 2023-01-03 | 150 | 106.6667 | (120+150+50)/3 (范围: 01-02 ~ 01-03, 包含所有 01-03 的行) |
| Alice | 2023-01-03 | 50 | 106.6667 | (120+150+50)/3 (范围: 01-02 ~ 01-03, 包含所有 01-03 的行) |
| Alice | 2023-01-04 | 200 | 125.0000 | (150+50+200)/4 (范围: 01-03 ~ 01-04, 包含所有 01-03 的行) |
分析:RANGE 基于值。当计算 2023-01-03 的行时,CURRENT ROW 的值是 2023-01-03。窗口帧会包含所有 sale_date 在 [2023-01-02, 2023-01-03] 区间内的行。因此,2023-01-02 的一笔和 2023-01-03 的两笔销售都被包含进来,导致这两行的计算结果完全相同。
滑动窗口的写法
滑动窗口通常指一个大小固定、随当前行移动的窗口,常用于计算移动平均、移动求和。一般使用 ROWS 来实现精确的行数控制。
经典案例:计算最近7天的销售总额
1-- 假设 sale_date 是连续的2-- 如果日期不连续,用 ROWS 更能精确表达“最近7条记录”3SELECT4 sale_date,5 daily_sales,6 SUM(daily_sales) OVER (7 ORDER BY sale_date8 ROWS BETWEEN 6 PRECEDING AND CURRENT ROW9 ) AS moving_7day_sales_rows,10 -- 如果想表达“包含今天在内的过去7天(日历天)”,用 RANGE 更合适11 SUM(daily_sales) OVER (12 ORDER BY sale_date13 RANGE BETWEEN INTERVAL '6' DAY PRECEDING AND CURRENT ROW14 ) AS moving_7day_sales_range15FROM daily_summary;
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW:窗口大小最多为 7 行(当前行 + 前面 6 行)。如果前面不够 6 行,则有多少算多少。这代表最近7条记录。RANGE BETWEEN INTERVAL '6' DAY PRECEDING AND CURRENT ROW:窗口包含日历上从“今天-6天”到“今天”的所有记录。如果某天没有数据,ROWS会跳过,而RANGE的窗口期是固定的。这代表最近7天内。
默认窗口帧
如果只写 ORDER BY 而不指定 Frame 子句,默认行为是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。这是一个巨大的坑,因为在有重复值时,它会把所有与当前行值相同的行都计算进来,导致结果可能不符合"截至当前"的直觉。
为了得到一个标准的累计值(Running Total),最稳妥的写法是明确指定 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。
常见低分回答
- 混淆概念:“
ROWS和RANGE差不多,只是语法不同。” —— 这是最严重的错误,完全没有理解两者的核心区别。 - 忽略重复值:只用没有重复
ORDER BY值的例子来解释,导致无法体现RANGE的关键行为,说明理解不深入。 - 错误的默认值:当被问到
ORDER BY存在但没有 Frame 子句时的默认行为时,回答ROWS ...或者整个分区。正确答案是RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,能答出这一点是加分项。 - 对
RANGE的值偏移理解错误:认为RANGE BETWEEN 1 PRECEDING总是指前一个值。实际上它依赖于ORDER BY列的数据类型,对于数字是value-1,对于日期是date - 1 day,并且需要使用INTERVAL语法。 - 滑动窗口只知道一种写法:只知道
ROWS BETWEEN ...,不清楚在某些场景下(如按日历天统计),RANGE BETWEEN ...可能是更符合业务需求的语义。
工程实践与优化
- 性能考量:窗口函数通常比自连接或关联子查询实现相同逻辑的性能要好得多。因为数据库只需对数据进行一次排序(如果需要),然后进行一次扫描即可完成计算。自连接会产生笛卡尔积,计算量呈指数级增长。
- 索引优化:窗口函数的性能瓶颈通常在排序。为
PARTITION BY和ORDER BY的列建立复合索引(partition_key, order_key)至关重要。这能让数据库引擎直接利用索引的有序性,避免昂贵的filesort操作。 - 执行计划分析:在 MySQL 中,使用
EXPLAIN查看执行计划。如果Extra列出现Using filesort,说明排序无法利用索引,需要优化。理想情况下,ORDER BY的顺序应与索引顺序一致。 ROWSvsRANGE的选择:- 当业务需求是“最近N条记录”、“前N名”等基于计数的场景时,使用
ROWS。这是最常见的情况。 - 当业务需求是“1小时内的订单”、“价格相近的商品”等基于值范围的场景,或需要将
ORDER BY列的重复值视为一个整体时,使用RANGE。 - 为避免默认
RANGE行为带来的意外结果,建议在写累计聚合时,总是显式指定ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。
- 当业务需求是“最近N条记录”、“前N名”等基于计数的场景时,使用
常见追问与延伸题目
-
追问:如果
ORDER BY的列是字符串类型,RANGE还能用吗?- 回答要点:可以,但行为可能不直观。
RANGE可以在任何支持比较操作的类型上工作。但对于字符串,RANGE BETWEEN 'A' PRECEDING AND CURRENT ROW这样的语法是没有意义的,因为数据库不知道如何从一个字符串“减去”另一个。此时,RANGE主要用于处理重复值,其默认行为RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW仍然有效,它会把所有值小于等于当前行值的行都包含进来,并且所有与当前行值相同的行都会被一起处理。
- 回答要点:可以,但行为可能不直观。
-
追问:在 MySQL 5.7 中没有窗口函数,如何实现一个“最近7条记录的移动平均”?
- 回答要点:需要使用自连接或关联子查询。例如,使用自连接:
需要强调这种方法的性能极差,时间复杂度近似为 ,其中 W 是窗口大小,而窗口函数的复杂度是 (排序)或 (已排序)。这正体现了 MySQL 8.0 引入窗口函数的巨大价值。sql1SELECT2 t1.sale_date,3 AVG(t2.amount)4FROM5 sales t16JOIN7 sales t2 ON t1.employee = t2.employee AND t2.sale_date <= t1.sale_date8GROUP BY9 t1.employee, t1.sale_date, t1.id -- 假设有唯一id10HAVING11 COUNT(t2.id) <= 7; -- 这是一个近似实现,逻辑复杂且性能差
- 回答要点:需要使用自连接或关联子查询。例如,使用自连接:
-
追问:
RANGE的性能会比ROWS差吗?为什么?- 回答要点:可能会。
ROWS的窗口帧边界是固定的物理偏移,实现起来非常直接,只需在有序数据上移动指针。而RANGE在每次计算时,需要根据当前行的值动态查找边界,尤其是在有大量重复值时,它需要向前或向后扫描以找到所有符合值范围的行。这个查找过程可能会引入额外的开销。不过,在有合适索引的情况下,这种差异通常不显著。在实践中,应优先考虑语义的正确性,再进行性能测试和优化。
- 回答要点:可能会。