count(distinct user_id) 不能直接算出峰值,因为它统计的是总去重人数,而非同一时刻并发在线人数;峰值需基于时间粒度,将登录时间扩展为带开始与结束的活跃区间,再通过时间切片展开并统计最大重叠数。

为什么 COUNT(DISTINCT user_id) 不能直接算出峰值?
很多人一上来就写 SELECT COUNT(DISTINCT user_id) FROM login_log,以为这就是“在线用户数”。其实这只是总去重人数,和“峰值”毫无关系。峰值必须带时间粒度——同一时刻有多少人在线,本质是求时间轴上并发会话数量的最大值。
关键在于:你得把每个用户的在线行为转化为「有开始、有结束」的时间段(比如登录时间 + session_timeout),再把这些时间段投影到时间线上,统计每秒/每分钟的重叠数。
怎么用窗口函数+时间序列生成模拟并发快照?
真实日志往往只有登录时间,缺少登出时间。稳妥做法是假设 session 有效期为 30 分钟(可根据业务调整)。先为每个用户生成一个「活跃区间」,再用时间切片暴力展开,最后聚合计数。
-
SELECT login_time, login_time + INTERVAL '30 minutes' AS logout_time构造每个用户的在线区间 - 用
generate_series()(PostgreSQL)或递归 CTE(MySQL 8.0+/SQL Server)按分钟生成时间点序列 -
JOIN区间与时间点:当ts >= login_time AND ts 时,该用户在 <code>ts时刻在线 -
GROUP BY ts后取MAX(COUNT(*))即为峰值
示例(PostgreSQL):
SELECT MAX(active_count) AS peak_online
FROM (
SELECT ts, COUNT(*) AS active_count
FROM login_log l
JOIN generate_series(
date_trunc('minute', l.login_time),
l.login_time + INTERVAL '30 minutes',
'1 minute'
) AS ts
ON ts >= l.login_time AND ts
<h3>性能差怎么办?避免全量时间展开</h3>
<p>如果日志量大(比如百万级登录记录),<code>generate_series</code> 展开会爆炸式膨胀。更高效的做法是只关注「事件时间点」:每个登录和登出都是一次状态变更,按时间排序后用累计加减法模拟在线数变化。</p>
- 把每个
login_time记为 +1,对应登出时间记为 -1(仍用login_time + INTERVAL '30 minutes') -
UNION ALL所有事件,ORDER BY event_time - 用
SUM() OVER (ORDER BY event_time)累计当前在线数 -
MAX(online_count)即峰值
这个方法只处理 2×N 行事件数据,不依赖时间粒度,速度提升明显,且结果更精确(无分钟截断误差)。
注意 timezone 和 session 定义的一致性
数据库 timezone 设置、日志时间字段的时区标注、session 超时逻辑三者必须对齐。常见坑:
- 日志里存的是 UTC 时间,但
generate_series默认用本地时区,导致时间错位 -
INTERVAL '30 minutes'没考虑夏令时跳变(PostgreSQL 中 safe,但某些数据库需用AT TIME ZONE显式转换) - 用户长时间不操作但未登出,实际在线状态和 session 过期时间不一致——这时需要结合心跳日志或实时连接池数据,纯 SQL 已无法覆盖
真正线上系统中,峰值统计往往要混合应用层上报、Redis 在线集合、以及 SQL 日志回溯三种来源,单靠聚合函数只是其中一环。











