累计和(Running Total)与平均值是报表统计中最常见的需求,比如电商系统要展示每日销售额的累计曲线,监控系统要展示最近七天的滑动平均负载。传统做法是在应用程序里查出原始数据后用循环累加,或者在SQL里写多层嵌套子查询,数据量一大性能就崩。其实SQL标准从2003年起就引入了窗口函数(Window Function),配合SUM、AVG等数值聚合函数,一条查询语句就能算出累计和与各种口径的平均值,而且数据库引擎会做专门优化,执行效率远高于应用层计算。

窗口函数基础:OVER子句的工作原理
要理解累计和怎么算,先得弄清楚窗口函数和普通聚合函数的区别。普通的SUM配合GROUP BY使用时,会把多行折叠成一行,比如统计每个月的总销售额,结果是每个分组只剩一行。而窗口函数加上OVER子句后,每一行原始数据都保留,同时在旁边附加一个计算结果,这个计算结果的可见范围由窗口定义决定。
OVER子句里最关键的是三个部分:PARTITION BY决定分区,相当于把数据切成多个独立计算的小组;ORDER BY决定累计的方向和顺序;帧子句(Frame Clause)决定从当前行往前后各取多少行参与计算。下面用一个订单表来演示,表结构如下:
CREATE TABLE orders (
order_date DATE, -- 下单日期
region VARCHAR(20), -- 销售区域
amount DECIMAL(10,2) -- 订单金额
);
INSERT INTO orders VALUES
('2024-01-01', '华东', 100.00),
('2024-01-02', '华东', 150.00),
('2024-01-03', '华东', 200.00),
('2024-01-01', '华北', 120.00),
('2024-01-02', '华北', 80.00);先看一个最简单的普通聚合与窗口函数的对比。GROUP BY写法只能得到每个区域的总额,而窗口函数写法能同时看到每一笔订单和它所属区域的总额,这种特性正是累计计算的基础。
用SUM OVER实现累计和的完整写法
累计和的核心是SUM(amount) OVER (ORDER BY order_date),含义是按照日期排序后,从第一行累计到当前行。数据库在执行时会维护一个不断累加的中间值,每一行输出的都是它之前所有行(含自身)的总和,时间复杂度是O(n),比子查询自关联的O(n²)快了不止一个量级。
SELECT
order_date,
amount,
SUM(amount) OVER (ORDER BY order_date) AS running_total
FROM orders
WHERE region = '华东';执行结果中,1月1日这一行显示100,1月2日显示250(100+150),1月3日显示450(100+150+200),这就是典型的累计和曲线。如果去掉ORDER BY只写OVER(),窗口就变成整个结果集,每一行都会显示全量总和,这是新手最容易混淆的地方:有没有ORDER BY,窗口的行为完全不同。
还有一个隐藏的坑需要特别注意:如果排序键有重复值,比如同一天有多笔订单,默认的窗口范围是RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,相同日期的行会被视为一个整体,互相看到对方的值。比如1月2日有两笔订单各100元,这两行的累计和都会显示300而不是一笔200、一笔300。如果想严格按行累计,需要显式改成ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,用行号而不是排序值来划定边界。
分组累计:PARTITION BY的实战应用
真实业务里很少只算一条累计线,更多时候要按区域、按用户、按商品分类分别累计。这时PARTITION BY就派上用场了,它相当于把窗口函数执行多次,每个分区内部独立累计,互不干扰。写法上只需要在OVER里先写分区再写排序:
SELECT
region,
order_date,
amount,
SUM(amount) OVER (
PARTITION BY region
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS region_running_total
FROM orders;这条语句会为华东和华北各自生成一条累计线:华东区的行只累计华东区的数据,华北区同理。生成多区域对比报表时,这种写法一条查询就能搞定,不需要在应用层按区域分组后循环计算。
分区的另一个高频场景是计算占比。先用窗口函数算出总额,再用当前行金额除以总额,就能得到每笔订单在全量中的占比,而不需要先查一次总额再回表关联:
SELECT
order_date,
amount,
amount / SUM(amount) OVER () AS pct_of_total
FROM orders;滑动平均与帧子句的精确控制
累计平均是把当前行之前的所有数据都算进去,但很多场景需要的是滑动平均(也叫移动平均),比如股票的5日均线、服务器的近7天平均响应时间。这就轮到帧子句出场了。帧子句的语法是ROWS BETWEEN 上边界 AND 下边界,边界可以是UNBOUNDED PRECEDING(分区第一行)、N PRECEDING(前N行)、CURRENT ROW(当前行)、N FOLLOWING(后N行)、UNBOUNDED FOLLOWING(分区最后一行)。
计算最近3天的滑动平均,写法如下:
SELECT
order_date,
amount,
AVG(amount) OVER (
ORDER BY order_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3
FROM orders
WHERE region = '华东';2 PRECEDING AND CURRENT ROW表示窗口覆盖前两行加当前行,共3行,这就是标准的3日滑动窗口。前两行因为不足3条数据,会按实际行数平均,比如第一行的滑动平均就等于它自身的值。如果希望不足窗口大小时返回NULL,可以额外判断行号:CASE WHEN ROW_NUMBER() OVER (ORDER BY order_date) >= 3 THEN ... END。
顺带一提,ROWS和RANGE是两种不同的帧模式:ROWS按物理行数计算边界,RANGE按排序值计算,相同排序值的行会被一起纳入窗口。做滑动平均时绝大多数情况应该用ROWS,语义更精确可控。
不同数据库的兼容性与性能注意事项
窗口函数在主流数据库中的支持情况总体良好:MySQL从8.0版本开始支持,PostgreSQL、SQL Server、Oracle、SQLite(3.25以上)都支持完整语法。老版本的MySQL 5.x不支持窗口函数,只能通过用户变量@total := @total + amount这种黑科技模拟累计和,可读性差且在优化器改写下容易出错,建议升级或改用应用层计算。
性能方面有几点经验值得参考。第一,窗口函数要求结果集先按分区和排序键排列,索引设计应尽量与PARTITION BY和ORDER BY的字段顺序保持一致,比如上例中建立(region, order_date)的联合索引,可以避免额外的排序操作。第二,窗口函数无法直接配合WHERE过滤计算结果,因为窗口计算发生在WHERE之后,如果想筛选累计和大于某个阈值的行,需要用子查询或CTE包一层再过滤。
WITH totals AS (
SELECT
order_date,
amount,
SUM(amount) OVER (ORDER BY order_date) AS running_total
FROM orders
WHERE region = '华东'
)
SELECT * FROM totals
WHERE running_total >= 300; -- 窗口函数结果必须在外层过滤第三,避免在一个查询里堆砌大量不同的窗口定义,每个不同的OVER子句都可能触发一次单独的排序,多个窗口尽量统一排序键,让数据库复用同一次排序结果。掌握这些细节后,累计和、移动平均、分组占比这类统计需求都可以在SQL层一步到位,既省去了应用层的循环代码,也让统计口径集中在数据库中,维护起来更加清晰。