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 的结果一致,而且可以重复得到同样的结果。

哪种才对,取决于你的问题:

移动求和: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 或视图里算一次,不要到处重复写窗口。

常见错误清单

本文所有查询用的都是 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