峰值统计是数据分析里非常高频的需求,比如运营要看每天的销售峰值、DBA要监控每小时的连接数峰值、产品要找出每个用户单日最大活跃时长。这类查询表面上是取最大值,实际写起来经常踩坑:峰值本身好取,但峰值对应的时间点、峰值与均值的差距、多维度组合下的峰值却需要不同技巧。本文用一套完整的案例把SQL峰值统计的常用写法梳理一遍,覆盖MySQL、PostgreSQL等主流数据库都能跑通的方案。

基础用法:MAX配合GROUP BY做分组峰值
先从最简单的场景说起。假设有一张订单表orders,包含字段order_id、user_id、amount、created_at,需求是统计每个用户的单笔最大订单金额。这是MAX最典型的用法,直接配合GROUP BY即可。
SELECT user_id, MAX(amount) AS max_amount FROM orders GROUP BY user_id;
这条语句的执行逻辑是:先按user_id分组,然后在每个组内对amount求最大值。需要注意的是,MAX是聚合函数,只能出现在SELECT列表、HAVING子句或ORDER BY中,不能直接写在WHERE里。如果只想看峰值超过1000的用户,要用HAVING来过滤,因为WHERE在分组前执行,聚合结果还不存在。
SELECT user_id, MAX(amount) AS max_amount FROM orders GROUP BY user_id HAVING MAX(amount) > 1000 ORDER BY max_amount DESC;
另一个容易出错的点是SELECT里的字段。上面查询里除了聚合列之外只出现了user_id,这是因为它恰好是分组列。如果再额外选出order_id,在严格模式(比如MySQL的ONLY_FULL_GROUP_BY)下会直接报错,因为一个用户可能有多条订单,引擎无法确定你要展示哪一行的order_id。这个限制正是引出下一个进阶需求的原因:怎么拿到峰值所在的那一整行数据。
进阶技巧:取出峰值所在行的完整信息
统计峰值时,业务方往往不只是要看一个数字,还要知道这个峰值是什么时候发生的、对应哪条记录。比如想知道每个用户金额最高的那笔订单的完整信息。这在SQL里有几种主流写法,各有优劣。
第一种是相关子查询的写法,兼容性最好,老版本的数据库也支持:
SELECT o.*
FROM orders o
INNER JOIN (
SELECT user_id, MAX(amount) AS max_amount
FROM orders
GROUP BY user_id
) t ON o.user_id = t.user_id AND o.amount = t.max_amount;
先在子查询里算出每个用户的峰值,再回表关联拿到整行数据。缺点是如果同一用户存在两笔金额相同且都是峰值的订单,结果会出现两行,需要根据业务判断是否要去重。另外在大表上,这种写法需要扫描两遍数据,性能一般。
第二种是用窗口函数,这是更现代也更优雅的方案:
SELECT *
FROM (
SELECT o.*,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY amount DESC) AS rn
FROM orders o
) t
WHERE rn = 1;
ROW_NUMBER按用户分组、按金额倒序编号后取第一名,天然解决并列峰值的问题(如果希望并列的都保留,换成RANK即可)。窗口函数只需要扫描一遍数据,性能明显优于子查询关联,MySQL 8.0以上、PostgreSQL、Oracle都支持。日常开发中,除非要兼容特别老的环境,否则推荐优先使用窗口函数方案。
实战案例:按天统计峰值并定位峰值时间
来一个更贴近实际的案例:监控系统中有一张指标表metrics,包含字段metric_name、value、recorded_at,需求是统计每个指标每天的最大值,并且要知道这个最大值出现在几点。很多人第一反应是这样写:
-- 错误示范
SELECT metric_name,
DATE(recorded_at) AS stat_date,
MAX(value) AS peak_value,
MAX(recorded_at) AS peak_time -- 这里的时间是全局最大时间,不是峰值时间!
FROM metrics
GROUP BY metric_name, DATE(recorded_at);
这个写法的隐蔽bug在于MAX(recorded_at)取的是当天最后一条记录的时间,而不是value最大的那条记录的时间。因为两个聚合函数是独立计算的,互相之间没有任何关联。正确做法还是回到窗口函数:
SELECT metric_name, stat_date, peak_value, peak_time
FROM (
SELECT metric_name,
DATE(recorded_at) AS stat_date,
value AS peak_value,
recorded_at AS peak_time,
ROW_NUMBER() OVER (
PARTITION BY metric_name, DATE(recorded_at)
ORDER BY value DESC, recorded_at ASC
) AS rn
FROM metrics
) t
WHERE rn = 1;
注意ORDER BY里加了recorded_at ASC作为次级排序,这样当一天内出现多个相同峰值时,会取最早发生的那个时间点,保证结果确定性。这类细节在监控报表里非常重要,否则同一条数据跑两次结果不一样,业务方会直接找上门。
扩展场景:峰值与均值对比、滑动窗口峰值
除了单点峰值,更高级的分析是峰值与平均值的对比,用来衡量波动是否剧烈。窗口函数可以直接在一条SQL里同时算出两者:
SELECT metric_name,
DATE(recorded_at) AS stat_date,
MAX(value) AS peak_value,
ROUND(AVG(value), 2) AS avg_value,
ROUND(MAX(value) / AVG(value), 2) AS peak_ratio
FROM metrics
GROUP BY metric_name, DATE(recorded_at)
HAVING MAX(value) / AVG(value) > 3
ORDER BY peak_ratio DESC;
peak_ratio超过3说明当天出现过明显的异常尖刺,配合HAVING过滤可以直接输出异常日期清单,是排查线上毛刺问题的常用手段。
还有一种需求是滚动峰值,比如计算每个时间点往前推一小时内的最大值,用于画峰值趋势曲线。这在PostgreSQL里可以用带ROWS/RANGE的窗口实现,MySQL 8.0虽然暂时不支持区间滑动的RANGE帧,但可以通过自关联或日期函数变通处理:
-- PostgreSQL:计算每小时窗口内的滚动峰值
SELECT recorded_at,
value,
MAX(value) OVER (
ORDER BY recorded_at
RANGE BETWEEN INTERVAL '1 hour' PRECEDING AND CURRENT ROW
) AS rolling_peak
FROM metrics
WHERE metric_name = 'cpu_usage'
ORDER BY recorded_at;
滚动峰值曲线能直观反映指标的瞬时压力上限,比单纯的日峰值更能暴露短时突发问题,比如某个服务在10秒内被打爆但拉长到分钟粒度就看不出来。
总结一下,峰值统计的核心思路有三层:简单分组峰值用MAX加GROUP BY;要定位峰值所在行用ROW_NUMBER窗口函数;要分析波动或滚动峰值则要靠窗口帧的灵活搭配。把这三种写法掌握好,日常九成以上的峰值统计需求都能应对。写这类SQL时特别提醒两点:一是注意ONLY_FULL_GROUP_BY模式下字段选择的合法性,二是遇到并列峰值时明确排序规则,让输出结果稳定可复现。
SQL MAX聚合函数峰值统计窗口函数修改时间:2026-09-14 12:51:56