保持 SQLtest 存活:帮助支付本月 $100 的服务器费用,以保持所有课程和 SQL 游乐场在线。 支持该项目 →
SQL 代码已复制到剪贴板

第 12.7 课 · 阅读时间:约 6 分钟

累计总和(running total)把每个值加到之前所有值的和上:截至当前的收入、每笔交易后的账户余额、累计注册人数。它是仪表板中的常见指标,也是热门的 SQL 面试题。在本课中,你将用 SUM() OVER (ORDER BY ...) 计算累计总和,让它在每个分组中重新开始,避开排序值重复带来的陷阱,并了解在窗口函数出现之前是如何实现的。

SQL 累计总和

普通的 SUM 加 GROUP BY 会把多行压缩成一个总数。累计总和则保留每一行,并增加一列截至当前的总和。这正是窗口函数做的事:SUM(amount) OVER (ORDER BY day) 按 day 的顺序,把从第一行到当前行的所有行加起来。窗口函数和窗口框架已在第 7.1 课和第 7.3 课中介绍,这里用它们来解决一个实际问题。

示例在 Sakila 示例数据库(MySQL 8.0)上运行。

按天累计收入

先按天汇总付款,再在每天的汇总行上计算累计总和:

WITH daily AS (
    SELECT DATE(payment_date) AS pay_day,
           SUM(amount) AS daily_amount
    FROM payment
    WHERE payment_date >= '2005-08-01'
      AND payment_date < '2005-09-01'
    GROUP BY DATE(payment_date)
)
SELECT pay_day,
       daily_amount,
       SUM(daily_amount) OVER (ORDER BY pay_day) AS running_total
FROM daily
ORDER BY pay_day;

结果:2005 年 8 月有付款的 10 天;累计总和从 2817.29 增长到 24072.13,即整个月的收入。

pay_daydaily_amountrunning_total
2005-08-012817.292817.29
2005-08-022726.575543.86
2005-08-16111.775655.63
2005-08-172457.078112.70
.........
2005-08-232523.0124072.13

先汇总很重要:如果对单笔付款使用 SUM(...) OVER,就会每笔付款返回一行,而不是每天一行。没有付款的日期直接缺失;要让它们显示为 0,请把数据连接到日历上(第 6.6 课)。


每个分组重新开始累计

PARTITION BY 为每个分组分别计算累计总和。这里的月度收入有两个累计列:一个从头一直累计,另一个每年重新开始:

WITH monthly AS (
    SELECT YEAR(payment_date) AS pay_year,
           MONTH(payment_date) AS pay_month,
           SUM(amount) AS monthly_amount
    FROM payment
    GROUP BY YEAR(payment_date), MONTH(payment_date)
)
SELECT pay_year, pay_month, monthly_amount,
       SUM(monthly_amount) OVER (ORDER BY pay_year, pay_month) AS running_total,
       SUM(monthly_amount) OVER (PARTITION BY pay_year ORDER BY pay_month) AS year_to_date
FROM monthly
ORDER BY pay_year, pay_month;

结果:5 个月;总累计值达到 67416.51,而年内累计值在 2006 年从 514.18 重新开始。

pay_yearpay_monthmonthly_amountrunning_totalyear_to_date
200554824.434824.434824.43
200569631.8814456.3114456.31
2005728373.8942830.2042830.20
2005824072.1366902.3366902.33
20062514.1867416.51514.18

同样的模式也能计算每位客户(PARTITION BY customer_id ORDER BY payment_date)或每家门店的累计总和。


陷阱:相同的排序值

当窗口的 ORDER BY 中有重复值时,结果会让很多人感到意外。Sakila 中有两笔付款的时间完全相同:

SELECT payment_id, payment_date, amount,
       SUM(amount) OVER (ORDER BY payment_date) AS default_frame,
       SUM(amount) OVER (ORDER BY payment_date, payment_id
                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rows_frame
FROM payment
WHERE payment_date >= '2005-05-30 14:40:00'
  AND payment_date < '2005-05-30 15:00:00'
ORDER BY payment_date, payment_id;

结果:付款 1258 和 8245 的 default_frame 都是 5.98,而 rows_frame 依次为 2.99 和 5.98。

payment_idpayment_dateamountdefault_framerows_frame
12582005-05-30 14:47:312.995.982.99
82452005-05-30 14:47:312.995.985.98
99182005-05-30 14:49:340.996.976.97

当窗口有 ORDER BY 但没有显式指定框架时,默认框架是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW:它包含所有与当前行排序值相同的行。并列的行会得到相同的累计值。要严格逐行累加,请使用 ROWS 框架,并在 ORDER BY 中加上唯一的排序依据,例如主键。


不用窗口函数

在窗口函数出现之前(MySQL 8.0 之前),累计总和是用相关子查询计算的:对每一行,把截至该行的所有行加起来。

WITH daily AS (
    SELECT DATE(payment_date) AS pay_day,
           SUM(amount) AS daily_amount
    FROM payment
    WHERE payment_date >= '2005-08-01'
      AND payment_date < '2005-09-01'
    GROUP BY DATE(payment_date)
)
SELECT d1.pay_day,
       d1.daily_amount,
       (SELECT SUM(d2.daily_amount)
        FROM daily d2
        WHERE d2.pay_day <= d1.pay_day) AS running_total
FROM daily d1
ORDER BY d1.pay_day;

结果:与窗口函数版本相同的 10 行和累计值。

对每一行,子查询都要扫描之前所有的行,所以工作量按平方增长。可以把它作为备用方案提一下,实际工作中请使用 SUM() OVER。

练习


要点:

  • SUM(x) OVER (ORDER BY key) 返回截至每一行的累计和,并保留所有行。
  • 先汇总到所需的粒度(天、月),再计算累计总和。
  • PARTITION BY 让每个分组(客户、门店、年份)的累计值重新开始。
  • 默认框架是 RANGE,所以排序值相同的行共享同一个累计值;请使用 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 和唯一的排序依据。
  • 不用窗口函数时可以用相关子查询,但速度要慢得多。

常见问题

如何在 SQL 中计算累计总和?

使用窗口函数:SELECT day, amount, SUM(amount) OVER (ORDER BY day) AS running_total FROM daily;。它适用于 MySQL 8.0+、MariaDB 10.2+、PostgreSQL、SQL Server、Oracle 和 SQLite 3.25+。

如何按分组计算累计总和?

加上 PARTITION BY:SUM(amount) OVER (PARTITION BY customer_id ORDER BY payment_date)。每位客户的第一行都会重新开始累计。

为什么有些行的累计总和相同?

因为它们在窗口 ORDER BY 列上的值相同,而默认的 RANGE 框架会包含所有这些并列的行。请指定 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,并在 ORDER BY 中加入唯一列,例如 id。

如何计算移动平均而不是累计总和?

把框架限制为固定行数:AVG(amount) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) 计算当天及前六天的平均值。窗口框架在第 7.3 课中介绍。