第 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_day | daily_amount | running_total |
|---|---|---|
| 2005-08-01 | 2817.29 | 2817.29 |
| 2005-08-02 | 2726.57 | 5543.86 |
| 2005-08-16 | 111.77 | 5655.63 |
| 2005-08-17 | 2457.07 | 8112.70 |
| ... | ... | ... |
| 2005-08-23 | 2523.01 | 24072.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_year | pay_month | monthly_amount | running_total | year_to_date |
|---|---|---|---|---|
| 2005 | 5 | 4824.43 | 4824.43 | 4824.43 |
| 2005 | 6 | 9631.88 | 14456.31 | 14456.31 |
| 2005 | 7 | 28373.89 | 42830.20 | 42830.20 |
| 2005 | 8 | 24072.13 | 66902.33 | 66902.33 |
| 2006 | 2 | 514.18 | 67416.51 | 514.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_id | payment_date | amount | default_frame | rows_frame |
|---|---|---|---|---|
| 1258 | 2005-05-30 14:47:31 | 2.99 | 5.98 | 2.99 |
| 8245 | 2005-05-30 14:47:31 | 2.99 | 5.98 | 5.98 |
| 9918 | 2005-05-30 14:49:34 | 0.99 | 6.97 | 6.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 课中介绍。