用 SQL 做销售报表或对账明细时,常会遇到一个需求:保留每一行原始记录,同时增加一列显示该行所属分组内从开始到当前行的累计值。例如按区域统计订单金额时,既想看华东每一天的单笔金额,又想知道华东截至当天的销售总额。普通的 GROUP BY 聚合会丢失明细行,而利用窗口函数 SUM() OVER 可以一次查询完成分组不间断累计。本文将围绕 SUM OVER 的语法、执行逻辑和常见问题展开,给出可以直接运行的 SQL 示例。

下面通过一个订单表 orders 来演示。表结构包含订单编号、区域、销售日期和金额四个字段,需求是按区域分组,同时按日期累计区域销售额。
一、窗口函数与分组累计的关系
窗口函数与 GROUP BY 本质不同。GROUP BY 做的是分组聚合,每个分组只返回一行结果,所有明细被压缩成汇总值。窗口函数则在每一行上计算一个值,这个值可以依赖同一窗口内其他行。以累计求和为例,窗口函数会把分区内的所有行组成一个逻辑窗口,根据 ORDER BY 指定的顺序,将窗口起点到当前行的值累加,从而实现逐行递增的累计效果。
用一个简单对比更容易理解。下面的查询返回每个区域的总销售额,这是传统分组聚合。
SELECT region,
SUM(amount) AS total_amount
FROM orders
GROUP BY region;
该结果只有一行一个区域,看不到明细。如果想在每一笔订单后追加一列累计金额,就需要窗口函数。窗口函数不会减少结果集行数,它会为原始结果集的每一行计算累计值,这就是累计求和与普通分组求和最明显的区别。
二、用 SUM OVER 实现分组累计
先创建测试数据。
CREATE TABLE orders (
id INT PRIMARY KEY,
region VARCHAR(20),
sales_date DATE,
amount DECIMAL(10,2)
);
INSERT INTO orders VALUES
(1, '华东', '2024-01-01', 100.00),
(2, '华东', '2024-01-02', 150.00),
(3, '华东', '2024-01-03', 80.00),
(4, '华南', '2024-01-01', 200.00),
(5, '华南', '2024-01-02', 120.00);
接下来使用 SUM(amount) OVER (PARTITION BY region ORDER BY sales_date, id) 计算每个区域的累计销售额。
SELECT
id,
region,
sales_date,
amount,
SUM(amount) OVER (
PARTITION BY region
ORDER BY sales_date, id
) AS running_total
FROM orders
ORDER BY region, sales_date, id;
查询结果会显示华东第 1 行累计 100,第 2 行累计 250,第 3 行累计 330;华南第 1 行累计 200,第 2 行累计 320。PARTITION BY region 将数据按区域切成独立窗口,每个区域都从自己的第一行开始累加,不会跨区串数。ORDER BY sales_date 决定按日期先后累加,如果同一天有多条记录,再按 id 排序保证结果稳定。这种写法就是所谓的不间断分组累计,因为分区内每一行都会参与前面所有行的累加,中间不会因为 GROUP BY 丢弃行。
三、理解默认窗口帧和 ROWS 控制
上面的查询没有写窗口帧,数据库默认使用 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。意思是窗口范围从分区起点到当前行,按 ORDER BY 的值确定包含哪些行。在日期唯一时,默认行为与累加一致。但如果 ORDER BY 字段存在重复值,RANGE 会把所有相同排序值的行视为同一批次,导致累计结果可能出现多条行都包含同批数据的情况。为了让每一行严格逐行累加,建议显式使用 ROWS 窗口帧。
下面的 SQL 显式声明 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,表示从分区第一行到当前行逐行累计,不受重复排序值影响。
SELECT
id,
region,
sales_date,
amount,
SUM(amount) OVER (
PARTITION BY region
ORDER BY sales_date, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders
ORDER BY region, sales_date, id;
如果想计算最近 N 条记录的滚动合计,可以把 UNBOUNDED PRECEDING 换成 N PRECEDING。例如按区域和日期排序后,只累计当前行及前两行,可以写 ROWS BETWEEN 2 PRECEDING AND CURRENT ROW。这种窗口帧控制在库存明细、移动平均等场景中非常实用。
四、不分组累计和多字段分区
有时需求不是按分组累计,而是对整个结果集按时间累计。此时可以省略 PARTITION BY,只保留 ORDER BY。例如计算全公司每天销售总额的累计值。
SELECT
sales_date,
SUM(amount) AS day_total,
SUM(SUM(amount)) OVER (
ORDER BY sales_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders
GROUP BY sales_date
ORDER BY sales_date;
这段代码先按日期汇总每天销售额,再用窗口函数对日汇总做累计。注意内层 SUM(amount) 是聚合,外层 SUM(...) OVER 是对聚合结果再计算累计,这种嵌套写法很常见。也可以在多字段分区,比如按区域和月份累计,只需在 PARTITION BY 后列出多个字段。
SELECT
region,
DATE_FORMAT(sales_date, '%Y-%m') AS sales_month,
sales_date,
amount,
SUM(amount) OVER (
PARTITION BY region, DATE_FORMAT(sales_date, '%Y-%m')
ORDER BY sales_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS month_running_total
FROM orders
ORDER BY region, sales_month, sales_date;
DATE_FORMAT 是 MySQL 的函数,其他数据库可以使用 TO_CHAR 或 FORMAT。关键是 PARTITION BY 后的多个字段共同决定窗口边界,每个区域每个月都会重新开始累计,非常适合月度指标分析。
五、NULL 值与排序细节
累计求和遇到 NULL 金额会产生两个问题:一是 SUM 会忽略 NULL,但它仍会占一行;二是如果累计列本身有 NULL,外层逻辑可能出现断档。最好在数据准备阶段用 COALESCE 把 NULL 转成 0,例如 SUM(COALESCE(amount, 0)) OVER (...)。这样累计值不会因为某行缺失金额而失真。
排序字段重复是另一个容易忽略的坑。假设只按 sales_date 排序,而同一天有多条记录,RANGE 默认会把同一天的所有记录放在同一窗口位置,这会导致这些行计算出相同的累计值,而不是按插入顺序逐行累加。解决办法是在 ORDER BY 中加入唯一键,例如 ORDER BY sales_date, id,并显式使用 ROWS 窗口帧。这样即使日期相同,也能按照 id 的顺序严格逐行累计。
不同数据库对窗口函数的支持有所不同。MySQL 从 8.0 开始完整支持 SUM() OVER、ROWS 和 RANGE;PostgreSQL、SQL Server、Oracle 很早就支持。使用时需要确认数据库版本,尤其是老版本 MySQL 不支持窗口函数,只能通过变量或自连接模拟累计。
六、性能优化建议
窗口函数虽然写起来方便,但执行时需要对分区和排序字段进行处理。如果表数据量很大,最好在 PARTITION BY 和 ORDER BY 的字段上建立联合索引。例如本示例可以在 region、sales_date、id 上建立索引,减少排序开销。数据库执行计划中通常会看到 WindowAgg 节点,排序操作是最大的成本来源。
另一个优化点是避免在窗口函数内部重复计算复杂表达式。例如 DATE_FORMAT(sales_date, '%Y-%m') 出现在 PARTITION BY 中,如果表达式不基于索引,可能导致全表扫描。可以先生成月份字段,再在外层查询中使用窗口函数。对于只需要累计前几行的场景,使用 ROWS BETWEEN N PRECEDING AND CURRENT ROW 通常比默认 RANGE 更高效,因为不需要处理同值分组。
总之,利用 SUM() OVER 实现分组累计求和,核心是 PARTITION BY 划定边界、ORDER BY 决定顺序、ROWS 控制窗口帧。掌握这三个部分后,就能在不丢失明细行的情况下完成各种累计指标计算,避免重复扫描表或复杂的自连接。