正确做法是先按年月聚合再窗口求和,即用子查询或CTE对每月销售额聚合后,以ym排序累加;需补全缺失月份并用COALESCE处理零值,且注意时区转换与索引优化。

用 SUM() OVER() 实现窗口累计求和
直接用 SUM(sales) OVER (PARTITION BY YEAR(order_date), MONTH(order_date) ORDER BY order_date) 是错的——它算的是「当月内按日期累计」,不是「每月末的累计总额」。真正要的是按自然月粒度滚动累加,比如 1 月销 100、2 月销 150、3 月销 120,结果应是 100 → 250 → 370。
正确做法是先按年月聚合,再对聚合结果做窗口求和:
SELECT
ym,
monthly_sales,
SUM(monthly_sales) OVER (ORDER BY ym) AS cumulative_sales
FROM (
SELECT
DATE_FORMAT(order_date, '%Y-%m') AS ym,
SUM(amount) AS monthly_sales
FROM orders
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
) t;-
DATE_FORMAT(order_date, '%Y-%m')确保跨年不混(如'2023-12'和'2024-01'顺序正确) - 必须用子查询或 CTE 先聚合,否则窗口函数会在原始行上计算,导致重复累加
- PostgreSQL 用户把
DATE_FORMAT换成TO_CHAR(order_date, 'YYYY-MM'),SQL Server 用FORMAT(order_date, 'yyyy-MM')
处理缺失月份:补零后累计
如果某个月没销售数据,上面的 SQL 就不会出现该月,导致累计断层。比如 2 月无订单,结果从 1 月直接跳到 3 月,累计值也不连续。
需要生成完整月份序列再左连接:
WITH months AS ( SELECT '2023-01' AS ym UNION ALL SELECT '2023-02' UNION ALL SELECT '2023-03' UNION ALL -- ... 手动或用递归 CTE 生成范围 ), sales_agg AS ( SELECT DATE_FORMAT(order_date, '%Y-%m') AS ym, SUM(amount) AS amt FROM orders WHERE order_date >= '2023-01-01' GROUP BY DATE_FORMAT(order_date, '%Y-%m') ) SELECT m.ym, COALESCE(s.amt, 0) AS monthly_sales, SUM(COALESCE(s.amt, 0)) OVER (ORDER BY m.ym) AS cumulative_sales FROM months m LEFT JOIN sales_agg s ON m.ym = s.ym;
- 硬写
UNION ALL适合固定小范围;动态范围建议用递归 CTE(MySQL 8.0+ / PostgreSQL)或日历表 -
COALESCE(s.amt, 0)必须在窗口函数内使用,不能只在外层补零——否则累计会跳过 0 值月 - ORDER BY m.ym 依赖字符串排序,只要格式统一(
YYYY-MM)就安全
性能敏感时避免全表扫描
当 orders 表超千万行,每次查都要 GROUP BY DATE_FORMAT(order_date, '%Y-%m'),即使有 order_date 索引也用不上函数索引(MySQL 5.7 不支持函数索引,8.0+ 需显式创建)。
- 给
order_date加普通索引足够加速范围查询(如加WHERE order_date BETWEEN '2023-01-01' AND '2023-12-31') - 高频查询可建冗余字段
order_ym CHAR(7),写入时用触发器或应用层维护,再对该字段建索引 - 如果只看最近 12 个月,先用
WHERE order_date >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)限流,别让窗口函数扫全表
注意时区和日期截断逻辑
DATE_FORMAT(order_date, '%Y-%m') 用的是数据库服务器时区。若订单时间存的是 UTC,而业务要求按本地时区(如东八区)归月,直接格式化会出错。
- MySQL 中用
CONVERT_TZ(order_date, '+00:00', '+08:00')转换后再格式化 - PostgreSQL 用
(order_date AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Shanghai') - 更稳妥的做法是在应用层或 ETL 阶段把「业务日期」(非原始时间戳)单独存为字段,避免 SQL 层反复转换
累计值本身没歧义,但“哪个月”取决于你怎么定义月——这个边界一旦定错,后面所有累计都偏移,而且很难被肉眼发现。

















