要按日期展示每日销售额,同时带上从第一天到当天的累计销售额,这种需求在报表和财务对账中很常见。常规做法是先按日期分组算出每日小计,再用自连接或相关子查询把截止当天的数据加总。这样做代码啰嗦,数据量一大,性能往往非常差。其实SQL标准中的窗口函数早就提供了更简洁的方案,也就是SUM函数配合OVER子句,一条查询就能完成累计求和。它的基本思路是:在每一行上,根据ORDER BY指定的顺序,计算从窗口起点到当前行的SUM值,不会像GROUP BY那样把多行折叠成一行。

一、基础写法:ORDER BY决定累计方向
最基础的累计求和使用SUM(amount) OVER (ORDER BY order_date)。这里的窗口没有PARTITION BY,意味着整个结果集是一个窗口。ORDER BY order_date告诉数据库按日期升序排列,当前行以及之前所有行的amount都会被加总。查询返回的仍然是一行一条明细,只是多了一列running_total,每行代表截至该日期的累计销售额。
SELECT
order_date,
amount,
SUM(amount) OVER (ORDER BY order_date) AS running_total
FROM orders
ORDER BY order_date;
上面这段SQL中,窗口函数的计算逻辑可以这样理解:先对数据按order_date排序,然后从第一行开始,依次取当前行之前(含当前行)的所有amount求和。排序字段不一定是日期,也可以是自增主键、交易流水号等,只要它能表达业务上的先后顺序。比如按id升序累计,就写成SUM(amount) OVER (ORDER BY id)。如果想按日期倒序,从最近一天往前累计,可以写ORDER BY order_date DESC。总之,累计方向完全由ORDER BY控制,这是很多初学者容易忽略的地方。
还要注意,窗口函数在SELECT阶段计算,不会改变结果集行数。与GROUP BY不同,它不会把相同日期的多条记录合并。如果同一天有多笔订单,每一行都会返回,累计值会根据窗口范围规则逐行或按相同键处理。这个细节在第三节会展开。
二、按用户或分类分区累计:PARTITION BY的使用
很多场景下累计量并不是全局累加,而是分组独立累加。比如统计每个用户的消费累计、每个商品的销量累计、各个地区的订单金额累计。这时候需要在OVER子句中增加PARTITION BY。它的作用是把数据划分成多个独立分区,窗口函数在每个分区内单独计算,分区之间互不影响。看下面这个例子:订单表中有user_id,要计算每个用户自己的累计消费金额。
SELECT
user_id,
order_date,
amount,
SUM(amount) OVER (
PARTITION BY user_id
ORDER BY order_date
) AS user_running_total
FROM orders
ORDER BY user_id, order_date;
这个查询按照user_id把订单拆成多个窗口,每个用户一个窗口,再按order_date排序累计。当user_id从1001切换到1002时,累计值会重新从0开始。这样得到的user_running_total就是每个用户自己的历史累计消费,而不是所有用户混在一起。PARTITION BY后面可以跟多个字段,比如PARTITION BY region, user_id,表示先按地区再按用户分片。分区的本质是在内存或排序过程中对数据进行分组计算,不需要额外嵌套子查询。
需要注意,PARTITION BY和ORDER BY没有先后强制关系,但语法上PARTITION BY必须写在ORDER BY之前。如果窗口函数只有PARTITION BY而没有ORDER BY,那么它计算的是整个分区的总和,每一行都会返回相同的分区合计值。比如SUM(amount) OVER (PARTITION BY user_id)就是每个用户的总消费金额,常见于算占比或排名时配合使用。理解了这一点,就能灵活组合出不同的统计口径。
三、ROWS与RANGE:重复排序值如何影响累计结果
在累计求和里,有一个容易被忽视的细节:当ORDER BY的字段存在重复值时,默认窗口范围RANGE和物理行范围ROWS会产生不同结果。先看默认行为。SQL标准规定,窗口函数如果只写ORDER BY而不写窗口范围,默认等价于RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。RANGE模式按排序键的值来划分窗口,相同排序键的所有行会被视为一组。比如订单表里同一天有3笔订单,金额分别是100、200、300。按日期累计时,这3行的order_date相同,RANGE模式下它们的累计值都会是600,也就是当天所有订单加总后的值,而不是第一行100、第二行300、第三行600。
要想严格按物理行的先后顺序逐行累加,需要显式使用ROWS窗口。例如:
SELECT
order_date,
amount,
SUM(amount) OVER (
ORDER BY order_date, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS rows_total
FROM orders
ORDER BY order_date, id;
这里加了第二个排序字段id,用来在相同日期内确定行的先后顺序。窗口范围改成ROWS后,每一行只累加从窗口起点到当前行所包含的物理行,不会因为order_date相同而把后面的行提前合并。结果就是同一天的3笔订单依次得到100、300、600。如果业务需要逐笔流水累加,而不是按日期聚合后的累计,必须使用ROWS。尤其在交易流水、日志分析、账户余额变动等场景,ROWS更符合直觉。
除了累计求和,窗口范围还可以做滚动汇总。比如ROWS BETWEEN 6 PRECEDING AND CURRENT ROW表示当前行加上往前6行,共7行求和,适合计算近7天销量。RANGE则更适合按日期区间聚合,例如RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW,但MySQL 8对日期区间的RANGE支持有限,实际使用ROWS偏多。理解这两个窗口的差异后,就能根据排序键是否有重复值、业务是否需要逐行累计来选择合适的写法。
四、替代写法与性能对比:为什么推荐窗口函数
在窗口函数普及之前,很多开发人员用相关子查询或自连接来算累计求和。典型的写法是把同一张表取两个别名,外层表驱动每一行,内层子查询汇总所有小于等于当前日期的记录。查询语句大致如下:
SELECT
o1.order_date,
o1.amount,
(
SELECT SUM(o2.amount)
FROM orders o2
WHERE o2.order_date <= o1.order_date
) AS running_total
FROM orders o1
ORDER BY o1.order_date;
这种写法在数据量较小时可行,但它的复杂度通常是O(n²)。如果orders表有几万行,子查询就要扫描几万次几万行,耗时可能急剧上升。换成SUM OVER窗口函数后,数据库通常只需要对数据做一次排序,再线性扫描一遍即可完成累计,复杂度接近O(n log n)。在SQL Server、PostgreSQL、Oracle、MySQL 8以上的执行计划中,窗口函数往往表现为一个Window Aggregate算子,配合Sort算子,数据扫描次数明显减少。
另外,早期MySQL 5.x没有窗口函数,开发者会用用户变量模拟累计,例如先按日期排序,然后用@running变量逐行累加。这种方案依赖结果集顺序和变量赋值时机,一旦查询优化器调整了执行计划,或者SQL中有ORDER BY、GROUP BY干扰,结果很容易出错。窗口函数是标准语法,逻辑声明式、可读性高,数据库优化器还有机会做更多优化。因此,只要数据库版本支持,累计求和应该优先选择SUM OVER。
性能优化方面,如果表很大,排序字段和分区字段最好建立合适的索引。比如订单表经常按user_id分区、按order_date排序,可以建(user_id, order_date)联合索引。但要注意,窗口函数仍然可能触发额外排序,当查询结果本身就按相同顺序返回时,优化器可能省去一次Sort。对于并发较高的OLTP系统,窗口函数通常用于报表和离线分析,不建议在高频交易核心链路上直接扫描大表做全量累计。实际项目中可以通过定时任务把累计值固化到汇总表,或者借助物化视图、列存引擎进一步加速。
五、实战变形:滚动N日求和与累计占比
掌握了SUM OVER的基础用法,可以延伸出很多实用的统计口径。最常见的是滚动N日求和。比如要计算每笔订单日期往前7天的销售额总和,可以写:
SELECT
order_date,
amount,
SUM(amount) OVER (
ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS last_7_days_total
FROM orders
ORDER BY order_date;
这里ROWS BETWEEN 6 PRECEDING AND CURRENT ROW表示窗口包含当前行以及它前面的6行,一共7行。如果每天只有一条汇总记录,结果就是近7天销售额。如果同一天有多条明细,仍然需要结合id排序,否则每天的滚动累计会把当天后面的行也计算进来,含义会偏向最近7笔而不是最近7天。这种细节在开发时要结合数据粒度判断清楚。
另一个常见的变形是累计占比。比如想观察销售额随时间的累积贡献,可以同时计算全局总量和累计量,再把两者相除。窗口函数允许一个SELECT中同时出现多个聚合窗口。例如:
SELECT
order_date,
SUM(amount) OVER (
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total,
SUM(amount) OVER () AS grand_total,
SUM(amount) OVER (
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) * 100.0 / SUM(amount) OVER () AS running_percent
FROM orders
ORDER BY order_date;
这里SUM(amount) OVER ()没有ORDER BY和PARTITION BY,表示从整个结果集求总量。每个窗口独立计算,互不干扰。running_percent就是累计百分比。该写法省去了子查询和GROUP BY,代码清晰很多。需要注意的是,如果amount存在NULL,SUM会忽略NULL值,如果某行amount为NULL又希望计入0,可以用COALESCE处理。另外,当分母为0或NULL时,除法的结果可能为NULL,需要根据业务用CASE或NULLIF做保护。
总的来说,SUM OVER窗口函数把累计求和从复杂的过程式写法中解放出来,让SQL更接近业务描述:按什么顺序、在哪个范围内、对哪一列做累加。实际使用时,牢记ORDER BY控制方向、PARTITION BY控制分组、ROWS和RANGE控制窗口边界,就能应对绝大多数累计统计需求。配合合适的索引和数据结构设计,窗口函数在报表、对账、指标分析等场景中都是高效可靠的选择。