在电商或库存系统的数据表中,我们往往既想看到每一笔成交的价格,又希望顺带知道这个商品到目前为止出现过的最贵价格。使用普通的GROUP BY会把明细行合并掉,而关联子查询在大数据量下又慢得难以接受。窗口函数里的MAX OVER可以原地算出每个商品的历史最高价,同时不破坏原有记录行。

一、什么是MAX OVER窗口函数
MAX OVER是SQL标准中的窗口聚合函数,它在指定分区(PARTITION BY)内计算最大值,但不会像普通聚合那样把多行压缩成一行。每一行都会附加一个所在窗口的MAX结果,因此原始明细被完整保留。这种特性让它非常适合做“带维度的极值标注”。
基本语法为MAX(列名) OVER (PARTITION BY 分区列 [ORDER BY 排序列])。当省略ORDER BY时,MAX取整个分区的最大值;加上ORDER BY后,默认取从窗口第一行到当前行的累积最大值,这在统计“截至当日历史最高”时非常有用。
二、建表与示例数据
我们先构造一张简单的商品价格流水表,包含商品编号、成交日期与成交价格,便于后续演示MAX OVER的不同写法。
CREATE TABLE product_price (
product_id INT,
sale_date DATE,
price DECIMAL(10,2)
);
INSERT INTO product_price VALUES
(1, '2023-01-01', 10.00),
(1, '2023-01-05', 15.00),
(1, '2023-01-10', 12.00),
(2, '2023-01-02', 20.00),
(2, '2023-01-08', 18.00);
上面的数据里,商品1的最高价是15,商品2的最高价是20。如果我们直接GROUP BY product_id,就只能拿到这两个数字,看不到每天的价格明细。接下来用窗口函数解决这个矛盾。
三、统计每个商品的全周期历史最高价
最简单的形式是不加ORDER BY,仅按商品分区,此时MAX OVER会返回该商品在所有已知记录里的最高价格。
SELECT
product_id,
sale_date,
price,
MAX(price) OVER (PARTITION BY product_id) AS max_price_all
FROM product_price
ORDER BY product_id, sale_date;
执行后,商品1的三行都会带上15.00,商品2的两行都会带上20.00。这种写法常用来给明细行打上“全局最高价”标签,比如做价格异常预警时,可以一眼看出哪天卖得比历史峰值低很多。
从执行计划角度看,数据库只需对分区做一次排序或哈希分组,再广播最大值给分区内各行,成本远低于关联子查询。在千万级数据上,通常能从几十秒降到毫秒级响应。
四、统计截至当日的累积历史最高价
如果业务关心的是“到这一天为止,历史上最高卖过多少”,就需要在OVER子句里加ORDER BY。此时窗口默认从分区首行滑动到当前行。
SELECT
product_id,
sale_date,
price,
MAX(price) OVER (
PARTITION BY product_id
ORDER BY sale_date
) AS max_price_so_far
FROM product_price
ORDER BY product_id, sale_date;
以商品1为例:1月1日最高就是10;1月5日变成15;1月10日虽然当天是12,但累积最高仍是15。这比全周期最高多了时间维度上的渐进信息,适合绘制价格爬坡曲线。
需要注意的是,ORDER BY产生的默认窗口是RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。如果同一天有多条相同商品记录,它们会被视作同一排序值并共享累积最大值,不会出现前后行不一致的问题。
五、与传统写法的对比
在不支持窗口函数的旧版本数据库里,开发者常用关联子查询实现类似需求,例如下面这段:
SELECT
a.product_id,
a.sale_date,
a.price,
(SELECT MAX(b.price)
FROM product_price b
WHERE b.product_id = a.product_id) AS max_price_all
FROM product_price a;
这段代码逻辑正确,但每一行都要触发一次子查询扫描,复杂度是O(N^2)。当表扩大到百万行,数据库很容易超时。而MAX OVER只需一次分区扫描,代码也更短,可读性明显更好。
另外,有些人尝试用自连接再GROUP BY,同样会临时膨胀数据量。窗口函数由于不改变行数,在IO和内存上都有优势,是现代化SQL报表的首选方案。
六、常见误区与注意事项
一个容易踩的坑是:以为PARTITION BY和GROUP BY效果一样。实际上GROUP BY会折叠行,MAX OVER不会。若误把聚合结果与窗口结果混用,可能导致报表行数不对。
另一个误区是在ORDER BY后误以为MAX只取“当前行的值”。如前所述,默认框架是到当前行之前的所有行,所以它能反映历史峰值。若只想看当前行自身,应使用MAX(price) OVER (PARTITION BY product_id ORDER BY sale_date ROWS BETWEEN CURRENT ROW AND CURRENT ROW),但那种写法失去了历史含义,一般很少用。
| 写法 | 返回行数 | 时间含义 | 性能 |
|---|---|---|---|
| GROUP BY | 每商品一行 | 全周期 | 中等 |
| 关联子查询 | 原样明细 | 全周期 | 较差 |
| MAX OVER无ORDER BY | 原样明细 | 全周期 | 优 |
| MAX OVER有ORDER BY | 原样明细 | 累积至当日 | 优 |
七、实战组合技巧
我们可以把历史最高价和当前价做差,快速找出折扣力度。例如下面语句直接算出商品低于峰值多少:
SELECT
product_id,
sale_date,
price,
MAX(price) OVER (PARTITION BY product_id) AS peak,
MAX(price) OVER (PARTITION BY product_id) - price AS diff
FROM product_price;
这种衍生列在运营分析中极其实用,既不用临时表,也不用多层嵌套。配合视图封装,前端直接拖拽字段就能出图。
总体而言,MAX OVER以极低的认知成本和执行开销,解决了明细与聚合共存的经典难题。只要分清带不带ORDER BY的语义差别,就能灵活应对各种历史极值统计场景。