最稳妥方式是用 GROUP BY + SUM() OVER (ORDER BY month_key) 计算累计用户数,需先按用户首次行为归月(如 DATE_TRUNC('month', MIN(event_time))),再分组统计并窗口累加,避免跨年乱序和重复计数。

用 GROUP BY + 窗口函数算累计用户数
直接用 SUM() 配合 OVER (ORDER BY ...) 是最稳妥的方式。关键在于:新增用户必须按注册时间归到对应月份,且月份要能正确排序(比如用 DATE_TRUNC('month', register_time) 或 TO_CHAR(register_time, 'YYYY-MM')),否则累计值会乱序。
常见错误是把 register_time 直接 GROUP BY EXTRACT(YEAR FROM register_time), EXTRACT(MONTH FROM register_time),但这样无法保证跨年时的自然顺序,窗口函数会按字典序累加(2023-12 之后可能是 2023-1 而不是 2024-01)。
- PostgreSQL 推荐用
DATE_TRUNC('month', register_time)作为分组和排序字段 - MySQL 8.0+ 可用
DATE_FORMAT(register_time, '%Y-%m-01') - SQLite 没有原生月截断函数,得用
strftime('%Y-%m-01', register_time) - 窗口函数里必须写
ORDER BY month_key ASC,不能只写ORDER BY month_key(默认可能是 DESC)
避免重复计数:确保“新增”定义清晰
新增用户指首次出现的 user_id,不是当月任意一次登录。如果原始表是行为日志(如 login_log),直接按月统计 COUNT(DISTINCT user_id) 会高估——同一个用户可能在多个月份都有记录,但只应算作首月新增。
正确做法是先找出每个用户的最早注册/首次行为时间,再按该时间归月:
WITH first_seen AS (
SELECT user_id, MIN(event_time) AS first_time
FROM event_log
GROUP BY user_id
)
SELECT
DATE_TRUNC('month', first_time) AS month,
COUNT(*) AS new_users,
SUM(COUNT(*)) OVER (ORDER BY DATE_TRUNC('month', first_time)) AS cum_users
FROM first_seen
GROUP BY DATE_TRUNC('month', first_time)
ORDER BY month;
- 别在主表上直接
GROUP BY后套窗口函数,那样累计的是当月去重数之和,不是真实累计用户数 - 如果数据量大,
first_seenCTE 建议在user_id和event_time上建复合索引 - 注意时区:所有时间字段需统一转为业务所在时区再截取月份,否则跨零点的用户可能被分到错误月份
兼容旧版本 MySQL(无窗口函数)的替代方案
MySQL 5.7 或更早版本不支持 OVER,只能用自连接或变量模拟累计。变量方式简洁但不可靠(执行计划变动、并发查询易出错),推荐自连接:
SELECT t1.month, t1.new_users, SUM(t2.new_users) AS cum_users FROM monthly_new t1 JOIN monthly_new t2 ON t2.month <= t1.month GROUP BY t1.month ORDER BY t1.month;
-
monthly_new是已按月聚合好新增数的临时表(需提前生成) - 自连接性能随月份数增长呈 O(n²),超过 3–5 年数据时建议加
month索引 - 变量写法(
@cum := @cum + new_users)在 MySQL 8.0+ 已不推荐,且在预编译语句或连接池中极易出错
NULL 或空月份要不要补全?
如果某个月没新增用户,上述写法默认不会输出该行(结果中缺行)。业务上是否需要补 0,取决于下游用途。补全逻辑本身不难,但容易忽略边界:
- 用
GENERATE_SERIES()(PostgreSQL)或递归 CTE 构造连续月份序列,再LEFT JOIN到统计结果 - 起止月份必须覆盖业务全周期,不能只取现有数据的
MIN/MAX—— 比如上线是 2022-03,但数据从 2022-05 开始,2022-03 和 2022-04 就该补 0 - 补零后累计值仍要按真实月份顺序计算,不能对 NULL 做
SUM(),得用COALESCE(new_users, 0)再窗口求和
补月逻辑一旦加上,整个查询复杂度明显上升,多数报表场景其实可以由前端或 BI 工具处理缺月,数据库层保持简单更稳。

















