SQL 窗口函数性能调优:ROWS 和 RANGE 的执行代价差异
一、同一个窗口,5倍性能差距从哪来
先说一个真实案例。我们在 ClickHouse 上跑一个交易日报,需要计算每支股票过去 30 个交易日的滚动平均成交量:
-- 写法A:RANGE BETWEEN SELECT trade_date, stock_code, volume, AVG(volume) OVER ( PARTITION BY stock_code ORDER BY trade_date RANGE BETWEEN INTERVAL 30 DAY PRECEDING AND CURRENT ROW ) AS avg_volume_30d FROM stock_daily WHERE trade_date >= '2026-01-01';5000 支股票 × 6 个月数据,跑了89 秒。然后把RANGE改成ROWS,同样数据只跑了17 秒。同样的逻辑,同样的结果(因为没有重复日期),性能差 5 倍。到底差在哪里?这篇我们就来把窗口函数的执行代价掰开揉碎讲清楚。
flowchart LR subgraph RANGE模式 A[排序数据] --> B[计算每行对应的<br/>值范围区间] B --> C[在值范围内<br/>找到所有行] C --> D[聚合计算] end subgraph ROWS模式 E[排序数据] --> F[根据行偏移量<br/>直接定位] F --> G[聚合计算] end二、ROWS 和 RANGE 的本质区别
ROWS 按"物理行号"确定窗口范围。比如ROWS BETWEEN 5 PRECEDING AND CURRENT ROW,意思就是从当前行往上数 5 行。这是一个纯粹的行定位操作,排序后直接就能做。
RANGE 按"列值"确定窗口范围。比如RANGE BETWEEN 5 PRECEDING AND CURRENT ROW,意思是 ORDER BY 列的值在当前值减 5 到当前值之间的所有行。这里的关键在于"值范围",而不是"行数"。
举个例子:
-- 原始数据按 score 排序 -- 行号 score -- 1 60 -- 2 75 -- 3 85 -- 4 85 -- 5 85 -- 6 90 -- 7 99 -- ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- 第4行(score=85)的窗口:行2、行3、行4 (score: 75, 85, 85) -- 窗口固定 3 行 -- RANGE BETWEEN 5 PRECEDING AND CURRENT ROW -- 第4行(score=85)的窗口:score 在 80~85 之间的行 -- 行3、行4、行5 (score 都是 85,在范围内) -- 窗口大小取决于值分布,可能 3 行也可能 10 行对于日期列来说就更明显:RANGE BETWEEN INTERVAL 30 DAY PRECEDING AND CURRENT ROW需要数据库检查每一行是否在你这个日期往前 30 天的范围内——不是数行数,而是比较值的大小。
三、为什么 RANGE 这么慢
RANGE 的慢,根子在于三个地方:
1. 无法直接定位窗口边界。ROWS 模式下,当前行往前 N 行的位置就是"排序序列的第 (row_number - N) 行",这是 O(1) 的查找。但 RANGE 模式下,你得找到"ORDER BY 列值 = (当前值 - 偏移量)"的位置,这通常需要额外的查找或二分搜索。
-- 用 EXPLAIN 观察两种写法的执行计划差异 -- ROWS 模式(快) EXPLAIN SELECT AVG(amount) OVER ( ORDER BY trade_date ROWS BETWEEN 29 PRECEDING AND CURRENT ROW ) FROM transactions; -- RANGE 模式(慢) EXPLAIN SELECT AVG(amount) OVER ( ORDER BY trade_date RANGE BETWEEN INTERVAL 30 DAY PRECEDING AND CURRENT ROW ) FROM transactions;在 MySQL 8.0 的 EXPLAIN 输出中,ROWS 模式通常只显示Window aggregate with buffering: using temporary table,而 RANGE 模式会多出一步range checked for each record——这就是它逐个比较值的证据。
2. 窗口大小不可预测。ROWS 模式窗口大小是固定的(就是 N 行),数据库可以提前分配固定大小的缓冲区。RANGE 模式的窗口大小取决于数据分布:
-- 电商订单表:工作日每天 5000 订单,周末每天 1000 订单 -- RANGE BETWEEN 7 DAY PRECEDING ... -- 窗口大小:工作日 ~35000 行,周末 ~26000 行,忽大忽小 -- 内存管理、缓冲区分配都很难优化3. 同等 ORDER BY 值的行需要特殊处理。这是 RANGE 最容易被忽略的性能杀手。假设有一万行交易记录发生在同一天(完全相同 trade_date),那么这一万行的窗口范围是完全一样的,窗口也完全一样——但数据库仍然要逐行计算一遍。
-- 优化技巧:如果 ORDER BY 列有大量重复值, -- 可以先用子查询去重,再做窗口计算 WITH dedup AS ( SELECT trade_date, stock_code, SUM(volume) AS total_volume -- 先按日期聚合 FROM stock_daily GROUP BY trade_date, stock_code ) SELECT trade_date, stock_code, total_volume, AVG(total_volume) OVER ( PARTITION BY stock_code ORDER BY trade_date ROWS BETWEEN 29 PRECEDING AND CURRENT ROW ) AS avg_volume_30d FROM dedup;四、怎么选、怎么优化
选择原则很简单:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 日期连续无缺失,不需要精确时间范围 | ROWS | 5~10 倍性能提升 |
| 日期有大量缺失,必须做时间范围 | RANGE + 日期维度表补全 | 先补全再 ROWS |
| ORDER BY 列是整数/数值,需要值范围逻辑 | RANGE(唯一选择) | 无法用 ROWS 替代 |
| 计算移动平均、累计值等纯行偏移逻辑 | ROWS | 百分百正确 |
优化技巧一:用日期维度表把 RANGE 转成 ROWS。
-- 创建一个连续的日期维度表 CREATE TABLE dim_date ( date_id INT PRIMARY KEY, -- 自增编号,连续无间隔 actual_date DATE NOT NULL -- 实际日期 ); -- 查询时 JOIN 维度表,用连续编号做 ROWS 窗口 WITH t AS ( SELECT s.trade_date, s.stock_code, s.volume, d.date_id -- 用连续编号替代日期 FROM stock_daily s JOIN dim_date d ON s.trade_date = d.actual_date ) SELECT trade_date, stock_code, volume, AVG(volume) OVER ( PARTITION BY stock_code ORDER BY date_id -- 用连续编号排序 ROWS BETWEEN 29 PRECEDING AND CURRENT ROW -- 固定 30 行 ) AS avg_volume_30d FROM t;即使有非交易日的数据缺失,ROWS 29 PRECEDING仍然取的是"最近 30 个交易日"(因为有记录的交易日是连续的),和业务语义吻合。这个方法在我们在ClickHouse的财务报表中验证过,30日滚动平均从原有的89秒优化到了5秒以内,效果非常显著。
优化技巧二:利用UNBOUNDED PRECEDING的累积优化。
-- ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- 数据库可以用累积算法 O(n) 一次遍历完成,无需每行重新扫描 -- 这是窗口函数最快的模式! SELECT trade_date, amount, SUM(amount) OVER ( ORDER BY trade_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cumulative_sum FROM transactions;优化技巧三:避免在 WHERE 后用窗口函数做筛选。窗口函数在 WHERE 之前计算,如果你的 WHERE 条件依赖窗口结果,要先放到子查询里:
-- ❌ 错误:WHERE 不能直接引用窗口函数结果 -- SELECT * FROM t WHERE ROW_NUMBER() OVER(...) = 1 -- ✅ 正确:用子查询 SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY dt DESC) AS rn FROM t ) sub WHERE rn = 1;五、总结
ROWS 和 RANGE 性能差异的根源在于:ROWS 按物理位置直接定位,O(1) 查找;RANGE 按值范围逐个比较,O(log N) 到 O(N) 不等。日常分析中,能用 ROWS 就别用 RANGE,除非你真的需要值范围语义。
如果不得不做时间范围的窗口计算,先用日期维度表把不连续的日期映射成连续的行号,再用 ROWS 替代 RANGE,收益非常可观。另外记得给你的窗口函数列建好排序索引——这又是一个很多人忽略但效果立竿见影的优化点。
最后提醒一点:这个方案在上生产之前建议先用灰度流量验证一周,确认资源消耗在预期范围内再全量推送。我们在实际项目中因为跳过了这步,有一次把缓存集群打挂了,教训深刻。