应使用GROUP BY DATE(log_time)截断时分秒并配合WHERE log_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)动态筛选,再用COUNT(DISTINCT user_id)去重统计,避免误用COUNT(*)或错误索引。

用 DATE_SUB 和 GROUP BY DATE() 拆分日期维度
直接对时间字段做 GROUP BY 时,如果字段含时分秒(比如 datetime 类型),必须先截断到日粒度,否则同一天不同时间的记录会被拆成多组。MySQL 中最稳妥的方式是 GROUP BY DATE(log_time);如果用 GROUP BY log_time 或 GROUP BY CAST(log_time AS DATE),在某些版本或严格模式下可能报错或结果不一致。
同时,筛选“过去30天”不能写死日期,得用动态计算:WHERE log_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)。注意这里用 CURDATE() 而非 NOW(),避免因时分秒干扰边界(比如今天是 2024-06-15 14:22:03,DATE_SUB(NOW(), INTERVAL 30 DAY) 是 2024-05-16 14:22:03,会漏掉 5 月 16 日 00:00–14:22 的记录)。
去重统计用 COUNT(DISTINCT user_id),别用 COUNT(*)
活跃用户数本质是「每天有多少个不同的 user_id」,不是「每天多少条日志」。如果一个用户当天有 5 次操作,COUNT(*) 会算成 5,而 COUNT(DISTINCT user_id) 才是 1。
常见错误包括:
- 漏写
DISTINCT,导致数值虚高 - 在
WHERE条件里误加user_id IS NOT NULL—— 其实COUNT(DISTINCT ...)本就自动忽略NULL,加了反而让意图变模糊 - 用
UNIQUE或UNIQUE(user_id)(语法错误,SQL 不支持)
确保 log_time 和 user_id 字段有合适索引
这类查询通常扫描量大,没索引时可能全表扫几十秒甚至超时。关键索引组合是:(log_time, user_id)。原因:
-
log_time用于范围过滤(>= DATE_SUB(...)),放索引最左列能高效定位起始位置 - 加上
user_id后,COUNT(DISTINCT user_id)可在索引内完成,避免回表查原始行 - 单独建
log_time索引不够,因为之后还要按user_id去重,仍需访问数据页
如果表已有 (user_id, log_time) 索引,效果会差很多——范围查询无法利用该索引的左前缀。
注意时区和数据延迟带来的统计偏差
数据库服务器时区和业务时区不一致时,CURDATE() 返回的是服务器本地日期,可能导致“昨天”实际对应业务侧的“今天凌晨”。例如服务器在 UTC+0,业务在 UTC+8,那么服务器 00:00 时,业务时间还是前一天 08:00,这时查“过去30天”会少算 8 小时。
更隐蔽的问题是数据写入延迟:用户行为日志可能经 Kafka/Flink 异步落库,真实发生时间和入库时间相差几分钟甚至更久。如果按入库时间统计,某天 23:59 的行为可能被计入第二天。解决办法是优先使用日志中的业务时间戳(如 event_time),而非数据库的 created_at。
另外,如果业务定义“活跃”需要满足特定动作(比如登录 + 页面浏览 > 2 次),就不能只靠 user_id 存在与否判断,得先过滤再聚合——这点容易被忽略,一上来就套用模板反而统计失真。











