SQL 中如何按日期分组统计每日新增用户?

Ada
Ada
发布时间:2026-08-25| 117人浏览过
原创

前言

按日期统计新增用户,看上去只是一个 GROUP BY,实际最容易错在时间戳分组粒度、首次注册口径,以及时间过滤写在哪一层。本文把这几个关键点拆开讲清,并给出可直接核对的 SQL 写法和排查思路,方便你快速判断结果偏差究竟来自语法、口径还是数据质量。

按天统计“新增用户”时,很多查询看起来能跑出结果,却会在时间戳分组、首次注册口径和时间过滤位置上悄悄出错。下面按几个最容易踩坑的点拆开说明,并给出不同数据库可直接套用的写法,方便你快速判断自己的 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 看起来没错,最后其实是统计口径和业务定义没有对齐。

相关标签:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。