SQL 数据分析 · 指南 · 2026年10月30日 · 14 分钟阅读
SQL 累计求和:用窗口函数计算累计值、分组累计与重置

累计求和(running total,也叫累计值、累加和)就是把每一行的值和它之前所有行的值加在一起:第 1 天,然后第 1 天 + 第 2 天,再然后第 1 天 + 第 2 天 + 第 3 天,依此类推。在现代 SQL 里,用一个窗口函数就能写出来:SUM(value) OVER (ORDER BY day)。加上 PARTITION BY,每个分组都会从零重新累计;再加上 ROWS BETWEEN 6 PRECEDING AND CURRENT ROW 这样的框架子句,它就变成了移动求和。
这一行代码背后藏着好几个决定:排序列出现并列值时怎么办、筛选条件是在窗口之前还是之后执行、某些日期缺失时框架如何表现。本文用一个真实数据集逐一讲清楚:Nomto SQL 课程里的航空公司训练数据库,包含 2026 年夏天的订单、机票和航班(金额单位为美元)。下面每个查询都真实运行过,每张表都是实际输出,你可以把任何一个例子粘贴到 Nomto 的 SQL 练习场里,得到同样的数字。
最基本的累计求和:SUM() OVER
先从 bookings 表算出每天的营收,再加上一列累计值:
SELECT
CAST(book_date AS DATE) AS day,
COUNT(*) AS bookings,
SUM(total_amount) AS revenue,
SUM(SUM(total_amount)) OVER (ORDER BY CAST(book_date AS DATE)) AS running_revenue
FROM bookings
WHERE book_date >= '2026-08-01'
GROUP BY CAST(book_date AS DATE)
ORDER BY day
LIMIT 7;
| day | bookings | revenue | running_revenue |
|---|---|---|---|
| 2026-08-01 | 5746 | 4504496.00 | 4504496.00 |
| 2026-08-02 | 5761 | 4563873.00 | 9068369.00 |
| 2026-08-03 | 5665 | 4521583.00 | 13589952.00 |
| 2026-08-04 | 5813 | 4676666.00 | 18266618.00 |
| 2026-08-05 | 5937 | 4793778.00 | 23060396.00 |
| 2026-08-06 | 6081 | 4856044.00 | 27916440.00 |
| 2026-08-07 | 6184 | 4907956.00 | 32824396.00 |
有两点值得注意。
SUM(SUM(...)) OVER 不是笔误。 里面的 SUM 是普通聚合,由 GROUP BY 为每一天计算;外面的 SUM ... OVER 是窗口函数,在分组之后执行,把这些每日数值累加起来。窗口函数在 WHERE、GROUP BY 和 HAVING 之后才计算,所以它能「看到」聚合结果。
累计值从 8 月 1 日开始,而不是从第一笔订单开始。 WHERE 在计算窗口之前就把更早的数据去掉了。有时这正是你想要的(「本月初以来的营收」),但很多时候并不是。下一节会说明其中的区别。
多数人觉得用 CTE 的写法更好读:先建出每日表,再在上面套窗口。
WITH daily AS (
SELECT CAST(book_date AS DATE) AS day, SUM(total_amount) AS revenue
FROM bookings
GROUP BY CAST(book_date AS DATE)
)
SELECT day, revenue,
SUM(revenue) OVER (ORDER BY day) AS running_revenue
FROM daily
ORDER BY day;
先算窗口,再筛选
假设你想看 7 月最后几天和 8 月头几天的「本月累计营收」。一种很顺手的写法是把日期筛选和窗口放在同一个 SELECT 里。可是 WHERE 先执行,于是 7 月的累计会从 7 月 29 日才开始,7 月 1 日到 28 日的数据全丢了。
正确做法是:一步计算窗口,下一步再筛选。
WITH daily AS (
SELECT CAST(book_date AS DATE) AS day, SUM(total_amount) AS revenue
FROM bookings
GROUP BY CAST(book_date AS DATE)
),
mtd AS (
SELECT day, revenue,
SUM(revenue) OVER (PARTITION BY date_trunc('month', day) ORDER BY day) AS mtd_revenue
FROM daily
)
SELECT *
FROM mtd
WHERE day BETWEEN '2026-07-29' AND '2026-08-03'
ORDER BY day;
| day | revenue | mtd_revenue |
|---|---|---|
| 2026-07-29 | 4423319.00 | 127170650.00 |
| 2026-07-30 | 4284520.00 | 131455170.00 |
| 2026-07-31 | 4517665.00 | 135972835.00 |
| 2026-08-01 | 4504496.00 | 4504496.00 |
| 2026-08-02 | 4563873.00 | 9068369.00 |
| 2026-08-03 | 4521583.00 | 13589952.00 |
现在 7 月 29 日这一行带着截至当天的整个 7 月累计,约 1.272 亿美元;到了 8 月 1 日,计数器又回到单日营收。如果筛选条件和窗口写在同一个查询里,7 月 29 日那一行只会显示 4,423,319。
经验法则:决定「统计哪些数据」的条件放在窗口之前的 WHERE 里;只决定「显示哪些行」的条件放在窗口之后的外层查询里。
会重置的累计求和:PARTITION BY
上面的查询其实已经用到了重置机制。PARTITION BY 把行切成彼此独立的分组,每个分组的累计值都从零开始。按日历周期分区,就得到本月累计或本年累计;按类别分区,就得到每个类别各自的一条累计线。
下面是按起飞日期统计的航段营收,每个舱位等级分别累计:
WITH daily AS (
SELECT CAST(f.scheduled_departure AS DATE) AS day,
tf.fare_conditions,
SUM(tf.amount) AS revenue
FROM ticket_flights tf
JOIN flights f ON f.flight_id = tf.flight_id
GROUP BY CAST(f.scheduled_departure AS DATE), tf.fare_conditions
)
SELECT day, fare_conditions, revenue,
SUM(revenue) OVER (PARTITION BY fare_conditions ORDER BY day) AS running_revenue
FROM daily
WHERE day <= '2026-07-14'
ORDER BY fare_conditions, day;
| day | fare_conditions | revenue | running_revenue |
|---|---|---|---|
| 2026-07-12 | Business | 446047.00 | 446047.00 |
| 2026-07-13 | Business | 585901.00 | 1031948.00 |
| 2026-07-14 | Business | 571886.00 | 1603834.00 |
| 2026-07-12 | Comfort | 60600.00 | 60600.00 |
| 2026-07-13 | Comfort | 60147.00 | 120747.00 |
| 2026-07-14 | Comfort | 66747.00 | 187494.00 |
| 2026-07-12 | Economy | 1159688.00 | 1159688.00 |
| 2026-07-13 | Economy | 1507831.00 | 2667519.00 |
(这里显示 9 行中的 8 行;Economy 在 7 月 14 日累计到 4267240.00。)
这里的 WHERE day <= '2026-07-14' 是安全的:航班从 7 月 12 日开始,截掉时间段的尾部不会改变剩下那些行的累计值。对向前看的累计求和来说,筛掉区间的终点永远没有影响;筛掉区间的起点才有影响。
也可以同时按多列分区,例如 PARTITION BY fare_conditions, date_trunc('month', day),得到每个舱位的本月累计。
并列值:为什么 ROWS 和 RANGE 结果不同
在 OVER 里写了 ORDER BY 却没写框架时,SQL 使用默认框架 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。「RANGE」的意思是「排序值小于或等于当前行的所有行」,其中包括和当前行并列的所有行。排序值唯一时你不会察觉;一旦有重复值,并列的行会得到同一个累计值。
订单时间精确到分钟,有时两笔订单落在同一分钟:
WITH t AS (
SELECT book_ref, book_date, total_amount,
SUM(total_amount) OVER (ORDER BY book_date) AS range_total,
SUM(total_amount) OVER (ORDER BY book_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rows_total,
SUM(total_amount) OVER (ORDER BY book_date, book_ref) AS tiebroken
FROM bookings
)
SELECT *
FROM t
WHERE book_date BETWEEN '2026-06-20 01:58' AND '2026-06-20 02:21'
ORDER BY book_date, book_ref;
| book_ref | book_date | total_amount | range_total | rows_total | tiebroken |
|---|---|---|---|---|---|
| FBD168 | 2026-06-20 01:58:00 | 1304.00 | 34666.00 | 34666.00 | 34666.00 |
| 6E9274 | 2026-06-20 02:00:00 | 506.00 | 35172.00 | 35172.00 | 35172.00 |
| 0477BC | 2026-06-20 02:01:00 | 258.00 | 35690.00 | 35430.00 | 35430.00 |
| A06421 | 2026-06-20 02:01:00 | 260.00 | 35690.00 | 35690.00 | 35690.00 |
| D18DDD | 2026-06-20 02:21:00 | 751.00 | 36441.00 | 36441.00 | 36441.00 |
02:01 的两笔订单在默认的 RANGE 框架下都显示 35690.00:它们彼此「看得见」对方。用 ROWS 时,第一笔显示 35430.00,第二笔显示 35690.00,这才是一步一步的累计。
但单靠 ROWS 也有问题:并列行的先后顺序是不确定的,哪一笔排在前面可能因运行次数或数据库而不同。稳妥的做法见最后一列:在 ORDER BY 里加一个唯一的决胜列(book_date, book_ref)。这样每一行都有自己的位置,RANGE 和 ROWS 的结果一致,而且可以重复得到同样的结果。
哪种才对,取决于你的问题:
- 「截至 02:01 的总营收」:
RANGE的答案是对的,两笔订单在那一刻都已发生。 - 「每笔交易之后的余额」:你需要每行走一步,所以用唯一的排序(想写得更明确的话再加上
ROWS)。
移动求和:ROWS BETWEEN n PRECEDING
改变框架的起点,累计求和就变成了移动(滚动)求和。7 天滚动营收:
WITH daily AS (
SELECT CAST(book_date AS DATE) AS day, SUM(total_amount) AS revenue
FROM bookings
GROUP BY CAST(book_date AS DATE)
)
SELECT day, revenue,
SUM(revenue) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS rolling_7d,
COUNT(*) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS days_in_window
FROM daily
ORDER BY day
LIMIT 8;
| day | revenue | rolling_7d | days_in_window |
|---|---|---|---|
| 2026-06-17 | 4419.00 | 4419.00 | 1 |
| 2026-06-18 | 7753.00 | 12172.00 | 2 |
| 2026-06-19 | 18220.00 | 30392.00 | 3 |
| 2026-06-20 | 59770.00 | 90162.00 | 4 |
| 2026-06-21 | 153054.00 | 243216.00 | 5 |
| 2026-06-22 | 290491.00 | 533707.00 | 6 |
| 2026-06-23 | 543399.00 | 1077106.00 | 7 |
| 2026-06-24 | 912564.00 | 1985251.00 | 7 |
days_in_window 这一列揭示了序列开头的陷阱:前六行的窗口里不足七天,它们的「7 天」合计和后面的数值没法直接比较。要么把它们隐藏起来(在外层查询里写 WHERE days_in_window = 7),要么清楚地标注出来。如果算的是滚动平均,同一框架下的 AVG 会除以窗口里实际的行数,反而把问题藏得更深,所以一定要检查行数。
日期有缺失时:带间隔的 RANGE
ROWS BETWEEN 6 PRECEDING 的意思是「前六行」,而不是「前六天」。如果序列中间有缺口,六行可能跨越两周。从莫斯科多莫杰多沃(DME)飞往堪察加彼得罗巴甫洛夫斯克(PKC)的航班不是每天都有,正好能看出区别:
WITH daily AS (
SELECT CAST(f.scheduled_departure AS DATE) AS day, COUNT(*) AS passengers
FROM flights f
JOIN ticket_flights tf ON tf.flight_id = f.flight_id
WHERE f.departure_airport = 'DME' AND f.arrival_airport = 'PKC'
GROUP BY CAST(f.scheduled_departure AS DATE)
)
SELECT day, passengers,
SUM(passengers) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS rows_7,
SUM(passengers) OVER (ORDER BY day
RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW) AS range_7d
FROM daily
ORDER BY day
LIMIT 8;
| day | passengers | rows_7 | range_7d |
|---|---|---|---|
| 2026-07-13 | 43 | 43 | 43 |
| 2026-07-16 | 66 | 109 | 109 |
| 2026-07-18 | 44 | 153 | 153 |
| 2026-07-20 | 52 | 205 | 162 |
| 2026-07-23 | 95 | 300 | 191 |
| 2026-07-25 | 58 | 358 | 205 |
| 2026-07-27 | 63 | 421 | 216 |
| 2026-07-30 | 92 | 470 | 213 |
到 7 月 30 日,ROWS 版本加总的是分布在 15 天里(7 月 16 日至 30 日)的七个航班日(470 名乘客),而 RANGE 版本正确地只覆盖 7 月 24 日至 30 日(213 名)。当排序列是日期或时间戳时,PostgreSQL 11 及以上版本支持带间隔偏移量的 RANGE。
另一种解决办法是在套用窗口之前,先用 generate_series 生成一张完整的日历表,把缺失的日期补成 0,再对补齐后的每日序列使用 ROWS。
窗口框架速查
| 框架 | 加总哪些行 | 典型用途 |
|---|---|---|
不写框架(默认 RANGE ... CURRENT ROW) |
之前所有行加上并列行 | 按日期的累计值 |
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW |
之前所有行,每行一步 | 每笔交易后的余额 |
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW |
当前行及前六行 | 没有缺口的每日序列的滚动求和 |
RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW |
最近 7 个自然日内的行 | 日期可能缺失时的滚动求和 |
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING |
整个分区 | 计算占比时的总数 |
基于累计求和的实用写法
累计值什么时候越过某个门槛?
先算累计值,再取第一个达到目标的行。这里找的是订单累计营收第一次超过 1 亿美元的那一天:
WITH daily AS (
SELECT CAST(book_date AS DATE) AS day, SUM(total_amount) AS revenue
FROM bookings
GROUP BY CAST(book_date AS DATE)
),
cum AS (
SELECT day, SUM(revenue) OVER (ORDER BY day) AS running_revenue
FROM daily
)
SELECT day, running_revenue
FROM cum
WHERE running_revenue >= 100000000
ORDER BY day
LIMIT 1;
| day | running_revenue |
|---|---|
| 2026-07-19 | 102940157.00 |
窗口函数本身不能写在 WHERE 里,所以要先把累计值放进 CTE。
累计占比(帕累托分析)
用累计值除以总数,就得到累计占比。把航线按营收排序,就能看出业务有多集中:
WITH route AS (
SELECT f.departure_airport || '-' || f.arrival_airport AS route,
SUM(tf.amount) AS revenue
FROM ticket_flights tf
JOIN flights f ON f.flight_id = tf.flight_id
GROUP BY f.departure_airport || '-' || f.arrival_airport
),
ranked AS (
SELECT route, revenue,
SUM(revenue) OVER (ORDER BY revenue DESC, route
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cum_revenue,
SUM(revenue) OVER () AS total,
ROW_NUMBER() OVER (ORDER BY revenue DESC, route) AS rn,
COUNT(*) OVER () AS routes
FROM route
)
SELECT rn, route, revenue,
ROUND(100.0 * cum_revenue / total, 1) AS cum_share_pct,
routes
FROM ranked
WHERE rn <= 5
ORDER BY rn;
| rn | route | revenue | cum_share_pct | routes |
|---|---|---|---|---|
| 1 | DME-KHV | 7534783.00 | 3.6 | 457 |
| 2 | KHV-DME | 7337978.00 | 7.2 | 457 |
| 3 | DME-OVB | 5482189.00 | 9.8 | 457 |
| 4 | OVB-DME | 5315037.00 | 12.4 | 457 |
| 5 | KHV-LED | 5076724.00 | 14.8 | 457 |
这里并排放了两个窗口:OVER () 为空的 SUM(revenue) OVER () 在每一行上都是总数,带排序的那个则是累计值。继续往下数可以发现,457 条航线中只要前 39 条就贡献了航段营收的一半。注意写的是 100.0 而不是 100:在 PostgreSQL 里,整数除以整数会丢掉小数部分。
旧写法与性能
在有窗口函数之前,累计求和是用相关子查询或自连接写的:对每一行,把日期更早的所有行加起来。结果一样,但每一行都要把表读一遍,表越大就越慢,增长是平方级的。窗口函数只排序一次,一趟扫描就走完所有行。在 PostgreSQL 里,给 PARTITION BY 和 ORDER BY 用到的列建索引可以省掉排序;如果是在聚合好的每日表上计算,开销本来就可以忽略。
如果多个地方都要用到累计值,就在 CTE 或视图里算一次,不要到处重复写窗口。
常见错误清单
- 在同一个查询里既筛选又开窗,而这个筛选本不该改变统计范围。先计算,再在外层查询里筛选。
ORDER BY里没有决胜列。 如果每一行都应该单独算一步,就加一个唯一列。- 在有缺失日期的序列上用
ROWS。 改用带间隔的RANGE,或者先补齐日历。 - 忘了
PARTITION BY,想要每组各自重置,结果得到一条跨越所有分组的线。 - 算占比时整数相除。 先乘以
100.0。 - 拿开头的滚动值和后面的比较,而开头的窗口里行数更少。
本文所有查询用的都是 Nomto SQL 课程里的表,你可以在 SQL 练习场里逐个运行,改一改分区或框架,看看数字怎么变化。想系统了解 ROW_NUMBER、RANK、LAG 等其他窗口函数,可以读 SQL 窗口函数详解;累计求和也是 SQL 面试题 里的常见考点。
常见问题
SQL 怎么计算累计求和?
用窗口函数:SUM(amount) OVER (ORDER BY date_column)。如果同一个日期有多行数据,先聚合(用 GROUP BY 或在 CTE 里),再对每日合计套用窗口。
怎么让累计值按分组或按月重置?
在 OVER 里加上 PARTITION BY:SUM(revenue) OVER (PARTITION BY customer_id ORDER BY day) 得到每个客户的累计值,PARTITION BY date_trunc('month', day) 得到本月累计。
累计求和里 ROWS 和 RANGE 有什么区别?
ROWS 按物理行计数;RANGE 按排序列的值计算,所以值相同的行(并列行)被当作同一步,得到相同的累计值。排序键唯一时,两者结果相同。RANGE 还支持 INTERVAL '6 days' PRECEDING 这样的间隔偏移量。
能在 WHERE 里使用累计值吗?
不能直接用,因为窗口函数在 WHERE 之后才计算。先把累计值放进 CTE 或子查询,再在外层查询里筛选。
这种写法在 MySQL 和 SQL Server 里能用吗?
SUM() OVER (PARTITION BY ... ORDER BY ...) 在 PostgreSQL、MySQL 8+、SQL Server、Oracle、SQLite 3.25+ 以及大多数分析型数据库里都能用。对带间隔偏移量的 RANGE,各数据库的支持程度不同;PostgreSQL 从 11 版开始支持。
其他语言: English