按天统计“新增用户”时,很多查询看起来能跑出结果,却会在时间戳分组、首次注册口径和时间过滤位置上悄悄出错。下面按几个最容易踩坑的点拆开说明,并给出不同数据库可直接套用的写法,方便你快速判断自己的 SQL 问题到底出在哪。
为什么直接按 created_at 分组会出错
最常见的错误,是直接写 GROUP BY created_at。这样分组时使用的是完整时间戳,通常精确到秒,同一天里不同时间写入的记录会被拆成多组,最后看到的就不是“按天统计”的结果。
正确思路是先把时间截断到“年月日”,再进行分组。这里不要想当然地把一种写法套到所有数据库里,不同引擎支持的函数并不完全一致,最好使用各自原生的日期截断方式。
- MySQL:
DATE(created_at) - PostgreSQL:
created_at::DATE或DATE_TRUNC('day', created_at) - SQL Server:
CAST(created_at AS DATE) - SQLite:
DATE(created_at)
如果你只是要统计每天有多少条注册记录,这一步就已经够了;但如果目标是“每日新增用户”,还不能停在这里,因为新增的定义不是“当日有记录”,而是“用户第一次出现的那一天”。
“每日新增用户”应该怎么定义
“每日新增”意味着每个 user_id 只能在首次注册那一天被计入一次。因此,不能直接对 created_at 分组后再 COUNT(*),那样统计出来的是注册记录数,或者更宽泛地说,是当日满足条件的行为记录数,并不等于去重后的新增用户数。

更准确的做法,是先按 user_id 找出最早的 created_at,也就是每个用户的首次出现时间,再按日期聚合统计:
SELECT DATE(first_seen) AS day, COUNT(*) AS new_users
FROM (
SELECT user_id, MIN(created_at) AS first_seen
FROM users
GROUP BY user_id
) t
GROUP BY DATE(first_seen)
ORDER BY day;这条 SQL 的核心在于两层逻辑:
先取首次时间,再按日汇总
子查询中的 MIN(created_at) 决定了每个用户第一次出现的时间;外层再对 first_seen 做日期截断和计数,得到的才是每天真正新增了多少用户。
大表场景要考虑索引
如果 users 表数据量很大,GROUP BY user_id 这一步可能会比较慢。为了降低扫描和排序成本,通常建议在 user_id 与 created_at 上建立联合索引。是否能明显提速,还要看你的表结构、数据分布以及数据库执行计划,但这个方向通常是排查性能问题的第一步。

时间范围过滤放哪一层才对
另一个很容易写错的位置,是时间范围条件到底加在子查询还是外层。这个问题本质上不是语法问题,而是统计口径问题。
如果你要查最近 7 天或某个时间段内的新增用户,时间条件应该进入“找首次时间”的那一层,也就是子查询里。这样才能保证你统计的是“目标时间段内首次出现的用户”。
SELECT DATE(first_seen) AS day, COUNT(*) AS new_users
FROM (
SELECT user_id, MIN(created_at) AS first_seen
FROM users
WHERE created_at >= '2024-06-01'-- ✅ 这里过滤
GROUP BY user_id
) t
GROUP BY DATE(first_seen)
ORDER BY day;这样写的含义很明确:先只看 2024-06-01 之后的数据,再从这些记录里找每个用户在该时间窗口内的首次出现时间,最后统计每天新增人数。
为什么放在外层会出逻辑偏差
如果把 WHERE created_at >= '2024-06-01' 放到外层,等于先在全量数据里找到用户最早的 first_seen,再过滤日期。这样会把首次时间早于 6 月 1 日的用户直接排除掉。对于“统计时间段内新增用户”这个目标来说,这样的结果往往不符合预期,也容易和业务定义打架。
所以判断标准很简单:如果你统计的是某个时间段内的“首次出现”,过滤就应参与首次时间的计算过程,而不是在结果出来后再补一刀。
NULL 和异常时间值为什么不能忽略
即便 SQL 写法正确,数据质量问题也会让结果静默失真。最典型的情况是 created_at 允许为 NULL,或者表里混入了类似 '0000-00-00' 这样的异常时间值。
这类数据进入 MIN(created_at) 后,可能导致某些用户的首次时间不可用,最终被排除在统计结果之外,而且很多时候数据库不会主动报错,只会让最终新增数偏低。
上线前至少要做几项基础检查:
- 执行
SELECT COUNT(*) FROM users WHERE created_at IS NULL - 执行
SELECT COUNT(*) FROM users WHERE created_at < '1970-01-01'(或你业务允许的最早时间) - 如果发现异常值,要么先清洗数据,要么在子查询中过滤掉无效时间
例如可以这样补条件:
WHERE created_at IS NOT NULL AND created_at > '1970-01-01'这一步看似不起眼,但实际很关键。因为一旦统计结果偏低,又没有明显报错,排查成本通常比修 SQL 本身更高。
一条实用检查清单
如果你已经写好 SQL,但结果总觉得不对,可以按下面顺序快速自查:
- 是否把完整时间戳直接拿去
GROUP BY - 是否先按
user_id取了MIN(created_at) - 时间范围过滤是否放在了子查询里
- 是否排查了
NULL和明显异常的时间值 - 大表是否缺少
user_id、created_at相关索引
满足这些条件后,再去核对业务定义,例如“新增”到底指首次注册、首次登录,还是首次产生某类行为。很多 SQL 看起来没错,最后其实是统计口径和业务定义没有对齐。









