正确做法是用DATE_FORMAT(ts, '%Y-%m-%d %H:00:00')对齐小时边界,再通过ROW_NUMBER() OVER (PARTITION BY hour_slot ORDER BY concurrent DESC, ts ASC)为每小时内并发降序、时间升序编号,最后WHERE rn = 1获取每小时峰值记录,同时需注意时区、索引与时戳语义等关键陷阱。

用 PARTITION BY 切小时粒度再找峰值
核心是把时间戳归到整点小时,再在每小时内找并发最高的那个时间点。不能直接 GROUP BY HOUR(ts) 然后取 MAX(concurrent)——那样丢掉了“时间点”信息。必须用窗口函数保留原始行,再筛选。
常见错误是写成:SELECT MAX(concurrent) OVER (PARTITION BY HOUR(ts)) AS peak,这只能拿到值,拿不到对应的时间戳。得配合 ROW_NUMBER() 或 RANK() 排序后取首行。
- 先用
DATE_FORMAT(ts, '%Y-%m-%d %H:00:00')(MySQL)或DATE_TRUNC('hour', ts)(PostgreSQL)对齐小时边界 - 再用
ROW_NUMBER() OVER (PARTITION BY hour_slot ORDER BY concurrent DESC, ts ASC):并发相同时优先取更早的时间点,避免随机性 - 外层
WHERE rn = 1即可拿到每小时的峰值记录
注意时区和时间字段类型陷阱
如果 ts 是 TIMESTAMP 类型但数据库默认时区不是业务所在地,HOUR(ts) 可能错位一小时。比如日志是 UTC 时间,而你按本地时区切小时,结果全偏了。
另一个坑是字段为 DATETIME 且没索引:按小时分组 + 排序会全表扫描,千万级表可能跑几分钟。建议提前建组合索引:INDEX (ts, concurrent),让排序能走索引。
- MySQL 中
CONVERT_TZ(ts, '+00:00', '+08:00')再截断,比在应用层转换更可控 - PostgreSQL 必须显式
AT TIME ZONE 'Asia/Shanghai',否则DATE_TRUNC按服务器时区执行 - 如果
ts是字符串(如'2024-05-20 14:23:17'),先用STR_TO_DATE()或TO_TIMESTAMP()转成时间类型,否则PARTITION BY会按字典序分组
并发量定义不明确会导致结果失真
“最大访问并发量”本身依赖采样逻辑。如果是每秒打点的 concurrent 字段,那直接取最大值没问题;但如果是基于日志推算的(比如每个请求带 start/end 时间),就得先做“区间重叠计数”,不能直接查原始字段。
典型误操作:把 Nginx 日志里 time_local 当作并发发生时刻,其实它只是日志写入时间,和真实请求处理时间有毫秒级偏差。高并发下这种偏差会让峰值时间点漂移。
- 若需精确区间并发,得用自连接或
LATERAL扫描所有与当前请求时间重叠的其他请求 - 简单近似方案:用
LEAD(ts, 1) OVER (ORDER BY ts)计算请求间隔,小于阈值(如 100ms)视为同一波并发,再聚合计数 - 生产环境建议统一用 APM 工具(如 SkyWalking)上报的
active_thread_count,比日志推算可靠
MySQL 8.0 以下版本无法用窗口函数怎么办
MySQL 5.7 或更老版本不支持 ROW_NUMBER(),得用变量模拟。但要注意变量执行顺序不可靠,尤其在加了 ORDER BY 或 LIMIT 后容易出错。
安全做法是用关联子查询,虽然慢但语义清晰:
SELECT t1.hour_slot, t1.ts, t1.concurrent
FROM (
SELECT
DATE_FORMAT(ts, '%Y-%m-%d %H:00:00') AS hour_slot,
ts, concurrent
FROM access_log
) t1
WHERE NOT EXISTS (
SELECT 1 FROM access_log t2
WHERE DATE_FORMAT(t2.ts, '%Y-%m-%d %H:00:00') = t1.hour_slot
AND t2.concurrent > t1.concurrent
);
这个写法在并发值有重复时会返回多行,需要额外去重逻辑。真要兼容旧版,不如先升级 MySQL 或改用临时表预聚合。
真正难的不是写对窗口函数,而是确认“并发量”这个指标在你系统里到底由什么产生、有没有采样丢失、时间戳是否代表处理开始时刻——这些细节不厘清,SQL 写得再漂亮,结果也是误导性的。










