写窗口函数的时候,OVER (ORDER BY ... ROWS BETWEEN ...) 和 OVER (ORDER BY ... RANGE BETWEEN ...) 在语法上只差一个单词,很多人默认不写框架子句时也从未留意过——实际上省略框架时的默认值就是RANGE UNBOUNDED PRECEDING AND CURRENT ROW。而这两者在真实数据量下的性能差距可能达到几十倍,尤其是排序列存在大量重复值的时候。要理解这个差距,必须回到窗口边界判定的底层实现上。

ROWS与RANGE的语义差异是性能差距的根源
ROWS是物理行的概念:它以当前行在分区内的位置为锚点,ROWS BETWEEN 1 PRECEDING AND CURRENT ROW 表示严格取当前行的前一行和当前行,共两行,不多不少。数据库引擎处理时只需要知道“当前是第几行”,边界的判定是一个简单的整数比较。
RANGE则是逻辑值的概念:它以当前行排序列的取值为锚点,RANGE BETWEEN 1 PRECEDING AND CURRENT ROW 表示取所有排序值落在 [当前值-1, 当前值] 区间内的行。关键点来了——如果排序列有重复值,这些重复行会被一次性纳入窗口。也就是说RANGE的窗口大小是数据分布决定的,行数不固定,引擎在扫描到某一行时无法立刻知道窗口边界在哪里,必须回头扫描值相同的兄弟行。
举个具体例子,假设按amount排序的值为 10, 20, 20, 20, 30。对第四行(amount=20)执行 ROWS BETWEEN 1 PRECEDING AND CURRENT ROW 窗口是第三、四行共两行;而执行 RANGE BETWEEN 1 PRECEDING AND CURRENT ROW(amount差为1以内)窗口包含所有amount在19到20之间的行,也就是第二、三、四行共三行。排序列重复越多,两种模式的窗口差异越大,RANGE需要处理的行也就越多。
执行计划层面的实现差异
以SQL Server为例,看它的实际执行计划可以非常直观地观察到差别。使用ROWS模式时,执行计划里的Window Aggregate算子会显示为普通的流式聚合,引擎维护一个固定大小的缓冲区(比如窗口是1 PRECEDING时缓冲区就是1行),每读入一行就做增量计算:新行加入聚合,滑出窗口的旧行从聚合中扣除。整个过程是O(n)的,一遍扫描完成。
而RANGE模式在SQL Server的实现中,你会发现执行计划多了一个序列化key匹配的环节。因为引擎需要判断“哪些行的排序值与当前值相等(或落在区间内)”,这个判断不再是整数行号的比较,而要对排序列的值做序列化后逐key匹配。在排序列重复值很多的情况下,一个窗口可能覆盖成千上万行,引擎要么退化为对每个不同值做一次嵌套循环式的回扫,要么采用segment top这类调度模式。Plan里的实际行数(Actual Rows)往往会比ROWS模式高出一个数量级,这就是耗时膨胀的直接原因。
PostgreSQL的实现同样体现了这个差异。ROWS模式的窗口函数在internal执行层面用行号直接定位帧的起止,帧推进是单调的、可增量的;RANGE模式则必须在W-window帧计算时反复比较排序值,帧起点只有在遇到下一个不同值时才能推进。当数据倾斜严重(比如某个值占了表的一半行数)时,PostgreSQL的RANGE窗口内存中需要物化整个帧,work_mem的压力也随之上升。可以在EXPLAIN ANALYZE的输出里观察WindowAgg节点的耗时:
EXPLAIN ANALYZE
SELECT
user_id,
amount,
SUM(amount) OVER (ORDER BY amount
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS sum_rows,
SUM(amount) OVER (ORDER BY amount
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS sum_range
FROM orders;
-- 排序列amount重复度高时,sum_range对应的WindowAgg节点
-- 自身耗时通常是sum_rows节点的数倍到数十倍
排序与去重的额外开销
窗口函数执行的前置条件是数据按PARTITION BY和ORDER BY排好序。对ROWS来说,排序键的重复值无所谓,任何稳定的排序结果都能直接使用。但对RANGE来说,引擎还需要识别“值相同的连续段”,一些实现会为此在排序key上追加额外的标记列,或者在WindowAgg内部维护一个eqfunction做逐值比较。这些都是ROWS模式不需要付出的成本。
更隐蔽的一点是RANGE配合区间偏移(如 RANGE BETWEEN 5 PRECEDING AND 5 FOLLOWING)时,某些引擎无法简单地用双向指针滑动,必须借助索引查找或对已物化的分区做二分定位。窗口越大、偏移越宽,随机访问的代价越明显。而ROWS无论偏移多大,本质上都只是两个指针在有序流上的单调移动。
内存方面也有差异。ROWS的增量聚合只需要保存窗口宽度的行数,ROWS BETWEEN 10 PRECEDING AND 10 FOLLOWING最多缓存20行左右的状态;RANGE的窗口行数不确定,引擎必须把整个当前帧的输入行保留在内存或workfile中,重复值高时可能触发溢出到磁盘(SQL Server中表现为tempdb spills,PostgreSQL中表现为窗口物化带来的IO),这是除CPU之外的第二个性能杀手。
业务改写:什么时候能用ROWS替代RANGE
理解了实现差异,优化思路就很清晰了:在业务语义允许的前提下,尽量用ROWS改写。一个典型场景是“取时间戳完全相同的行视为同一时刻”,如果业务上每个时刻只有一行数据(比如主键粒度),RANGE和ROWS的结果完全一致,直接写ROWS就能白拿性能。
另一种常见的改写技巧是给排序列追加唯一列,消除重复值:ORDER BY amount, id 配合ROWS使用。因为加了唯一列之后排序组合不再重复,RANGE语义中“同行”的概念退化为“同一行”,两种模式结果一致,但执行走的是ROWS的快路径。来看一个累计求和的改写对比:
-- 原写法:RANGE模式,排序列amount重复多时性能差
SELECT user_id, amount,
SUM(amount) OVER (PARTITION BY user_id ORDER BY create_time
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_sum
FROM orders;
-- 改写:追加唯一id消除重复,改用ROWS,语义等价
SELECT user_id, amount,
SUM(amount) OVER (PARTITION BY user_id ORDER BY create_time, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_sum
FROM orders;
当然改写前必须确认语义等价性:如果业务明确要求“同一时刻的行共享同一个累计值”(比如所有同分的选手并列排名),那就必须保留RANGE,此时能做的是减少排序列的重复度、控制分区大小,或者预先用GROUP BY压缩重复值再开窗。另外要注意,不写框架子句时默认就是RANGE模式,这也是很多人无意中踩坑的地方——显式写出 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 常常是一个零风险的性能优化。
小结
ROWS与RANGE的性能差异不是实现质量的偶然,而是语义复杂度决定的必然:物理行号可以增量推进,逻辑值边界则需要逐值比较、整段扩张、额外物化。排查这类问题时,养成先看执行计划里Window Aggregate节点实际行数的习惯,再检查排序列的重复度(NDV,number of distinct values),基本就能定位是RANGE在拖后腿。能改写成ROWS的场景不要犹豫,语义必须保留RANGE的场景则要控制数据倾斜,避免单帧过大导致的CPU和内存双重压力。
SQL窗口函数ROWS与RANGE数据库性能优化修改时间:2026-09-06 19:42:42