直接用 HOUR() 分组会因忽略日期导致跨天时间错分且统计失真;正确做法是用 DATE_FORMAT(login_time, '%Y-%m-%d %H') 按“年-月-日-小时”粒度分组,安全、可读、索引友好。

用 HOUR() 直接分组会漏掉跨小时的用户
很多人一上来就写 GROUP BY HOUR(login_time),结果发现 23:59 和 00:01 被分到不同组,但实际属于同一天的凌晨活跃高峰。更糟的是,如果数据横跨多天,HOUR() 完全不区分日期,2点登录的用户在周一、周二、周三全被压进同一组,统计彻底失真。
正确做法是先截取到「年-月-日-小时」粒度,再分组:
SELECT DATE_FORMAT(login_time, '%Y-%m-%d %H:00:00') AS hour_slot, COUNT(DISTINCT user_id) AS active_users FROM user_log GROUP BY DATE_FORMAT(login_time, '%Y-%m-%d %H')
注意:DATE_FORMAT(..., '%Y-%m-%d %H') 是分组依据,DATE_FORMAT(..., '%Y-%m-%d %H:00:00') 只是展示用,两者必须一致才能对齐时间槽。
为什么不用 STR_TO_DATE(HOUR(...), ...) 拼接
有人试图用 STR_TO_DATE(CONCAT(YEAR(login_time), '-', MONTH(login_time), '-', DAY(login_time), ' ', HOUR(login_time), ':00:00'), '%Y-%m-%d %H:%i:%s') 构造时间点——这不仅冗长,还会因隐式类型转换引发时区或精度问题(比如 HOUR() 返回 NULL 时整个表达式崩掉)。
DATE_FORMAT() 是唯一安全、可读、索引友好的方案。它原生支持格式化,且 MySQL 8.0+ 对该函数有优化,配合 login_time 上的 B-tree 索引仍可能走 range 扫描(取决于 WHERE 条件)。
- 别在
WHERE子句里对login_time做函数运算(如WHERE HOUR(login_time) = 14),会强制全表扫描 - 想查某小时段?用
WHERE login_time >= '2024-06-01 14:00:00' AND login_time
去重逻辑必须明确是「每小时去重」还是「全局去重后按小时计数」
这是业务含义最易混淆的点。上面示例中 COUNT(DISTINCT user_id) 是「每小时独立去重」,即同一个用户在 14:00 登录两次只算 1 人,在 14:00 和 15:00 各登录一次则两小时各算 1 人。
如果你要的是「当天活跃过的用户,在每个小时里是否出现过」(即布尔标记),就得用窗口函数或关联子查询,不能靠简单 GROUP BY 解决。
常见误判:把 COUNT(user_id) 当成活跃用户数——那只是登录次数,不是用户数。
时区不对会导致整点错位
MySQL 的 HOUR() 和 DATE_FORMAT() 全部依赖服务器时区(@@time_zone)。如果日志时间存的是 UTC,而服务器设的是 SYSTEM(本地时区),那 DATE_FORMAT(login_time, '%Y-%m-%d %H') 就会偏移。
务必确认三者一致:
- 原始数据中
login_time的时区含义(UTC?东八区?) - MySQL 会话时区:
SELECT @@time_zone - 必要时显式转换:
DATE_FORMAT(CONVERT_TZ(login_time, '+00:00', '+08:00'), '%Y-%m-%d %H')
漏掉时区校准,所有整点分组都会系统性偏移,而且很难排查——因为数据看起来“很整齐”。

















