SQL里的sum() over(partition by ... order by ...),我见过太多人栽在它上面了。前两天还有人在我这边交流,说想算一个"按区域累计的销售额",结果写出来的SQL出来的数字和自己手算的完全对不上。一看他的写法:sum(amount) over(partition by region),没带order by。这问题就出在这个细节上——带不带order by,窗口函数算出来的东西完全是两回事。这篇就把sum() over()的底层逻辑和实战场景拆开揉碎讲清楚,保证你看完能直接用。
1. 一个让人懵掉的SQL:sum() over(partition by)到底算出了什么
1.1 先用一张销售表把场景立起来
空说理论没意思,我们造一张销售明细表,后面所有示例都从这张表出发:
CREATE TABLE sales_detail ( sale_date DATE, region VARCHAR(20), product VARCHAR(50), revenue DECIMAL(10,2) ); INSERT INTO sales_detail VALUES ('2024-01-05', '华东', '智能手环', 1200.00), ('2024-01-05', '华东', '蓝牙耳机', 800.00), ('2024-01-06', '华东', '智能手环', 1500.00), ('2024-01-06', '华东', '蓝牙耳机', 900.00), ('2024-01-05', '华北', '智能手环', 1100.00), ('2024-01-05', '华北', '台灯', 300.00), ('2024-01-06', '华北', '智能手环', 1300.00);这张表一共7条明细,每一条代表一次销售。我们先跑一个最基础的窗口查询:
SELECT region, product, revenue, SUM(revenue) OVER(PARTITION BY region) AS region_revenue FROM sales_detail;结果长这样(为了演示,我按区域和日期排了一下):
| region | product | revenue | region_revenue |
|---|---|---|---|
| 华东 | 智能手环 | 1200.00 | 4400.00 |
| 华东 | 蓝牙耳机 | 800.00 | 4400.00 |
| 华东 | 智能手环 | 1500.00 | 4400.00 |
| 华东 | 蓝牙耳机 | 900.00 | 4400.00 |
| 华北 | 智能手环 | 1100.00 | 2700.00 |
| 华北 | 台灯 | 300.00 | 2700.00 |
| 华北 | 智能手环 | 1300.00 | 2700.00 |
注意看:华东这四行的region_revenue全是4400,华北三行全是2700——这就是"华东区域的总销售额"和"华北区域的总销售额",但每一行明细都保留着,没有折叠成两条,这跟GROUP BY最根本的区别。
1.2 为什么窗口函数能"行行都带汇总值"
很多人一开始不理解:SUM(revenue)不是聚合函数吗?聚合函数不是应该把多行压成一行吗?怎么这里每行都算了一遍?
关键在于OVER()这一部分。OVER(PARTITION BY region)定义了一个窗口,它把表按照region切成了若干份。你可以把窗口想象成"每一行旁边临时挂了一个只含本组数据的迷你表格",SUM(revenue)是在这个迷你表格上做的聚合,算出来的结果再粘贴回这一行。所以它的执行过程不是"聚合后折叠",而是"聚合后复制到组内每一行"。
这一点特别适合用来做明细与汇总并存的报表。如果只靠GROUP BY,你得先分组去算汇总,再回头JOIN明细,SQL写起来长,性能也差。窗口函数一次扫描就能同时输出明细和汇总,这也是它成为分析类SQL标配的原因。
注意:
PARTITION BY不是必选项。如果写成SUM(revenue) OVER(),那就意味着窗口没有被切分,整张表是一个组,每一行拿到的都是全表的销售额总和。后面讲占比计算的时候,这个写法会非常有用。
1.3 先记住最重要的一句话
OVER()管的是"计算范围",SUM()管的是"在这个范围内怎么算"。范围切到哪,聚合就算到哪。partition by xx是在竖向上切组,order by xx是在组内横向上定顺序——而顺序一旦定了,聚合方式又会发生变化。这就是下一节要展开的核心。
2. order by才是分水岭:从"分组求和"到"逐行累计"的核心差异
2.1 同样写sum() over(partition by),结果却完全不同
还是同一张表,我们把SQL改成:
SELECT region, sale_date, product, revenue, SUM(revenue) OVER(PARTITION BY region ORDER BY sale_date) AS running_revenue FROM sales_detail ORDER BY region, sale_date;结果变成这样:
| region | sale_date | product | revenue | running_revenue |
|---|---|---|---|---|
| 华东 | 2024-01-05 | 智能手环 | 1200.00 | 2000.00 |
| 华东 | 2024-01-05 | 蓝牙耳机 | 800.00 | 2000.00 |
| 华东 | 2024-01-06 | 智能手环 | 1500.00 | 4400.00 |
| 华东 | 2024-01-06 | 蓝牙耳机 | 900.00 | 4400.00 |
| 华北 | 2024-01-05 | 智能手环 | 1100.00 | 1400.00 |
| 华北 | 2024-01-05 | 台灯 | 300.00 | 1400.00 |
| 华北 | 2024-01-06 | 智能手环 | 1300.00 | 2700.00 |
之前的4400和2700,现在变成了逐行累计:华东1月5日的两行是2000(1200+800),1月6日的两行是4400(2000+1500+900)。华北同理:1月5日是1400,1月6日变成2700。
这就是带order by与不带order by的本质区别:
- 不带
ORDER BY:SUM() OVER(PARTITION BY region)= 组内总和。 - 带上
ORDER BY:SUM() OVER(PARTITION BY region ORDER BY sale_date)= 从组内第一行累加到当前行的累计和。
2.2 底层是怎么算的:窗口的"默认边界"问题
很多数据库的SQL优化器在处理ORDER BY时,会为窗口定义一个默认的窗口框架(frame):RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。翻译成人话就是——从整个分组的开头开始,一直算到当前行。
所以"累计和"不是数据库随手算出来的,它严格遵循"起止边界"的规则。边界起点是UNBOUNDED PRECEDING(分组的第1行),终点是CURRENT ROW(当前行)。SUM()每算一行,就把范围往后面推进一行,于是就有了逐行累加的效果。
2.3 同一天有多行时,会有一个隐蔽的"合并"行为
注意看上面华东1月5日那两行:智能手环1200、蓝牙耳机800,它们的running_revenue都是2000。为什么不是第一行1200、第二行2000?
这是因为默认框架是RANGE BETWEEN ...,不是ROWS BETWEEN ...。RANGE按"排序键的值"来界定范围,两行的sale_date都是1月5日,排序键值相同,会被当成同一行来处理——1200和800这两行共享同一个累计结果,等于系统把"值相同"的行合并成一个逻辑位置了。
如果你想实现"物理上每往前挪一行就累加一次",哪怕排序键相同也要逐行累加,那就必须显式写:
SUM(revenue) OVER( PARTITION BY region ORDER BY sale_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW )这段用了ROWS,就会严格按行的物理位置来推算:1月5日智能手环那行是1200,蓝牙耳机那行是2000。在很多金融对账、库存流水场景里,这个物理位置顺序特别重要,千万别默认用RANGE。这是新手最容易踩的隐藏坑,测试数据量小的时候根本看不出来,一旦并发加重、同一秒出现多笔交易,累加数就"看起来不太对"。
3. 三种最常见的sum() over()实战写法,直接照着套
3.1 场景一:算"每个分区占总体的比例"
这是sum() over()最经典的使用场景之一。需求是:看每个区域、每个产品的销售额占整体销售额的百分比。
SELECT region, product, revenue, SUM(revenue) OVER() AS total_revenue, ROUND( revenue / SUM(revenue) OVER() * 100, 2 ) AS pct FROM sales_detail;关键点在SUM(revenue) OVER()——不加PARTITION BY也不加ORDER BY,窗口覆盖全表,每一行算出来的都是全表总额7200。然后拿当前行的revenue除以这个总额,就是占比。
如果只想看每个区域内各产品的占比,就把窗口改成OVER(PARTITION BY region):
SELECT region, product, revenue, ROUND( revenue / SUM(revenue) OVER(PARTITION BY region) * 100, 2 ) AS region_pct FROM sales_detail;这种写法可以用来做区域内部产品结构分析:哪个区域里哪类产品卖得最好、占比是否健康。整个计算不需要子查询、不需要JOIN,SQL非常清爽。
3.2 场景二:跑批任务里常用的"按时间累计"
再比如,你在做月度销售进度看板,需要回答"截至某一天,本月累计销售额是多少"。用窗口函数:
SELECT sale_date, region, revenue, SUM(revenue) OVER( PARTITION BY region, DATE_TRUNC('month', sale_date) ORDER BY sale_date ) AS month_running FROM sales_detail;这里我给PARTITION BY加了两个字段:region和DATE_TRUNC('month', sale_date)。意思是窗口先按"区域+月份"切组分桶,组内再按日期顺序做逐日累计。每个月的第一天累计值从0开始重新计算,这样就能直接看某个区域某月每天都冲到多少了。
我做报表时经常把这个结果嵌到子查询里,再配一个"当月目标值"字段,相减就能算进度缺口:
WITH running AS ( SELECT sale_date, region, SUM(revenue) OVER( PARTITION BY region, DATE_TRUNC('month', sale_date) ORDER BY sale_date ) AS month_running FROM sales_detail ) SELECT sale_date, region, month_running, target_value, month_running - target_value AS gap FROM running LEFT JOIN monthly_target t ON running.region = t.region AND DATE_TRUNC('month', running.sale_date) = t.target_month;3.3 场景三:用累计值算"较上期的净增量"
有时候你已经统计出每天的累计值,想看看某一天比前一天新增了多少。如果拿"累计值对累计值"做减法逻辑上绕,更直接的做法是:用LAG()去读"上一个分区的累计值"。
WITH daily_sales AS ( SELECT sale_date, revenue, SUM(revenue) OVER(ORDER BY sale_date) AS running_total FROM sales_detail ) SELECT sale_date, running_total, LAG(running_total, 1) OVER(ORDER BY sale_date) AS prev_total, running_total - LAG(running_total, 1) OVER(ORDER BY sale_date) AS daily_increment FROM daily_sales;这里有个小技巧:LAG(running_total, 1)取的是上一行累计值,两个累计值相减,正好等于这一天的增量。很多ETL任务里"增量计算"都是用这个思路做的——不需要再去关联前一天的明细表,性能上省一大截。
4. 窗口边界搞不清楚,累计数就会悄悄出错——ROWS/RANGE详解
4.1 默认边界的"隐形规则"再强调一遍
ORDER BY存在时,默认窗口框架是RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。RANGE的含义是:排序键值相等的行视为同一行,框架会包含它们全部。对于销售额累计这种需求,如果sale_date一直精确到天,同一天多条记录就会合并不分。这是业务报表里最常见的数据对不上原因。
要精确到每一行物理累加,写ROWS;对于求日均价、库存快照这类逻辑,可能RANGE反而更合适。理解并显式声明窗口边界,比"记语法"重要得多。
4.2 滚动区间:算最近3个月累计、最近7天汇总
如果我想看"截至当前行的最近3条销售记录的累计金额",窗口可以写成:
SELECT sale_date, product, revenue, SUM(revenue) OVER( ORDER BY sale_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS last_3_rows_total FROM sales_detail;ROWS BETWEEN 2 PRECEDING AND CURRENT ROW表示窗口从当前行的前2行开始,到当前行结束。这个写法常用于移动平均、滚动汇总。需要注意:前2行如果不存在(比如第一行),数据库会从实际存在的首行开始算,窗口收缩,不会报错。这是SQL规则允许的,但你要知道结果为何和预期不同。
如果按"时间值"滚动,比如"最近7天销售额总和",用RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW(不同数据库方言有差异),这类写法在零售分析、库存预警里都很常用。
4.3 多个窗口函数同时用,怎么共享同一个窗口定义
一个查询里既要累计数、又要移动平均、还要占比,如果每个都重新写一遍窗口定义,SQL不仅长而且容易漏改条件。可以在OVER()里重复写,也可以用WINDOW子句把窗口定义抽出来:
SELECT sale_date, revenue, SUM(revenue) OVER w AS running_total, ROUND(AVG(revenue) OVER w, 2) AS running_avg FROM sales_detail WINDOW w AS (ORDER BY sale_date);简洁、可读性高、维护成本低。多注意一点:WINDOW子句在MySQL 8.0、PostgreSQL、SQL Server里都支持,老版本数据库不一定认。
4.4 调试窗口函数的一个笨但有效的办法
窗口函数写得再花哨,最终要对上数才有意义。我的调试习惯是三步走:
- 选一个很小的分区,比如时间范围只包含3~5天,把明细行数控制在10行以内。
- 手工在Excel里复算一遍,看看每个窗口结果对不对。
- 把
ROWS/RANGE两种边界分别跑一遍,对比差异,确认自己到底要哪种。
很多"看起来正确其实错误"的SQL,都是在这种小样本对比下暴露出来的。不要嫌麻烦,窗口函数一旦在数据量大的生产环境里错,排查代价远比这个高。
5. sum() over()实战避坑记录:我踩过的那几个雷
5.1 第一大坑:WHERE子句里直接用窗口函数别名
窗口函数是在SELECT阶段计算的,WHERE的过滤发生在它之前。所以下面这段SQL会直接报错:
SELECT region, revenue, SUM(revenue) OVER(PARTITION BY region) AS region_total FROM sales_detail WHERE region_total > 3000;region_total这个别名在WHERE里根本不存在。你需要包一层子查询:
SELECT * FROM ( SELECT region, revenue, SUM(revenue) OVER(PARTITION BY region) AS region_total FROM sales_detail ) t WHERE region_total > 3000;这个"包一层"的思路不仅适用于窗口函数,所有在SELECT阶段生成的别名,想继续过滤/排序,都必须先包子查询。这是我见过新手报错频率最高的一条。
5.2 第二大坑:大分区时性能明显下滑
窗口函数要在分区内做排序和累积计算。如果你的PARTITION BY列区分度很低——比如只有两个区域,每个分区有上千万行——窗口函数需要把这上千万行先按order by排好序,内存和临时磁盘空间会吃得很厉害。
实测下来比较有效的优化手段:
- 尽量在
PARTITION BY和ORDER BY涉及的列上建联合索引,让排序直接走索引。 - 查询先通过
WHERE把无关数据过滤掉,缩小参与窗口计算的数据集,比让窗口函数在大数据集上跑完再过滤快得多。 - 如果只是要百分比和累计值,考虑先用
GROUP BY把数据聚合成较小的粒度,再在聚合结果上开窗。
5.3 第三大坑:NULL值悄然改变累计结果
SUM()会跳过NULL,不会把它当成0,也不会报错。这在某些场景是好事,但当你拿"累计结果 - 上个结果 = 增量"时,只要中间的revenue有NULL,增量就可能"看上去少了一段"。比如某天的销售额NULL,累计值不变,日增量算出来是0,但真实情况可能是"缺数了"。
处理办法:在进入窗口计算前用COALESCE(revenue, 0)把NULL转成0,这样累计值才是连续的。同时排查数据源为什么会出现NULL,窗口函数不会替你诊断业务问题。
5.4 第四大坑:同值排序导致窗口结果不稳定
很多数据库的排序并不具备完全唯一性。当ORDER BY的列存在大量重复值(比如同一天好几万笔订单),而你没有在ORDER BY后面追加一个唯一键(比如订单ID),那同一组数据在不同版本优化器下执行,行的物理顺序可能不同,累计结果就可能有细微差异。
解决方案很简单:ORDER BY里多带一个唯一性字段:
SUM(revenue) OVER( PARTITION BY region ORDER BY sale_date, order_id )这样窗口内的行序是确定的,累计结果也是确定性的。这个细节在大数据平台(Hive、Spark SQL)上尤其重要——分布式环境下行序不稳定是常态。
5.5 数据库方言差异也别忽略
主流数据库对窗口函数的支持程度略有差异:
| 数据库 | 支持情况 | 主要注意点 |
|---|---|---|
| MySQL 8.0+ | 完整支持 | 8.0之前完全不支持窗口函数 |
| PostgreSQL | 完整支持 | 兼容性好,WINDOW子句写起来顺手 |
| SQL Server | 完整支持 | 老版本不支持某些ROWS边界写法 |
| Oracle | 完整支持 | 注意与ROWNUM混用时的语义变化 |
| Hive/Spark SQL | 完整支持 | 分布式环境下排序确定性需要额外关注 |
| SQLite 3.28+ | 部分支持 | 窗口函数实现较有限,专项检测 |
如果你的项目还在用MySQL 5.7或Oracle老版本,sum() over()可能根本跑不了。上生产前先确认版本,这比临场换SQL方案成本低得多。
我自己的经验是:窗口函数是分析型SQL里性价比最高的一类功能。你不需要学很多函数,把sum() over()吃透,就已经能解决报表开发里相当大比例的"分组汇总""逐行累计""占比计算"需求。刚上手时最容易卡住的不是语法本身,而是"这个函数算出来到底代表哪种逻辑"——先从小数据集上把边界、ORDER BY、ROWS/RANGE这些概念验证明白,再去处理大表,基本上就不会翻车。上面那些坑,大部分是我在实际报表开发里一步一步踩出来的,写成文字分享出来,希望对正在折腾窗口函数的人有点帮助。