1. 从一张报表需求说起
我之前带团队处理过一个电商数据看板的需求,其中有一张表专门存每天每个店铺的销售额明细,字段很简单:shop_id(店铺编号),sale_date(销售日期),amount(销售额)。业务方提了一个看起来特别朴素的要求:想把这张明细表导出来,同时给每一行加上一列“该店铺从月初到当前日期的累计销售额”。
当时团队里刚入职没多久的同事A同学,上来就准备写子查询,思路也合理:先按店铺和日期维度聚合出每日销售额,然后再关联一张子查询表,子查询里用SUM(amount)配合GROUP BY shop_id去汇总。他写出来的SQL确实能跑出结果,但那段SQL我看了之后只觉得头大,嵌套了三层,而且只是新增一列累计值就要把整个明细表自关联一次,等后面数据量从几十万涨到几千万,这种写法基本就废了。
后来我给他改成了SUM(amount) OVER(PARTITION BY shop_id ORDER BY sale_date),三行代码解决问题,逻辑一目了然,执行计划也干净了非常多。他看完之后愣了半天,说原来SQL还能这么写。其实这就是窗口函数里最经典也最实用的一个用法:SUM() OVER(PARTITION BY ... ORDER BY ...),在数据分析、报表开发、取数场景里几乎天天用到。
这篇文章就把这个函数从语法、原理到实际场景完整拆一遍,最后再把我踩过的坑和排查经验也一起放进来。不管是刚学SQL的新手,还是已经写了好几年SQL但平时主要用GROUP BY的开发者,这篇文章都适合先收藏再慢慢看。
2. 语法拆解:每个部分到底在干什么
2.1 函数全貌与参数含义
先看完整语法结构:
SUM(expr) OVER ( [PARTITION BY partition_expr_list] [ORDER BY order_expr_list] [frame_clause] )这里面有三个核心部分,逐个说清楚:
SUM(expr):这个不用多解释,就是求和函数,expr通常是数值字段,比如amount、score、price这种。PARTITION BY:按什么维度分组。字面意思是“分区”,实际作用就是告诉数据库“把数据按这几列的值分成若干组,每一组内部独立计算”。比如PARTITION BY shop_id,就是把所有数据按店铺分成不同组,每个店铺各自算各自的,互不干扰。ORDER BY:在每一组内部,按指定列排序。这个ORDER BY是所有累计类窗口函数的关键,它决定了“累加到当前行之前,到底包含哪些行”。frame_clause:窗口边界定义,也就是进一步限定“累计范围”。最常用的就是ROWS BETWEEN ... AND ...这种写法,后面专门讲。
要理解PARTITION BY和ORDER BY配合的效果,我习惯用一个生活化类比:想象全校成绩排名,PARTITION BY 班级就是把所有学生按班级分成不同队列,ORDER BY 总分 DESC就是在每个队列里按总分从高到低排队。此时SUM(总分) OVER(PARTITION BY 班级 ORDER BY 总分 DESC)的意思是:在每一个班级队列里,从排第一名的同学开始,一直累加到当前同学为止,得到的就是“班级内排名前N名同学的总分之和”。随着行号往后走,这个值一直在增大。
2.2 为什么它和GROUP BY完全不同
很多新手会把SUM(...) OVER(PARTITION BY ...)和GROUP BY ... SUM(...)搞混,我干脆把两者的核心区别列成一张对比表:
| 对比维度 | 普通聚合SUM + GROUP BY | 窗口函数SUM + OVER |
|---|---|---|
| 返回行数 | 每组返回一行 | 每一行原样保留,同时附带计算结果 |
| 数据粒度 | 被压缩到组级别 | 明细粒度完全保留 |
| 是否依赖排序 | 不依赖 | 依赖ORDER BY决定累计范围 |
| 常见用途 | 生成汇总报表 | 在明细旁边“贴”一列统计值 |
记住一句话就行:GROUP BY是把你需要聚合的行“合并成一行”,窗口函数是“每一行还在原地不动,只是在旁边多了一列聚合结果”。这也是为什么窗口函数特别适合做“明细+汇总”同时展示的场景。
在SQL执行顺序上,窗口函数是在WHERE、GROUP BY、HAVING都执行完之后才计算,并且发生在ORDER BY(这里指SQL最外层的排序)之前。这意味着你在窗口函数里不能用WHERE已经过滤掉的字段做判断,但窗口函数算出来的结果可以继续被外层ORDER BY使用。这个顺序理解到位,很多奇怪的报错就都解释通了。
2.3 执行计划里的真实处理过程
为了让你更直观地理解数据库内部怎么处理,我给你拆一下执行流程。假设有下面这张表t_sales:
shop_id sale_date amount A 2024-01-01 100 A 2024-01-02 150 A 2024-01-03 80 B 2024-01-01 200 B 2024-01-02 120执行:
SELECT shop_id, sale_date, amount, SUM(amount) OVER(PARTITION BY shop_id ORDER BY sale_date) AS cum_amount FROM t_sales;数据库在处理OVER(PARTITION BY shop_id ORDER BY sale_date)的时候,会经历这样的过程:先把整张表按shop_id分好区,A店铺一组、B店铺一组;然后在每个分区内部,按sale_date升序排列;接下来从每个分区的第一行开始,一行一行往下走,每走一行就把amount累加到“当前累计变量”里,然后把这个变量值写到当前行的cum_amount列。
所以最终结果是:
shop_id sale_date amount cum_amount A 2024-01-01 100 100 A 2024-01-02 150 250 A 2024-01-03 80 330 B 2024-01-01 200 200 B 2024-01-02 120 320注意看B分区的cum_amount:第一行是200,第二行是320。它没有受到A分区任何影响,这就是PARTITION BY把数据隔离开的效果。假如把PARTITION BY去掉,只写ORDER BY sale_date,那结果就是全局不分区的累计,A和B的金额全部混在一起累加。
3. 实战场景一:分组累计与占比分析
3.1 销售明细里加累计列
上面这个例子其实就是最经典的“累计值”需求之一,在电商、零售、财务对账等场景里遍地都是。
我再拓展一个更贴合实际业务的写法。当表里存在一天有多条记录的时候,如果直接对明细行做累计,会出现“同一天里前一行没把当天的其他行包含进去”的问题。比如sale_date相同,有两条记录,金额分别是100和50,按日期排序后先遇到100那条,累计出来100,再遇到50那条,累计出150。这结果看起来没问题,但如果你在同一天里把两条记录的累计值单独拎出来看,第一条显示的是“截止当前行之前的累计值”,而不是“截止当天结束的累计值”。
解决办法通常有两种:
第一种,先把每天的数据按店铺汇总成一行,再做窗口累计:
SELECT shop_id, sale_date, daily_amount, SUM(daily_amount) OVER(PARTITION BY shop_id ORDER BY sale_date) AS cum_amount FROM ( SELECT shop_id, sale_date, SUM(amount) AS daily_amount FROM t_sales GROUP BY shop_id, sale_date ) t ORDER BY shop_id, sale_date;第二种,直接用RANGE BETWEEN把窗口边界扩展到“当前日期”的所有行。这个属于窗口边界的进阶用法,我在第5部分详细展开。
实际工作中,只要维度对得上、性能也扛得住,我建议先做日粒度汇总再做累计。这样逻辑最直观,团队其他人接手看代码也一眼能懂。
3.2 算累计占比的一个高效姿势
累计值最大的用途之一,就是算“贡献度”或者“累计占比”,比如帕累托分析。业务上经常问:目前销量排名前20%的店铺,贡献了多少销售额?
用窗口函数可以这样算:
SELECT shop_id, total_amount, SUM(total_amount) OVER(ORDER BY total_amount DESC) AS running_sum, SUM(total_amount) OVER(ORDER BY total_amount DESC) AS running_sum, ROUND( SUM(total_amount) OVER(ORDER BY total_amount DESC) / SUM(total_amount) OVER() * 100, 2 ) AS running_percent FROM ( SELECT shop_id, SUM(amount) AS total_amount FROM t_sales GROUP BY shop_id ) t ORDER BY total_amount DESC;注意这里有一个关键写法:SUM(total_amount) OVER()后面没有跟ORDER BY,也没有跟PARTITION BY,表示“整个结果集作为一个分区,不做任何排序,直接求总合计”。这样每次拿累计值去除以总合计,得到的就是累计占比。这也是我经常用的一个技巧:SUM(...) OVER()来替代标量子查询。
比如结果可能是:A店铺累计销售额500万,总销售额2000万,累计占比25%;B店铺累计400万,累计占比45%(因为A和B加一起900万,除以2000万)。越往后,这个累计占比越接近100%,你很容易就能从中看出头部店铺的集中度。
3.3 多维度分组同时累计
PARTITION BY后面是支持多个字段的,用逗号隔开就行。比如订单明细表里,既要按“区域”累计,又要按“产品线”累计,可以写:
SELECT region, product_line, sale_date, amount, SUM(amount) OVER(PARTITION BY region, product_line ORDER BY sale_date) AS region_product_cum FROM t_order_detail ORDER BY region, product_line, sale_date;这种写法在业务报表里非常实用。还有一种是“既要维度累计,又要全局累计”,可以直接在一句SQL里写两个窗口函数:
SELECT region, product_line, sale_date, amount, SUM(amount) OVER(PARTITION BY region, product_line ORDER BY sale_date) AS group_cum, SUM(amount) OVER(ORDER BY sale_date) AS global_cum FROM t_order_detail ORDER BY sale_date;每个窗口函数有自己独立的PARTITION BY和ORDER BY,互不干扰,数据库会分别计算。这个特性特别适合在明细报表中同时展示“本组累计”和“整体累计”,不用再关联两次表。
4. 实战场景二:移动窗口与动态范围
4.1 什么是窗口边界
很多文章讲SUM() OVER(PARTITION BY ... ORDER BY ...)时,只讲默认行为,也就是“从分区起点到当前行”。但默认行为不是唯一的,而且恰恰是在边界设置上,藏着大量容易踩的坑。
MySQL从8.0开始、很多主流的MPP数据库(比如某些分布式分析型数据库)都支持窗口函数完整语法,其中就包括显式指定窗口边界:
SUM(amount) OVER( PARTITION BY shop_id ORDER BY sale_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW )ROWS BETWEEN ... AND ...就是窗口边界。比较常用的几种组合:
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW:从分区第一行到当前行,这是“默认累计”的一种显式写法。ROWS BETWEEN N PRECEDING AND CURRENT ROW:从当前行往前数N行到当前行,适合算“最近N笔订单的总金额”。ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING:从当前行到分区最后一行,适合算“剩余量”。ROWS BETWEEN N PRECEDING AND N FOLLOWING:以前后N行为窗口,适合算移动平均或移动合计。
4.2 移动窗口实战:最近3天销售额之和
业务上经常要算“最近3天销售额”,如果用普通分组逻辑写,往往要处理日期表的补全、缺失日期的处理,非常麻烦。用窗口函数可以非常优雅地写出来:
SELECT shop_id, sale_date, amount, SUM(amount) OVER( PARTITION BY shop_id ORDER BY sale_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS last_3_days_sum FROM t_sales ORDER BY shop_id, sale_date;注意,这里说的是“最近3行”,不是“最近3个自然日”。如果你的表里每天只有一行,那两者刚好等价;但如果表里有缺失日期或者一天多行,那“最近3行”和“最近3天”结果就会有偏差。遇到这种业务口径比较严格的情况,要么提前把日期序列补全,要么改用RANGE BETWEEN INTERVAL 2 DAY PRECEDING AND CURRENT ROW,这个语法在MySQL和很多数据库中是可以支持日期区间的。
但我必须提醒一句:RANGE配合INTERVAL对数据库的支持度和索引利用率差异很大,大数据量下性能可能比ROWS差不少。能用ROWS就用ROWS,实在不行再上RANGE,这是经验之谈。
4.3 一个容易被忽略的边界问题
写移动窗口的时候,最容易被忽略的就是分区内前几行的处理。比如ROWS BETWEEN 2 PRECEDING AND CURRENT ROW,每个分区的第1行前面没有两行可用,数据库会自动把窗口缩小,实际只统计第1行自身。第2行则统计第1行+第2行。这种“自动收缩”不是错误,但你要知道,否则看到前几行的累计值明显“偏小”时会怀疑自己SQL写错了。
从业务解读角度,这种前几行累计值偏小是合理的,因为数据还没积累够。比如某店铺刚开张前几天,最近3天销售额只能算到开张以来的部分。这个口径要在报表说明里写清楚,交给业务方时不至于被追问。
4.4 用窗口函数算“剩余库存”和“目标进度”
我还遇到过这样一个需求:一张项目任务表,每个项目有多条子任务,每条子任务有“任务预估工时”。业务方想在明细旁边看到“该项目从当前任务开始,剩余总工时还有多少”。这个用ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING一下就出来了:
SELECT project_id, task_name, estimated_hours, SUM(estimated_hours) OVER( PARTITION BY project_id ORDER BY task_order ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING ) AS remaining_hours FROM t_project_task ORDER BY project_id, task_order;排除那些会自欺欺人的“剩余0工时”的情况,这个字段对项目经理做任务排期调整非常有用。还有一个很常见的用法是搭配“目标值”,算逐行累计的目标完成率:
SELECT sales_id, month, amount, SUM(amount) OVER(PARTITION BY sales_id ORDER BY month) AS cum_amount, ROUND( SUM(amount) OVER(PARTITION BY sales_id ORDER BY month) / 100000 * 100, 2 ) AS target_complete_rate FROM t_monthly_sales ORDER BY sales_id, month;如果目标值是100000,那么每一行都能实时看到自己到当月为止完成了多少百分比。这种“逐行进度条”式报表,对销售团队来说非常直观。
5. 高频问题与排查实录
5.1 为什么累计结果和预期不一致?
这类问题出现频率最高,排名第一的原因就是分区或排序字段选错了。比如你按sale_date排序累计,但sale_date在同一分区里存在重复值,不同数据库对重复行的处理方式不一样。
在默认窗口下,我明确说一下行为:ORDER BY字段相等时,窗口会包含所有“等于当前排序值”的行,而不是只包含当前物理行。换句话说,同一天存在多笔记录时,这三笔记录会一起被纳入累计范围。这有时候正是你想要的(按天累计),但有时候会造成困惑(想逐笔累计)。
举个例子,有三天四笔记录:
shop_id sale_date amount A 2024-01-01 100 A 2024-01-01 50 A 2024-01-02 80 A 2024-01-03 70执行SUM(amount) OVER(PARTITION BY shop_id ORDER BY sale_date),结果是:
A 2024-01-01 100 150 A 2024-01-01 50 150 A 2024-01-02 80 230 A 2024-01-03 70 300看到没有,2024-01-01两行的累计值都是150,而不是第一行100、第二行150。绝大部分主流数据库(包括MySQL、PostgreSQL、SQL Server)在这个场景下行为是一致的:只要排序值相同,就一起累计。如果你想要的是“逐物理行累计”,需要额外加一个不重复的排序列,比如把主键或者自增ID加进ORDER BY里:
SUM(amount) OVER(PARTITION BY shop_id ORDER BY sale_date, id)这就是我在第3.1节里提到“先做日粒度汇总再做累计”的另一种替代方案。到底用哪种,取决于业务到底要“按日累计”还是“按笔累计”。这个细节非常重要。
5.2 排序字段有NULL值怎么办?
这个问题很容易被忽略。如果sale_date或分组字段中存在NULL,窗口函数的处理通常是:ORDER BY默认升序时,NULL排在最后(不同数据库略有差异)。而PARTITION BY列有NULL时,所有NULL值的行会被分到同一个组里。
这就可能导致一种诡异的现象:明明很多行的日期是空的,却全部被当成同一组,然后参与累计计算。处理这种数据的经验是,第一步先梳理数据质量,给sale_date做空值填充或者清洗。比如用COALESCE(sale_date, '1970-01-01')把空日期统一放到最早日期之前,避免排到最后影响累计结果。
SUM(amount) OVER( PARTITION BY shop_id ORDER BY COALESCE(sale_date, '1970-01-01') ) AS cum_amount5.3 排序字段选择不当导致乱序结果
我遇到过一位同事写的SQL:
SUM(amount) OVER(PARTITION BY shop_id ORDER BY month(sale_date))他的意图是按月份累计,但问题是:month(sale_date)只能取出月份,比如12月和1月的排序在数值上没问题,但跨年之后,2024年1月和2023年1月都会被排在1,导致同一组内按月排序时,2023年1月的数据和2024年1月的数据混在一起。查了半小时才定位到,就是因为在窗口函数里用了截断后的排序字段。
正确写法应该把排序字段保持完整,比如ORDER BY sale_date,或者用DATE_FORMAT(sale_date, '%Y-%m')这种保序格式,并且最好搭配对应的PARTITION BY涉及年份唯一性处理。窗口函数里的排序,讲究的是“排序字段在分区内唯一标识顺序”,任何可能打乱顺序的加工都要小心。
5.4 性能问题:窗口函数为什么越跑越慢
窗口函数虽然写起来爽,但不代表可以无脑用。如果数据量几亿行,并且在大型分析型数据库上跑,窗口函数会在分区和排序上消耗大量资源。常见的性能问题集中在几个地方:
- 分区字段没有合适的分布键,导致数据在计算节点之间大量shuffle。
ORDER BY字段没有索引(或者分析型数据库里没有对应sort key),排序开销极大。- 在同一个查询里写了多个窗口函数,但各自的
PARTITION BY和ORDER BY都不一样,导致数据库无法复用排序结果。 - 窗口范围过大且要缓存很多行,内存溢出。
针对这些点,我的优化经验是:
第一,尽量让多个窗口函数共用同一组PARTITION BY和ORDER BY。部分数据库优化器能复用同一个窗口排序结果。比如把SUM和AVG写在同一个OVER里(如果需求能做),或者至少保持相同的分区排序列。
第二,把不必要的明细累计查询下推到“预聚合”阶段。比如你要看每个店铺每个月的累计销售额,先按月做聚合生成小表,再对小表跑窗口累计,性能能提升一个量级。
第三,在大数据量场景下,排查用EXPLAIN看是不是出现了不必要的全表排序。如果看到Sort节点特别重,就要检查是否能把排序前置到分区键上,或者调整SQL逻辑。
5.5 一次排查实录:三小时定位一个隐藏问题
我把一次比较典型的排查过程完整记录下来,希望能给你一些排查思路的参考。某天数据分析师反馈,某张报表里“累计销售额”字段出现了一个奇怪现象:部分店铺的累计值在后续日期反而变小了。
第一反应是SQL写错了。我打开相关报表的查询代码,发现累计字段是这样写的:
SUM(amount) OVER(PARTITION BY shop_id ORDER BY sale_date) AS cum_amount从语法上完全看不出问题。但仔细再看,发现报表外层还有一个JOIN,把销售明细表关联上了店铺维表。问题就出在这里:如果店铺维表中存在一对多的记录(比如店铺改过名,维表里保留了两条记录),明细表的每一行会被扩成多行。累计值等于把同一行数据累加了多遍,而且由于明细顺序变化,某些累计结果自然乱掉。
最终的修复方案是:先通过子查询把店铺维表去重,保证一对一关联,再跑窗口累计。这个案例告诉我们,窗函数本身没问题的时候,要先回头检查数据源在进入窗口计算之前是不是干净、是否被关联放大过。
排查这类问题的通用思路是:先检视“进入窗口函数的数据源”,把这个表的行数、关键分组字段的枚举值先跑一遍,确认没有重复和一对一关联问题,再检视窗口函数本身。尤其是PARTITION BY字段必须能唯一标识你要分组的“实体”。换句话说,分组字段本身不能有重复含义。
6. 与其他窗口函数搭配的综合案例
6.1 一个综合报表的完整SQL
把前面讲的东西全部串起来,我写一个真实场景的综合案例。假设业务方需要一个“门店月度经营报表”,指标包括:当月销售额、年初至今累计销售额、最近3个月平均销售额、当月销售额在全部门店中的排名、当月销售额占全公司比例。
这个需求用窗口函数组合写起来非常紧凑:
SELECT shop_id, month, month_sales, SUM(month_sales) OVER(PARTITION BY shop_id ORDER BY month) AS cum_year_sales, AVG(month_sales) OVER(PARTITION BY shop_id ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS avg_3m_sales, ROW_NUMBER() OVER(PARTITION BY month ORDER BY month_sales DESC) AS rn_in_month, ROUND( month_sales / SUM(month_sales) OVER(PARTITION BY month) * 100, 2 ) AS sales_share FROM ( SELECT shop_id, month, SUM(amount) AS month_sales FROM t_sales GROUP BY shop_id, month ) t ORDER BY month, rn_in_month;每一个窗口函数的含义,我拆开解释一下:
SUM(month_sales) OVER(PARTITION BY shop_id ORDER BY month):某个门店从年初(第一行)到当前月份的累计销售额。前提是数据只包含一个自然年,如果跨年需要把年份字段也加进分区。AVG(...) OVER(PARTITION BY shop_id ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW):最近3个月的移动平均。注意这里用的是ROWS窗口,所以前两个月数据不足3行时会自动收缩成2行或1行。ROW_NUMBER() OVER(PARTITION BY month ORDER BY month_sales DESC):当月销售额在全部门店中的排名。这个属于排序窗口函数,和SUM没有直接关系但经常一起出现。SUM(month_sales) OVER(PARTITION BY month):每个月份所有门店的合计销售额,注意没有ORDER BY,所以是一个静态的月度总合计。拿单店销售额除以这个总合计,就是单店当月份额。
这样一张报表如果用传统写法,要么写多个子查询再join,要么在Java/Python代码里循环计算,代码量和维护成本都会翻好几倍。窗口函数组合起来用,一次扫描就拿到全部指标。
6.2 同类函数的并列对比
在实际写SQL时,我经常把这几个窗口函数放一起对比记忆:
| 函数 | 作用 | 典型场景 |
|---|---|---|
SUM() OVER() | 累计求和/总体占比 | 累计值、贡献度 |
AVG() OVER() | 移动平均 | 趋势平滑、短期均值 |
ROW_NUMBER() OVER() | 分组内唯一序号 | 去重取最新、排名 |
RANK() / DENSE_RANK() OVER() | 并列排名 | 排行报表 |
LAG() / LEAD() OVER() | 取前后行 | 环比计算 |
其中LAG()和LEAD()也经常用来算环比增长。比如取当月销售额跟上月对比,可以这样写:
SELECT shop_id, month, month_sales, LAG(month_sales, 1) OVER(PARTITION BY shop_id ORDER BY month) AS prev_month_sales, ROUND( (month_sales - LAG(month_sales, 1) OVER(PARTITION BY shop_id ORDER BY month)) / LAG(month_sales, 1) OVER(PARTITION BY shop_id ORDER BY month) * 100, 2 ) AS mom_growth_rate FROM ...这里有一个性能注意事项:LAG在同一个OVER里被写了两次,部分数据库的优化器可能没法自动复用同一个窗口计算。如果确定SQL引擎优化能力一般,可以先用子查询把LAG结果算出来,再在外面做比率运算:
SELECT shop_id, month, month_sales, prev_month_sales, ROUND( (month_sales - prev_month_sales) / prev_month_sales * 100, 2 ) AS mom_growth_rate FROM ( SELECT shop_id, month, month_sales, LAG(month_sales, 1) OVER(PARTITION BY shop_id ORDER BY month) AS prev_month_sales FROM ... ) t这种写法的可读性和执行效率通常更有保障。
7. 关于窗口边界与ORDER BY的隐藏细节
7.1ORDER BY到底影响了什么
很多人在刚接触时会被一个问题绕晕:SUM(...) OVER(PARTITION BY ...)和SUM(...) OVER(PARTITION BY ... ORDER BY ...)有什么区别?
区别非常大,这也是面试里经常被追问的一个细节。我直接用例子说明。假设分区里只有四行数据,金额分别是10、20、30、40。
如果写SUM(amount) OVER(PARTITION BY category),它没有指定排序,数据库会把这个分区当成一个整体,对每行都返回相同的总额100。这等价于一个窗口范围覆盖整个分区的聚合结果。
如果写SUM(amount) OVER(PARTITION BY category ORDER BY some_col),它会从分区第一行开始累加到当前行。四行分别返回10、30、60、100(假设排序字段递增)。这就是“累计值”和“整体合计”之间的差别。
所以,只要出现ORDER BY,窗口就从“整个分区”变成了“从分区起点到当前行的累计窗口”。这里特别容易踩坑的点是:有些人在写SUM(amount) OVER(PARTITION BY category)时,发现每一行都返回总额,而不是他们想要的“逐行累计值”。原因正是漏写了ORDER BY。
7.2ROWS与RANGE的区别再强调
默认窗口由ORDER BY触发时,实际上默认的边界是RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。注意这里是RANGE而不是ROWS。
RANGE和ROWS的区别在于:RANGE按照排序键的值来确定窗口范围,排序键相等的行会被同时包含;ROWS按照物理行的位置来确定窗口范围,不管排序键是否相等,只要行号在窗口内就包含。
回到第5.1节的例子,同一天两笔记录,如果用SUM(amount) OVER(PARTITION BY shop_id ORDER BY sale_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW),结果会变成:
A 2024-01-01 100 100 A 2024-01-01 50 150 A 2024-01-02 80 230而用默认的RANGE写法(也就是不写ROWS BETWEEN),结果是把同一天两笔一起算,如同之前展示的:
A 2024-01-01 100 150 A 2024-01-01 50 150这种细微差别,在你处理财务累计、周期性数据时会直接影响报表结果。我个人建议:在对“逐行累计”有明确要求时,显式写成ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,避免依赖数据库的默认RANGE行为。这样代码的意图也更清楚,后来的维护者不会因为你没写边界而产生误读。
7.3 分区内只取最新N行的奇技淫巧
还有一个高级用法,是结合窗口函数的ORDER BY DESC和ROWS BETWEEN ... FOLLOWING来做“取未来N行”的统计。比如要算“未来3天预计销售额合计”,可以写:
SUM(amount) OVER( PARTITION BY shop_id ORDER BY sale_date ROWS BETWEEN CURRENT ROW AND 2 FOLLOWING ) AS next_3_days_sum这在销售预测、排产计划里非常实用。需要注意的是,每个分区的最后两行可能不足3行,这种自动收缩行为前面已经说过了。如果你希望不足3行的时候显示NULL而不是偏小的值,可以配合COUNT(amount) OVER(...)判断一下,或者用CASE WHEN在行数不足时置空。
我在实际项目中做过类似的需求,当时本来想用自连接加一堆条件判断,后来发现窗口函数一行就搞定了,而且阅读代码的人能瞬间明白计算逻辑。自连接方案里如果少一个关联条件,结果就是错的,而且很难排查。
8. 最后的实操建议
8.1 快速验证窗口函数结果的土办法
写窗口函数最怕“感觉对但结果不对”。我自己验证的土办法非常有效:先取一个只有10行左右的小数据集,手工用Excel把预期结果算出来,然后执行SQL对比。如果发现不一致,就直接在小数据集上逐步调试,把PARTITION BY、ORDER BY、窗口边界逐个变量换着试,往往很快能找到问题。
另外一个经验是:在正式环境跑窗口函数之前,先用EXPLAIN看一下执行计划。重点看有没有排序节点、每个排序节点的规模大概多大。如果发现某个OVER的排序字段没有走索引或sort key,而且数据量又是百万级以上,就要考虑调整表结构或者换一种写法。
8.2 窗口函数里写DISTINCT为什么不行
这个问题频繁出现在新手群里。有人想对amount去重后累计,写:
SUM(DISTINCT amount) OVER(PARTITION BY ... ORDER BY ...)实际情况是,很多数据库对SUM(DISTINCT ...) OVER(...)的支持非常有限,有些直接报错,有些虽然不报错但在窗口内对amount去重求和,结果往往跟你预期完全不同。如果你真需要“去重后的累计值”,建议先把DISTINCT结果算好,放到子查询或CTE里,再做窗口累计。窗口函数本身的设计目的是对每一行做分布计算,而不是处理集合去重。
WITH distinct_daily AS ( SELECT DISTINCT shop_id, sale_date, amount FROM t_sales ) SELECT shop_id, sale_date, amount, SUM(amount) OVER(PARTITION BY shop_id ORDER BY sale_date) AS cum_amount FROM distinct_daily;8.3 窗口函数写多了如何保持可读性
我维护过一个报表模块,里面一堆窗口函数叠在一起,最长的一行超过300个字符,后来同事接手时基本没人敢改。从那以后我给自己定了一个规则:凡是窗口函数超过两个,就必须用CTE把中间结果拆开。不是CTE一定更快,而是可读性和可维护性大大提升。
WITH daily_sales AS ( SELECT shop_id, sale_date, SUM(amount) AS daily_amount FROM t_sales GROUP BY shop_id, sale_date ), cum_sales AS ( SELECT shop_id, sale_date, daily_amount, SUM(daily_amount) OVER(PARTITION BY shop_id ORDER BY sale_date) AS cum_amount FROM daily_sales ) SELECT shop_id, sale_date, daily_amount, cum_amount, cum_amount / SUM(daily_amount) OVER(PARTITION BY shop_id) AS cum_percent FROM cum_sales ORDER BY shop_id, sale_date;这样一层一层像剥洋葱一样,每一层都清晰。而且CTE还有一个好处是,可以在中间层随时加WHERE过滤,不会影响最终结果的可读性。窗口函数并没有多么高深,把它当做“给明细表加一列统计值”的工具,思路一下就打开了。
8.4 我最想跟你强调的一个习惯
如果你现在正在从传统GROUP BY聚合转向窗口函数,可能前期会有点不习惯:明明用GROUP BY也能算,为什么要换着法子写?我的看法是,窗口函数能让你在同一个查询里同时保留明细粒度和聚合结果,这在报表开发里太重要了。你不需要拆成两个SQL再去join,也不需要维护一堆临时表。熟练掌握SUM() OVER(PARTITION BY ... ORDER BY ...),只是窗口函数的第一步,但就是这一步,能帮你解决掉日常取数中大概三成以上的“加一列统计值”需求。
如果你把第5部分和第7部分的边界问题搞明白了,再遇到任何累计、占比、移动窗口的需求,基本都能游刃有余。个人感受是这种技能学会了真的一劳永逸,后面再学LAG、LEAD、ROW_NUMBER等等,会发现完全是一套思维模式。