
1. 从一条SUM引发的困惑说起先聊个我踩过的坑。几年前做一份销售月报想算“截至每个交易日的累计销售额”。我顺手写了SUM(amount) OVER (PARTITION BY product_id ORDER BY order_date)然后看结果时人傻了同一天产生的多笔订单第一行只算自己第二行却把当天所有订单都算进去了累计值忽大忽小完全不是我想要的“逐单累加”。问题的根源就是窗口函数里的ORDER BY触发了一个隐式窗口RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。它按“值相等”来圈定当前行的范围而不是按“行数”来圈。同一天的值相等于是当天所有订单全被框了进来。后来我把窗口改成ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW才算真正实现“逐行累加”。这个经历让我意识到开窗函数能不能用对关键是搞懂窗口范围里的ROWS和RANGE到底在干什么。网上讲窗口函数的文章不少但把这两个边界讲透、讲直观的并不多。这篇我打算用图文和可直接跑的示例把窗口范围的逻辑彻底拆开。1.1 谁是“开窗函数”开窗函数也叫分析函数Analytic Function它和普通聚合函数最大的区别在于普通聚合比如GROUP BY会把多行压成一行而开窗函数在返回结果时保留每一行同时附带一行和它相关的分组、排序、范围计算出来的值。你可以把开窗理解为“在一张虚拟的明细表上给每一行附一个计算区域”这个区域就是“窗口”。窗口有大有小边界靠三部分定义分区PARTITION BY、排序ORDER BY、范围ROWS或RANGE。前两个大家相对熟悉第三个是最容易踩坑的地方。1.2 为什么要专门把ROWS和RANGE拿出来讲因为在实际开发里窗口范围的写法直接决定计算结果对不对。同样的聚合函数、同样的分区和排序换个窗口边界结果可能天差地别。尤其下面这几类场景特别容易出问题移动平均算最近N天的日均值到底包含哪些天累计求和累计到当前行遇到重复值时算不算重复行同比环比对比上一周期数据窗口到底该卡在哪组内排名差异排名窗口配合范围时重复值会被当成一组。很多人背语法时记混淆甚至以为ROWS和RANGE只是写法不同、效果一样。真相是它们从设计哲学上就是两套逻辑用错了就是数据事故。这篇就把这两兄弟彻底讲明白。2. 图文看懂ROWS与RANGE的本质差异2.1 两种窗口的“圈地”逻辑要理解ROWS和RANGE最直观的方式是想象你站在排序后的一列数据里以自己所在行为“当前行”系统要决定“往前往后各拉多少行进来计算”。ROWS物理窗口只看行号。它不管你的排序列值是多少只按“相对物理位置”计算。比如ROWS BETWEEN 2 PRECEDING AND CURRENT ROW就是铁打的前两行加当前行一共3行。哪怕前两行的值一个100、一个负数都不会影响行数判定。RANGE逻辑窗口看值区间。它基于ORDER BY的列值做数学区间判定只要值落在指定区间内的都算进来。比如RANGE BETWEEN INTERVAL 5 DAY PRECEDING AND CURRENT ROW它会自动找到“日期在当前行日期往前5天”的所有行并包含进来——不管中间隔了几行、有多少重复。光看定义有点绕我画个表格来对比维度ROWSRANGE判定依据行物理序号ORDER BY列的值大小受重复值影响不受影响按行数圈受影响同值行会被整体圈入适用场景固定行数的移动计算按数值/时间区间的动态计算典型误用后果范围不稳难做时间窗重复值导致结果“跳变”2.2 最关键的默认窗口99%的人没注意写窗口函数时如果只写ORDER BY而不声明窗口范围数据库会默认一个范围大多数主流数据库Oracle、PostgreSQL、MySQL 8.0默认是RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW意思是从分区第一行开始到当前行并且“值等于当前行”的所有行。这个默认值的设计本意是“截至当前值的累计”但如果你在同一个分组里有多行值相同比如同一天多笔订单它就会把相同值的所有行都圈进来。所以一旦你看到“咦我没写窗口怎么结果不按行累加”第一反应就该去查是不是掉进这个默认RANGE规则里了。解决方法很简单要么显式写ROWS要么显式写RANGE别让系统替你决定。2.3 图文示例一行一行看两个窗口的区别假设有一张订单表ordersorder_idproductorder_dateamount1A2025-01-011002A2025-01-012003A2025-01-023004A2025-01-034005A2025-01-03500按order_date排序分别用ROWS和RANGE来做“截至当前行的累计金额”-- ROWS版本逐行累加 SELECT order_id, order_date, amount, SUM(amount) OVER ( PARTITION BY product ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_total_rows FROM orders; -- RANGE版本同值行一起累加 SELECT order_id, order_date, amount, SUM(amount) OVER ( PARTITION BY product ORDER BY order_date RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_total_range FROM orders;结果对比如下order_idorder_dateamountrunning_total_rowsrunning_total_range12025-01-0110010030022025-01-0120030030032025-01-0230060060042025-01-034001000150052025-01-0350015001500看出门道了吧running_total_rows是真正的“逐单累加”第一行100第二行100200300第三行加上300600第四行再加上4001000第五行再加5001500。running_total_range则是“值相等就一起算”第1、2行都是2025-01-01所以在第一行时它发现“当前行值2025-01-01”于是把所有2025-01-01的行都拉进来直接算出300。第4、5行同理在第四行时就已经把2025-01-03这两天订单全部算完直接跳到1500了。很多时候你要的是ROWS版本但默认机制给了RANGE版本这就是开窗函数“迷之结果”的头号原因。2.4 SQL里的完整语法上下文一个完整的窗口定义有时会包含两部分窗口函数 窗口子句OVER子句。完整的语法结构是窗口函数名(参数) OVER ( [PARTITION BY 分区列] [ORDER BY 排序列 [ASC|DESC]] [窗口范围子句] )窗口范围子句有几种写法比较常见ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW从分区第一行到当前行ROWS BETWEEN N PRECEDING AND CURRENT ROW从往前N行到当前行ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING从当前行到分区最后一行RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW从分区第一行到值等于当前行的所有行RANGE BETWEEN INTERVAL 7 DAY PRECEDING AND CURRENT ROW日期型字段往前7天的范围注意ROWS和RANGE后可以接PRECEDING往前、FOLLOWING往后、CURRENT ROW当前行、UNBOUNDED PRECEDING全区第一行、UNBOUNDED FOLLOWING全区最后一行这些边界描述词。有些数据库还支持ROWS BETWEEN 3 PRECEDING AND 1 FOLLOWING这种前后夹击的窗口非常灵活。提示如果只写了ORDER BY但不声明边界默认边界是RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW也就是“从起点到当前值区间的所有行”。这是最容易出问题的地方建议养成“显式声明窗口范围”的编码习惯。3. 高频场景实战移动平均、累计值与同环比3.1 场景一移动平均移动窗口的核心用法移动平均是ROWS用得最多的场景之一。比如计算“最近3笔订单的平均金额”典型的写法是SELECT order_id, order_date, amount, AVG(amount) OVER ( PARTITION BY product ORDER BY order_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS avg_3_orders FROM orders;执行到第4行时窗口取第2、3、4行做平均执行到第5行时窗口取第3、4、5行做平均。它的特点是滑动窗口大小固定永远只看3行数据。如果这里错写成RANGE BETWEEN 2 PRECEDING AND CURRENT ROW那问题就来了因为RANGE对一个数字列例如无日期的排序序号不一定报错逻辑上它会把“值在当前行值-2到当前行值”区间内的行都拉进来。如果你的排序字段是日期正好每个订单日期都不同那RANGE和ROWS可能结果一样看起来没事但一旦出现同一天多个订单窗口行数就忽大忽小平均值完全失真。实操经验绝大多数“最近N笔”“最近N行”的需求都应该用ROWS而不是RANGE。你是在按“物理出现行数”划窗口而不是按“数值大小”划窗口。3.2 场景二累计求和与百分比占比累计求和是开窗函数的入门场景也是ROWS和RANGE区别最敏感的场景。前面已经展示过如果你要“一行一行的累计”用ROWS如果你要“按当前值分组的累计”用RANGE。再看一个“组内累计占比”的场景。假设你要看每个产品截至各订单的累计金额占全产品总金额的比例SELECT order_id, product, amount, SUM(amount) OVER ( PARTITION BY product ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) / SUM(amount) OVER ( PARTITION BY product ) AS cum_ratio FROM orders;这里第一个窗口是“逐单累计”第二个窗口PARTITION BY product不加ORDER BY其窗口范围就是整个分区SUM(amount)直接等于该产品全部订单的总金额。跑出来的结果就能正确显示每一单完成时已累计金额占全产品的比例。如果把第一个窗口不小心改成默认的RANGE遇到重复日期时比例就会在某个节点一下子跳高后一单又不再增加。3.3 场景三同比环比按时间区间动态取值RANGE在“按值区间”的移动计算里反而很有优势。比如计算“每笔交易日往前推7天的总交易额”类似连续7天滚动日汇总用ROWS就不好使——因为不知道往前7天到底有几行用RANGE才最自然SELECT order_date, SUM(amount) AS day_amount, SUM(amount) OVER ( ORDER BY order_date RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW ) AS rolling_7d_amount FROM orders GROUP BY order_date;这条SQL按日期分组后对每个日期取“从它往前数6天含当日的所有日期的总额”做累计。如果某天没有订单RANGE依然能正确跳过空档把你需要的时间区间框进来而如果用ROWS BETWEEN 6 PRECEDING AND CURRENT ROW它只会笨拙地取物理行上的前6行碰到缺失日期的数据7天窗口就变成了“最近有记录的7行”完全不符合需求。画个重点日期时间型、数值区间型窗口优先考虑RANGE固定行数型窗口必须用ROWS。3.4 场景四RANK/DENSE_RANK和窗口边界的关系开窗函数家族里还有一类排序函数比如RANK()、DENSE_RANK()、ROW_NUMBER()。它们的排序逻辑天然和“相等值”有关ROW_NUMBER()不管值是否相同每行都分配一个唯一序号。RANK()相同值分配相同排名但后续排名会跳号比如两个并列第1下一个直接是第3。DENSE_RANK()相同值分配相同排名但后续排名不跳号两个并列第1下一个是第2。对比示例SELECT order_date, amount, ROW_NUMBER() OVER (ORDER BY amount DESC) AS rn, RANK() OVER (ORDER BY amount DESC) AS rk, DENSE_RANK() OVER (ORDER BY amount DESC) AS drk FROM orders;如果存在金额相同的订单你会看到rn都是唯一值rk会出现跳跃drk则连续排列。RANK()和DENSE_RANK()在功能上可以理解为“给值分组排名”而ROW_NUMBER()就是纯物理行号。这个本质差别也是理解RANGE的核心钥匙——RANGE在做范围判定时和RANK的“同组同值”思路如出一辙。4. 实操对比与可视化思路4.1 用示例数据生成可复现的对比脚本为了让你看完就能自己跑一遍我给出一个完整的建表和数据插入脚本兼容MySQL 8.0、PostgreSQL、Oracle等常见数据库注意日期写法需微调CREATE TABLE orders ( order_id INT, product VARCHAR(20), order_date DATE, amount NUMERIC(10,2) ); INSERT INTO orders VALUES (1, A, 2025-01-01, 100), (2, A, 2025-01-01, 200), (3, A, 2025-01-02, 300), (4, A, 2025-01-03, 400), (5, A, 2025-01-03, 500), (6, B, 2025-01-01, 150), (7, B, 2025-01-02, 250), (8, B, 2025-01-04, 350); -- 跑这两个查询并对比结果 SELECT order_id, order_date, amount, SUM(amount) OVER ( PARTITION BY product ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS rows_cum, SUM(amount) OVER ( PARTITION BY product ORDER BY order_date RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS range_cum FROM orders ORDER BY product, order_date, amount;执行完把rows_cum和range_cum两列放到一起对比。实践一遍比背十遍文档都管用。4.2 结果差异可视化数据看板背后的“暗雷”如果把上面的结果画成折线图你会发现ROWS版本的累计线是一条严格单调递增的线每一行都往上走一步视觉上很“平滑”。RANGE版本的累计线则是“阶梯状”的每当遇到重复日期台阶就一下子蹿高一大截然后再平走几行。这种阶梯状曲线在报表里未必是错的——比如你想看“每天结束时累计达成多少”它反而是对的。但如果你把它当作“每笔订单的实时累计”就会得出虚假的增长趋势。所以在数据可视化或者看板开发里窗口类型选错图表就会“骗人”。这也是我建议数据分析师、后端开发在写这类SQL时务必多看一眼窗口边界的原因。4.3 如何用EXPLAIN验证窗口执行逻辑在调试复杂SQL时我会习惯性对查询执行计划看一遍不同数据库命令不同MySQL是EXPLAINPostgreSQL是EXPLAIN ANALYZE。窗口函数在执行计划里通常体现为一个WindowAgg节点。对比ROWS和RANGE版本的执行计划你会发现两者的执行算子基本相同只是在计算逻辑上窗口边界不同。也就是说执行计划不会告诉你窗口边界对错所以更得靠自己对业务语义的判断。建议遇到可疑的窗口计算结果先用“缩小数据范围手工Excel核对”的方式抽几行对应的累计值手工算一遍基本就能定位是不是窗口边界问题。5. 高频采坑现场与排查速查表5.1 坑一只写ORDER BY默认窗口“偷偷”给你圈了同值行这个前面反复强调过。再说个真实案例有一次同事做“到店客流累计”按店铺和小时分组订单时间精确到分钟。他写的是SUM(cnt) OVER (PARTITION BY store_id ORDER BY hour)结果每小时最后一分钟的订单会带上整个小时的累计完全是错的。排查半天最后删掉ORDER BY里的小时换成ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW才恢复正常。避坑建议凡是窗口逻辑是“逐行累加”的一律显式写ROWS。5.2 坑二把RANGE用在排序字段不是数值/日期的场景RANGE的区间比较要求排序字段是可比较大小或可计算加减的数据类型。如果排序字段是字符串例如按照产品名称排序你还想写RANGE BETWEEN 1 PRECEDING AND CURRENT ROW数据库很可能会直接报错或者在底层把排序字段按字典序处理结果完全不可控。这种场景就用ROWS别硬上RANGE。5.3 坑三在移动窗口里选了错误的边界组合比如使用ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING来做“当前行到分组末尾的累加”这是常见的“剩余累计”需求很多人却把它和FOLLOWING的方向弄反。再比如ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING这是以当前行为中心的前后3行共7行适用于中心化移动平均。边界数量写错或方向搞反都会导致计算结果偏了一截。建议用一张速查表把手感练出来业务需求推荐窗口写法逐单累计无重复值干扰ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW按当前值分组累计同值一起算RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW最近N行移动平均ROWS BETWEEN N-1 PRECEDING AND CURRENT ROW最近N天滚动汇总日期连续/不连续都行RANGE BETWEEN INTERVAL N-1 DAY PRECEDING AND CURRENT ROW当前行及之后N行小计ROWS BETWEEN CURRENT ROW AND N FOLLOWING当前行到分组末尾累计ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING以当前行为中心前后N行ROWS BETWEEN N PRECEDING AND N FOLLOWING5.4 坑四分区字段与排序字段没想清楚窗口范围是在“每个分区内部”独立计算的。如果你忘了PARTITION BY那么所有行会作为一个整体分区窗口计算会跨业务实体进行。比如统计每个店铺的累计销售额如果漏了PARTITION BY store_id就会把所有店铺的数据混在一起累计结果彻底失去意义。写窗口函数时先确认分区是否正确再确认排序是否唯一最后再确认窗口边界——这三步缺一不可。5.5 坑五 NULL值对窗口边界的影响排序字段为NULL时不同数据库处理逻辑不太一样。有的把NULL排在最前有的排最后。使用RANGE时NULL值会形成一个特殊的“相同值组”很可能把多个NULL行圈到同一个逻辑窗口里。使用ROWS时NULL只是占据物理位置不影响相对行数。所以当数据里NULL很常见时建议在排序前先用COALESCE把NULL替换成业务上有意义的默认值避免窗口结果出现反直觉的“抱团”。6. 我的一些实操心得最后分享几个我自己的经验。第一个心得是动手之前先在纸上画窗口。画一条时间轴标出当前行再画出“往前几行/往前几天”的框然后才落到SQL语法。这种物理化的想象能帮你自然区分ROWS和RANGE——一个是在纸上用格子框出固定数量的格子一个是在时间线上用尺子量出宽度。第二个心得是在团队里统一窗口函数编码规范。我们组现在约定凡是用到窗口函数OVER()子句里必须显式写清楚窗口边界禁止省略。即使只写PARTITION BY不需要排序也要明确知道默认窗口是整个分区。长期下来代码的可读性和排查效率高了很多。第三个心得是遇到“数据看起来没错但总和不对”的报表优先怀疑窗口范围。因为窗口函数不像GROUP BY会导致行数变化它依然是一行对一行肉眼很难第一时间发现问题。这时候可以用“把窗口改成ROWS”和“把窗口改成RANGE”各跑一遍对比差异行往往几秒钟就能找出问题所在。最后一个感受开窗函数是SQL从“能用”到“好用”的分水岭。ROWS和RANGE的差别表面上是两个关键字实际上代表了两种思维模式——一种是面向物理行号的精确控制一种是面向业务值区间的逻辑语义。真正理解它们你写复杂报表、做数据分析和性能优化时心里会特别有底。