很多人学了窗口函数,一看到SUM() OVER(PARTITION BY ... ORDER BY ...)这种写法还是会懵,特别是ORDER BY加上之后,结果怎么就从“分组总和”变成“累加值”了?
这篇文章继续走实战路线,我会把SUM() OVER()从基础语法到进阶应用一层层剥开,重点放在PARTITION BY和ORDER BY组合时背后那套“隐形规则”上。先说明一点:市面上很多教程只会告诉你“这是求累计值”,却没告诉你为什么是累计值、默认窗口范围是什么时候开始什么时候结束的,导致你换个场景又不会了。
所以这篇文章适合所有用过 SQL、但窗口函数停留在“会写不会变通”阶段的开发者。全文我会用模拟的订单销售数据做演示,所有案例都能直接在主流数据库上跑(MySQL 8.0+、PostgreSQL、Hive、Spark SQL 语法都兼容),你跟着抄就行。
1. 窗口函数初印象:SUM() OVER() 到底比 GROUP BY 强在哪
1.1 一个让你崩溃的业务需求
先说一个我在业务中经常遇到的场景:运营要给出一张表,里面需要同时展示“每个销售当天的订单金额”和“每个销售从月初到当天的累计订单金额”。
如果只用GROUP BY,你最多能算出来“每个销售的总金额”,因为你一旦分组,原来的明细行就被折叠了。但需求是要把累计值“贴”到每一行明细后面,行的数量不能变,额外的累计数只能作为新列加上去。
我记着第一次遇到这个需求时,我还在用“关联子查询 + 临时聚合表”的方式做,SQL 写得又长又绕,跑了半天还容易出性能问题。那时候没有窗口函数,这种需求就是折磨人。
后来接触了SUM() OVER(),才发现这个需求用一列窗口函数就能解决,而且语义非常直白:PARTITION BY salesperson表示“按销售分组”,ORDER BY sale_date表示“组内按日期排序”,然后对排好序的数据做累计相加。
1.2 窗口函数与传统聚合的本质区别
要理解SUM() OVER(),核心是理解“窗口”两个字。
传统聚合函数比如SUM(sale_amount)配合GROUP BY,会把多行合并成一行,结果集的行数会变少。窗口函数则不同:它也是“按组计算”,但计算完的结果会保留原有行数,每一行都能看到它所在组的聚合结果。
我习惯用一个类比来记忆:传统聚合就像把一堆水果榨成一杯果汁,你看到的是混合后的整体;窗口函数就像给每个水果贴一个标签,这个标签上写的是这一筐水果总共多重,但水果本身还是一个个摆在那里。
SUM() OVER(PARTITION BY ...)做的就是第二种事:算完分组总和,不折叠行,把总和复制给组内每一行。
而一旦在OVER()里面加上ORDER BY,事情又发生了一次质变——SUM()不再是求整个分组的总和,而是变成了求“从分组起点到现在这一行”的累计值。这背后涉及的“窗口框架”概念,我放到第 4 章专门讲,因为它是理解整个问题的总开关。
2. PARTITION BY 和 ORDER BY 的各自分工
2.1 PARTITION BY:分组但不收敛
PARTITION BY的作用和GROUP BY有点像,都是指定“按哪些字段分成不同的桶”,却有一个显著差异:GROUP BY会折叠行,PARTITION BY不会。
举个例子,下面是一个简化的销售表:
| sale_date | salesperson | sale_amount |
|---|---|---|
| 2024-01-01 | 张三 | 100 |
| 2024-01-01 | 李四 | 200 |
| 2024-01-02 | 张三 | 150 |
| 2024-01-02 | 李四 | 300 |
| 2024-01-03 | 张三 | 180 |
| 2024-01-03 | 李四 | 250 |
看这条 SQL:
SELECT sale_date, salesperson, sale_amount, SUM(sale_amount) OVER(PARTITION BY salesperson) AS total_by_person FROM sales_data;执行结果里,每一个销售的原始行都还在,只是每一行旁边多了一列“这个人的总销售额”。张三的三行都显示 430,李四的三行都显示 750。
这个特性特别适合“原始明细 + 分组汇总”同时呈现的场景,比如你在做报表时既要给明细,又要给合计数。用GROUP BY做不到,用子查询关联也能做但性能差、代码丑。
2.2 ORDER BY:排序如何改变 SUM 的计算范围
PARTITION BY确定了“在哪个组内计算”,ORDER BY则负责确定“在组内按什么顺序计算”。当SUM()遇上ORDER BY后,它计算的就不再是“整个分组的总和”,而是“按排序顺序从分组第一行到当前行的累计值”。
这是窗口函数里最基础也最重要的概念——累计求和。看下面的 SQL:
SELECT sale_date, salesperson, sale_amount, SUM(sale_amount) OVER(PARTITION BY salesperson ORDER BY sale_date) AS cumulative_amount FROM sales_data;结果变成:
| sale_date | salesperson | sale_amount | cumulative_amount |
|---|---|---|---|
| 2024-01-01 | 张三 | 100 | 100 |
| 2024-01-02 | 张三 | 150 | 250 |
| 2024-01-03 | 张三 | 180 | 430 |
| 2024-01-01 | 李四 | 200 | 200 |
| 2024-01-02 | 李四 | 300 | 500 |
| 2024-01-03 | 李四 | 250 | 750 |
注意看张三的第三行:累计金额是 430,这个数字恰好等于张三所有订单之和。而李四的第三行也是 750,同样等于全部订单之和。在这个例子里,因为聚合到第三行后所有行都“到齐了”,所以最后一行看起来和整组总和一样,这纯属巧合。
关键在中间行:第二行累计值是 250,它不是张三的全组总和 430,而是只累加了两天的结果。这就是ORDER BY的威力——它把“分组”进一步划分为“按顺序逐步扩展的行集合”。
2.3 两者一起用时到底按什么逻辑算
PARTITION BY和ORDER BY一起出现时,计算逻辑可以分解为三步:
- 第一步,按照
PARTITION BY字段值分成若干独立组; - 第二步,在每个组内,按
ORDER BY字段排序; - 第三步,对每一行,从组内第一行开始累加,一直加到当前行,得到当前行的窗口结果。
这个“当前行之前的所有行 + 当前行”的范围,就是窗口函数里面非常重要的“聚合窗口”,或者叫“窗口框架”。它默认是从分组起点到当前行。
这个默认行为正是 SQL 标准设计的核心点——没有显式指定窗口框架时,只要OVER()里有ORDER BY,聚合函数就会默认采用“从分区起点到当前行”的累计框架。如果你不理解这层,就无法解释为什么加了ORDER BY结果从“总和”变成了“累计值”。
3. 实战案例拆解:从销售额累计到同环比计算
3.1 案例一:各部门销售累计趋势
先来个最常见的场景。某个公司有多个部门,每天产生销售记录,现在需要看每个部门从月初到每天的销售额累计,用于绘制趋势图。
建表和模拟数据:
CREATE TABLE dept_daily_sales ( dept_id INT, sale_date DATE, sale_amount DECIMAL(10,2) ); INSERT INTO dept_daily_sales VALUES (1, '2024-01-01', 1200.00), (1, '2024-01-02', 1500.00), (1, '2024-01-03', 1800.00), (2, '2024-01-01', 800.00), (2, '2024-01-02', 1100.00), (2, '2024-01-03', 1600.00), (3, '2024-01-01', 2000.00), (3, '2024-01-02', 1800.00), (3, '2024-01-03', 2400.00);查询语句:
SELECT dept_id, sale_date, sale_amount, SUM(sale_amount) OVER(PARTITION BY dept_id ORDER BY sale_date) AS dept_cum_amount FROM dept_daily_sales ORDER BY dept_id, sale_date;结果:
| dept_id | sale_date | sale_amount | dept_cum_amount |
|---|---|---|---|
| 1 | 2024-01-01 | 1200.00 | 1200.00 |
| 1 | 2024-01-02 | 1500.00 | 2700.00 |
| 1 | 2024-01-03 | 1800.00 | 4500.00 |
| 2 | 2024-01-01 | 800.00 | 800.00 |
| 2 | 2024-01-02 | 1100.00 | 1900.00 |
| 2 | 2024-01-03 | 1600.00 | 3500.00 |
| 3 | 2024-01-01 | 2000.00 | 2000.00 |
| 3 | 2024-01-02 | 1800.00 | 3800.00 |
| 3 | 2024-01-03 | 2400.00 | 6200.00 |
这里如果去掉ORDER BY sale_date,dept_cum_amount就会变成每个部门的整体总和,你拿到的是“每个部门全月总销售额”,而不是“逐日累计”。这一个ORDER BY之差,就是“静态分组汇总”和“动态累加”的本质区别。
3.2 案例二:按时间顺序计算移动累计,并计算占比
再给一个稍复杂点的需求:公司要看每个销售当天的业绩、当天业绩占整个公司当天总业绩的比例,以及个人的累计业绩。
这个需求如果没有窗口函数,你得用两个子查询加两个关联,SQL 写起来非常痛苦。有了窗口函数可以一次搞定:
SELECT sale_date, salesperson, sale_amount, SUM(sale_amount) OVER(PARTITION BY sale_date) AS daily_total, ROUND(sale_amount / SUM(sale_amount) OVER(PARTITION BY sale_date) * 100, 2) AS daily_pct, SUM(sale_amount) OVER(PARTITION BY salesperson ORDER BY sale_date) AS person_cum FROM sales_data ORDER BY sale_date, salesperson;同一个OVER()里面可以放不同的分区和排序组合,各自互不干扰。这一行代码里同时出现了“按日期分组算当天总业绩”、“按个人分组算累计业绩”和“自定义占比计算”,三种逻辑在同一条 SQL 中完成,这对业务报表来说是巨大的效率提升。
我在实际项目中经常把多个窗口函数放在同一个 SELECT 里,性能上通常是可接受的,因为相同分区排序定义可以被数据库优化器复用,不要一上来就拆成多个子查询。
3.3 案例三:用累计值做“首次达到目标”判断
累计值不仅仅用于展示,它还可以作为进一步逻辑判断的基础。比如运营给定目标金额 5000,需要知道每个销售哪一天首次完成了累计业绩超过 5000 的任务。
这时候可以把窗口查询作为子查询,再在外面筛选:
WITH cum_data AS ( SELECT salesperson, sale_date, sale_amount, SUM(sale_amount) OVER(PARTITION BY salesperson ORDER BY sale_date) AS cum_amount FROM sales_data ) SELECT salesperson, MIN(sale_date) AS first_reach_date FROM cum_data WHERE cum_amount >= 5000 GROUP BY salesperson;这个例子展示了一个常见技巧:窗口函数可以在子查询、CTE 中先算好,再供外部查询进一步过滤。很多人学窗口函数只停留在“能查出来”的层面,遇到嵌套使用就不知所措,其实只要理解“窗口计算发生在 SELECT 阶段,滤发生在 WHERE 之后”,就容易想通了——这也是为什么你不能在 WHERE 里直接引用窗口函数的别名。
4. 进阶:窗口框架(Frame)是理解累加的关键
4.1 默认窗口范围:为什么加上 ORDER BY 就变成累计值
我见过很多人在这一步栽跟头:明明只是给SUM() OVER(PARTITION BY ...)加了个ORDER BY,结果整个结果集的意义都变了。原因就是窗口框架的默认值发生了变化。
在 SQL 标准中,如果OVER()里只有PARTITION BY,没有ORDER BY,默认的窗口范围是整个分区,此时SUM()计算的就是分组总和。如果OVER()里有ORDER BY,默认的窗口范围就变成:
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW翻译过来就是“从分区开始到当前行”,而且这个范围是“范围”方式(RANGE),不是“行数”方式(ROWS)。
RANGE和ROWS的差别在于:RANGE会把排序字段值相同的行都包含进“当前行”的范围,而ROWS只严格按物理行数取。举个例子,如果有两行是同一天的订单,用RANGE做累计时,这两行会被当成同一层级处理,分别包含对方;用ROWS则严格按照行坐标,逐行推进。
这个差异导致的坑很经典:当你用ORDER BY sale_date对多笔同日订单做累计求和时,用默认的RANGE,两条同一天的记录会拿到相同的累计值(都包含对方);如果你期待的是逐行累加,就得显式指定ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。
4.2 手动指定窗口框架:ROWS 和 RANGE 的差异
显式定义窗口框架的语法是:
SUM(sale_amount) OVER( PARTITION BY salesperson ORDER BY sale_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW )这里的关键词ROWS表示按物理行取范围,CURRENT ROW是当前行,UNBOUNDED PRECEDING是分区起点。用ROWS的写法保证了逐行累加,不管你有多少笔同日期订单,每一行都会把前面的行加上当前行本身的金额。
对比一下RANGE:
SUM(sale_amount) OVER( PARTITION BY salesperson ORDER BY sale_date RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW )当有两行同日期数据时,RANGE模式下两行会互相包含,累计结果完全一致。这在业务上其实也有用意,比如“按天累计”时,同一天的所有订单一起累计,体现的是“到这一天为止”而不是“到这一个订单为止”。
两种模式没有绝对好坏,关键看业务口径。我曾经在线下数仓项目里遇到过一种情况:销售明细表里同一个销售同一天有多笔订单,运营想要的是“按订单粒度累加”,结果因为默认RANGE导致同日订单累计值相同,以为代码写错了,排查了半天。后来我把默认框架明确改成ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,问题立刻消失。
这条经验很重要:在写累计类窗口函数时,我强烈建议你显式写明窗口框架,不依赖数据库的默认行为,这样一方面避免不同数据库实现的差异,另一方面也让读 SQL 的人一眼知道你预期的口径是什么。
5. 常见坑与排查实录
5.1 坑一:ORDER BY 字段有重复值导致累计结果不符合预期
这是窗口函数使用中出现频率最高的问题,网上讨论也最多,但很多帖子都讲得含含糊糊。我直接给结论:
- 如果你的业务口径是“按物理行逐行累加”,使用
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW; - 如果想让相同排序键值的行共享同一个累计值,使用
RANGE或省略不写。
在实践中,绝大多数业务需求是前者,即严格按行累加。尤其当你ORDER BY的字段不是唯一键时,这个坑几乎百发百中。
我之前处理过一个库存流水表,ORDER BY operation_time,同一秒内有多笔入库,默认RANGE导致累计库存数跳变,后期对不上账。最后在两个地方做了修正:一是给ORDER BY追加一个唯一字段(比如自增 ID),保证排序稳定;二是显式使用ROWS窗口框架。
5.2 坑二:窗口函数结果集中混入 GROUP BY 分组的错误写法
有时候会被要求“既要明细,又要分类汇总”,新手常犯的错误是先把分组结果算出来,再去关联明细,导致最后的 SUM 值被重复计算多次。
比如:
SELECT a.sale_date, a.salesperson, a.sale_amount, SUM(b.person_total) AS wrong_total FROM sales_data a JOIN ( SELECT salesperson, SUM(sale_amount) AS person_total FROM sales_data GROUP BY salesperson ) b ON a.salesperson = b.salesperson GROUP BY a.sale_date, a.salesperson, a.sale_amount;这种情况在旧版本数据库里逻辑混乱,用窗口函数却能非常优雅地解决:
SELECT sale_date, salesperson, sale_amount, SUM(sale_amount) OVER(PARTITION BY salesperson) AS correct_total FROM sales_data;窗口函数不需要 JOIN、不需要 GROUP BY 折叠行,只添加一列聚合值。这也是为什么我遇到类似的“明细+汇总”组合需求时,第一选择就是窗口函数而不是子查询 JOIN。
5.3 坑三:OVER() 里不写 PARTITION BY 导致全局累计
如果你写SUM(sale_amount) OVER(ORDER BY sale_date),没有PARTITION BY,那整个表会被当成一个大组,按日期全局累计。
这在某些场景下是有意为之(比如公司总的累积销售额趋势),但很多时候是新手的无心之失,尤其当表里有多个部门、多个销售时,全局累计会得到毫无业务意义的数据。数据量一大,这个错很难通过抽查发现。
我建议:凡是看到SUM() OVER(ORDER BY ...)而思绪里没有任何分组的 SQL,都要停下来确认到底要不要全局累计。如果是多实体数据表,通常都需要PARTITION BY一个业务主体字段。
5.4 坑四:窗口函数和 WHERE 的先后顺序
窗口函数在 SQL 执行逻辑中位于WHERE条件之后、ORDER BY之前。这意味着:
- 你不能直接在
WHERE中引用窗口函数的计算结果; - 如果你想筛选窗口计算的产物(比如累计值大于某个数),需要把窗口查询包一层子查询或 CTE。
经常有初学者写出这种 SQL:
SELECT salesperson, sale_date, SUM(sale_amount) OVER(PARTITION BY salesperson ORDER BY sale_date) AS cum_amount FROM sales_data WHERE cum_amount > 1000;在多数数据库里,这会直接报错,提示“未知列 cum_amount”。正确写法是:
SELECT * FROM ( SELECT salesperson, sale_date, SUM(sale_amount) OVER(PARTITION BY salesperson ORDER BY sale_date) AS cum_amount FROM sales_data ) t WHERE cum_amount > 1000;这种“包一层”的思路在窗口函数使用中非常普遍,你需要习惯它。
6. 性能优化与索引设计心得
窗口函数用起来爽,性能却是另一个问题。如果一个表有几百万行,PARTITION BY加上ORDER BY的累计计算,会要求数据库对每个分区都维持一个排序状态,代价不小。
我总结几条实战调优经验:
第一,尽量在源数据层面压缩扫描范围。比如只取近 30 天数据、只取特定部门的记录,用 WHERE 先将数据规模缩小,再做窗口计算。窗口函数虽说是 SQL 标准功能,但对数据量的敏感度比普通聚合高得多。
第二,窗口函数依赖排序时,合理的索引能让数据库省去反复排序的开销。如果业务上频繁执行类似SUM(...) OVER(PARTITION BY dept_id ORDER BY sale_date)这样的查询,在(dept_id, sale_date)上建立一个联合索引会很有帮助。不要过度索引,但针对高频窗口查询建索引是合理的优化手段。
第三,能用单个窗口函数算完的不要拆成多个窗口函数嵌套。同一分区排序定义可以放在一个OVER()里做多次不同聚合,而不是写多个OVER(),因为数据库可以重用排序结果。
比如:
SELECT sale_date, sale_amount, SUM(sale_amount) OVER(PARTITION BY dept_id ORDER BY sale_date) AS cum_amount, COUNT(sale_id) OVER(PARTITION BY dept_id ORDER BY sale_date) AS cum_count比写成两个独立子查询后关联要高效得多。
第四,如果只是求“整组总和”而不需要累计,那就不要加ORDER BY。没有ORDER BY的窗口函数会比有ORDER BY的少做一次排序,在超大分组上性能差异可能非常明显。
第五,注意内存和临时表空间。排序量极大时,数据库可能落盘到临时文件,性能断崖式下降。遇到这种情况,优先检查数据过滤条件是不是太弱,或者把一些不需要窗口函数参与的字段先过滤掉。
7. 最后补充几个实用技巧
如果只记住这篇文章里的几句话,我建议记这几句:
一、SUM() OVER(PARTITION BY ... ORDER BY ...)等于“分组 + 排序 + 逐步累计”,三件事同时发生;去掉ORDER BY就只是“分组总和”。
二、遇到累计值出现“同排序值同结果”的情况,不要第一时间觉得数据库的问题,先想想RANGE和ROWS的默认差异。
三、窗口函数的结果不能直接在WHERE中引用,必须套一层子查询或 CTE。
四、写出窗口函数 SQL 后,一定要拿几条数据手算验证一下结果,尤其是累计值的边界。我见过太多人写完不验证就上线,结果数仓报表里数字对不上,排查到凌晨。
再给一个跟日期相关的实际技巧:如果PARTITION BY想按“月份”分组,但表里只有sale_date字段,有两种常见处理方式:
SUM(sale_amount) OVER(PARTITION BY DATE_FORMAT(sale_date, '%Y-%m') ORDER BY sale_date)或者提前在数据预处理阶段生成一个month_id字段,用month_id分区。后者对跨数据库兼容性更好,也方便后续GROUP BY和窗口函数共用同一个月份字段。
关于PARTITION BY多个字段的情况,比如“部门 + 销售”两个维度,直接写:
SUM(sale_amount) OVER(PARTITION BY dept_id, salesperson ORDER BY sale_date)这是一个非常常见的多维分区写法,每一组都是独立的累计序列,互不影响。
我自己在实际项目中最常用的窗口函数其实不是 RANK 或 LAG,而是SUM() OVER(PARTITION BY ... ORDER BY ...)。它在做留存分析、漏斗分析、累计达成率时都是主力工具。现在如果再遇到业务方说“给我一张表,每一行都要带累计值”,我基本不用思考,条件反射写出来的就是这个函数。
希望这篇文章能帮你在面对SUM() OVER(PARTITION BY ... ORDER BY ...)时,不再靠背方案,而是真正明白它背后分步执行的逻辑。表结构变化、排序字段变化、框架模式变化,你都能自然知道结果会怎么变,这才是掌握了这个函数。