在用户行为分析里,每月新增用户数是一个基础却容易算错的指標。很多系统并没有严格的注册环节,或者注册表和实际登录表不一致,这时如果直接对注册时间做GROUP BY,就会漏掉大量先试用后补信息的用户。更稳妥的做法是:从登录流水表中,利用MIN函数求出每个用户的最早登录日期,将其视为首次活跃时间,再按年月分组统计人数。

一、为什么用MIN函数而不是注册时间
大多数业务都有游客体验路径,用户可能在未填手机号前就点了微信授权并生成了本地账号,登录日志已经写入,但注册表还是空的。如果报表只查注册表,这部分人不会被计入新增,导致运营误判渠道质量。MIN函数作用在登录时间字段上,无论用户后来是否补全资料,都能抓到他第一次留下痕迹的时刻。
另一个常见问题是注册时间被后台脚本回写或批量导入覆盖,造成时间失真。登录流水通常 append-only,篡改成本高,用MIN(login_time) GROUP BY user_id 得到的首次登录日期更贴近真实拉新。下面先用一张简单的登录表来演示。
-- 登录流水表
CREATE TABLE user_login (
user_id BIGINT,
login_time DATETIME,
channel VARCHAR(20)
);
INSERT INTO user_login VALUES
(1001, '2023-01-05 10:00:00', 'app'),
(1001, '2023-01-20 12:00:00', 'app'),
(1002, '2023-01-15 09:30:00', 'web'),
(1003, '2023-02-03 18:00:00', 'app'),
(1002, '2023-02-10 08:00:00', 'web');
二、用MIN函数获取每个用户的首次登录日期
核心语句是对 user_id 分组,取 login_time 的最小值。这样每个用户只返回一行,即他的首登时间。我们可以把这一步写成子查询或CTE,方便后续按月汇总。
注意,如果 login_time 包含时分秒,直接按月份分组前需要用DATE_FORMAT或EXTRACT把年月提取出来,否则同一天的不同时刻不会被归到同一个月。以下查询在 MySQL 语法下求出首登日期:
-- 每个用户的首登日期
SELECT
user_id,
MIN(login_time) AS first_login
FROM user_login
GROUP BY user_id;
运行后,user_id为1001的 first_login 是2023-01-05,1002是2023-01-15,1003是2023-02-03。这个结果就是后续统计的基准。若数据量很大,建议在 user_id 和 login_time 上建联合索引,使分组扫描走索引有序性,减少临时表。
三、按月份分组统计新增用户数
把上面的首登结果作为派生表,再对外层按年月分组计数,就得到了每月新增。这里用DATE_FORMAT把首登转成年月字符串,COUNT(user_id)即为该月首次出现的人数。
该写法的优点是逻辑直观,任何支持标准SQL的数据库都能跑。缺点是派生表会先物化全部用户的首登,再分组;在亿级用户时,可改为直接嵌套聚合,或借助窗口函数优化,但MIN+GROUP BY仍是最易维护的版本。
-- 每月新增用户统计
SELECT
DATE_FORMAT(first_login, '%Y-%m') AS month,
COUNT(user_id) AS new_users
FROM (
SELECT
user_id,
MIN(login_time) AS first_login
FROM user_login
GROUP BY user_id
) AS t
GROUP BY DATE_FORMAT(first_login, '%Y-%m')
ORDER BY month;
以上语句输出两行:2023-01对应2人(1001和1002),2023-02对应1人(1003)。这就清晰回答了“每个月新增多少”的问题。如果还想拆到渠道维度,只需在内外层都加上 channel 分组,并注意子查询里 channel 要跟 user_id 一起GROUP BY或使用窗口函数取首登渠道。
四、与窗口函数写法的对比
有些开发者喜欢用 ROW_NUMBER() 打序号,取 rn=1 作为首登,再分组。这种方式在只需要首登本身时没问题,但MIN函数直接聚合,不需要排序开窗,在仅求最值场景通常更省资源。
下面的示例用窗口函数达到同样目的,逻辑稍重,但在同一查询里还要顺带取首登渠道、首登IP时更灵活。实际选型应看执行计划和表规模,不要盲目追求语法新颖。
-- 窗口函数替代方案
SELECT month, COUNT(*) AS new_users
FROM (
SELECT
user_id,
DATE_FORMAT(login_time, '%Y-%m') AS month,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_time) AS rn
FROM user_login
) AS t
WHERE rn = 1
GROUP BY month
ORDER BY month;
两种写法结果一致。MIN函数方案更贴合本题“通过MIN获取首次登录并分组”的语义,也更容易给后续接手的人讲清楚。生产环境中,把首登计算结果沉淀到用户宽表,每天增量更新,能进一步降低即席查询开销。