1. 先说一个最容易被忽略的问题:MAX() 真的只有“取最大值”这么简单吗
很多同学刚开始学 SQL 的时候,第一个聚合函数接触的往往不是 COUNT() 就是 MAX()。SELECT MAX(price) FROM products,查出一个最高价,完事。看起来确实没什么好讲的。但我在实际开发里发现,MAX() 这个函数真正用好的时候,远不止“找出最大数”这么简单,而用错的时候,造成的困惑又往往非常隐蔽。
前阵子帮同事排查一个线上报表的问题,逻辑很简单:想按用户分组取出每个人最后一次登录时间,SQL 写出来大概是:
sql复制SELECT user_id, MAX(login_time)
FROM user_login_log
GROUP BY user_id;
这段 SQL 本身没问题,但同事拿这个结果去关联 user 表取用户昵称的时候,出来的数据让他怀疑人生——同一批 user_id 居然能关联出多条记录,而且昵称还不一定对。问题出在哪?出在他以为“MAX() 查出了最后一次登录时间对应的整行记录”,但实际上 MAX() 只返回那个时间值,不返回那一行的其他字段。要用最大值所在的完整记录,得换思路。
这其实是对 MAX() 函数最常见的误解之一。所以我想借这篇内容把 MAX() 从语法、分组、窗口函数、数据清洗到性能优化完整捋一遍,也把那些我在实际项目里踩过的坑、排查过的慢 SQL 一并说清楚。这篇文章适合谁?如果你刚学 SQL 不久,可以用它把聚合函数的底子打牢;如果你已经写了几年 SQL,那重点看第 5 节和第 6 节的避坑与优化思路,应该会有帮助。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MAX() 基础语法与一个很多人没注意过的“输出规则”
2.1 标准语法和最简单的例子
MAX() 是标准 SQL 里的聚合函数,作用是返回某列的最大值。基本语法就一行:
sql复制SELECT MAX(column_name)
FROM table_name
[WHERE condition];
其中 column_name 可以是数值列、字符串列、日期列。它处理的数据类型很宽,但要注意的是:字符串列比较的是字典顺序,日期列比较的是时间先后,只有数值列才是真正数学意义上的“大小”。
举两个例子对比一下。数值列:
sql复制SELECT MAX(price) AS max_price
FROM order_items;
商品订单明细表里最高的一笔单价是多少,返回一行一列。
日期列:
sql复制SELECT MAX(created_at) AS latest_order_time
FROM orders;
订单表里最近一次下单时间。日期时间类型的大小比较就是时间先后,所以 MAX(created_at) 天然等价于“取最新时间”。
2.2 输出规则:聚合函数不配合 GROUP BY 时结果永远只有一行
这一条对新手来说特别容易困惑。在没有 GROUP BY 的情况下,对全表做聚合,结果必然是一行。注意我说的是“必然”——就算表里一行数据都没有,它也会返回一行。
空表的时候:
sql复制SELECT MAX(price) FROM empty_table;
不会报错,也不会返回零行,而是返回一行,那个字段的值是 NULL。这一点很多从 Excel 转过来的人会不适,因为 Excel 里 MAX 空区域返回 0,SQL 里返回 NULL,处理不好就是下游一堆空值报错。
2.3 MAX() 与 WHERE 的执行顺序
MAX() 配合 WHERE 时,执行顺序是先过滤后聚合。数据库引擎执行的时候会先根据 WHERE 条件把数据行筛出来,再对筛选结果做最大值运算。例如:
sql复制SELECT MAX(amount)
FROM payments
WHERE status = 'success';
这个需求语境是:只统计支付成功的订单里最大金额。如果写得不对,把条件放到聚合之后,或者忘了加条件,统计口径就会偏。
一个常见的错误是试图用 WHERE MAX(...) > 1000 筛选分组后的结果,这直接违反 SQL 执行顺序,会报错。聚合之后的条件过滤要用 HAVING,这个后面第 3 节会展开。
2.4 MAX() 对 NULL 的处理
这算是在数据质量参差不齐的生产环境里最实用的小知识点:MAX() 会自动忽略 NULL 值。
比如某张表里有一列 score,三行数据分别是 85、NULL、92,那么 SELECT MAX(score) 返回 92,而不是因为碰见 NULL 就整体变成 NULL。
但如果整列都是 NULL,或者表是空的,那 MAX() 的返回值就是 NULL。也就是说,聚合函数不会把“全 NULL”的结果当成 0 行,它仍然返回一行值 NULL。这在做数据校验的时候需要特别小心,如果下游有非空约束或数值计算,最好用 COALESCE(MAX(column), 0) 包一层兜底。
3. 分组统计里的 MAX():搭配 GROUP BY 才是生产环境的高频用法
3.1 分组求最大值的基本写法
MAX() 最常出现的生产场景是配合 GROUP BY 做分组统计。比如:
- 每个部门里工资最高的员工工资是多少
- 每个商品类目下最近一次上架时间是什么时候
- 每台服务器 CPU 使用率的历史峰值是多少
语法模板是:
sql复制SELECT group_column, MAX(value_column)
FROM table_name
GROUP BY group_column;
以员工表为例:
sql复制SELECT dept_id, MAX(salary) AS max_salary
FROM employee
GROUP BY dept_id
ORDER BY max_salary DESC;
语义是:“按部门分组,计算每个部门的最高工资”。注意一个细节:如果你在 SELECT 里同时写了 dept_id 和 MAX(salary),那么 dept_id 必须出现在 GROUP BY 中。反之,如果 GROUP BY 只写了 dept_id,那么 SELECT 里就只能出现 dept_id 和聚合函数,不能再出现 emp_name、hire_date 这类非分组非聚合列——在 MySQL 关闭 ONLY_FULL_GROUP_BY 模式时这个限制可能被放宽,但得到的结果是随机值,容易埋雷。
3.2 分组后过滤:HAVING 与 MAX() 的组合
聚合之后再过滤不能写在 WHERE 里,要用 HAVING。比如找出平均工资超过 10000 的部门中,部门的最高工资是多少:
sql复制SELECT dept_id, MAX(salary) AS max_salary
FROM employee
GROUP BY dept_id
HAVING AVG(salary) > 10000;
这个语句的完整执行逻辑是:先按 dept_id 分组,然后分别计算每组平均工资和最高工资,再保留平均工资大于 10000 的组。也就是说 HAVING 子句是在分组聚合完成之后才执行的,所以里面可以放心写聚合函数。
3.3 常见业务误用:要“最大值所在的整行”时,MAX() 单独用是做不到的
这是特别经典的一个问题。举个例子,需求是查每个部门里工资最高的员工,并且要展示员工姓名、工资、入职日期。很多人第一反应是:
sql复制SELECT dept_id, emp_name, MAX(salary)
FROM employee
GROUP BY dept_id;
这段 SQL 在 MySQL 的非严格模式下能跑出结果,但 emp_name 是错的——它不是工资最高那个人的名字,而是该组内任意一条记录的员工名,随着存储引擎和执行计划变化可能还不稳定。正确方案有三种。
方案一:关联子查询。先查出每个部门的最高工资,再通过关联条件取整行记录:
sql复制SELECT e.dept_id, e.emp_name, e.salary
FROM employee e
INNER JOIN (
SELECT dept_id, MAX(salary) AS max_salary
FROM employee
GROUP BY dept_id
) t ON e.dept_id = t.dept_id AND e.salary = t.max_salary;
方案二:窗口函数。用 ROW_NUMBER() 对每个部门内部按工资排序,取排名第一的行:
sql复制SELECT dept_id, emp_name, salary
FROM (
SELECT dept_id, emp_name, salary,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn
FROM employee
) t
WHERE rn = 1;
方案三:部分数据库支持 DISTINCT ON(PostgreSQL)或者 QUALIFY(Snowflake、BigQuery、Hive 较新版本),写法会更简洁,但可移植性差一些。
我个人的习惯是:能用窗口函数就用窗口函数。因为它不仅能把最高工资的记录取出来,还能顺手多取前几名、最低工资、总数等衍生指标,一条 SQL 搞定,可读性和维护性都好。
3.4 去重场景里的 MAX():数据清洗时的“取最新有效值”
搜索热词里反复出现“sql 语句去重查询”和“清洗---sql 语句去重”,在真实的数据清洗需求中,MAX() 经常被用来做“每个分组取最新值”。比如埋点日志表里同一 user_id 有多条记录,需要按用户取最后一次状态:
sql复制SELECT user_id, MAX(update_time) AS last_update_time
FROM user_status_log
GROUP BY user_id;
这个结果可以直接拿去关联维度表做“用户最新状态”宽表。但如果用户要的是“最新那条记录的完整内容”,那又回到 3.3 的问题,必须配合窗口函数或关联子查询。比如用窗口函数取每个用户最新一条状态记录:
sql复制SELECT user_id, status, update_time
FROM (
SELECT user_id, status, update_time,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY update_time DESC) AS rn
FROM user_status_log
) t
WHERE rn = 1;
这个写法在清洗乱序数据、构建用户维度快照、拉取商品最新价格等场景中出现频率极高,建议直接背下来。
4. 窗口函数中的 MAX():从“分组聚合一行”到“每行保留明细”
4.1 为什么需要用 MAX() OVER()
普通 GROUP BY 的痛点在于:一旦分组,明细行就被压缩了,输出结果里每个组只剩一行。但真实业务里经常既要明细数据,又要带上聚合指标做对比分析。比如分析订单时,想看每一笔订单金额和该用户历史最大订单金额的差距。如果先 GROUP BY user_id 算出最大金额,再 JOIN 回订单明细,也能做,但写法复杂,而且多一次 JOIN,性能不一定好。
窗口函数就是专门解决这个问题的:
sql复制SELECT order_id,
user_id,
amount,
MAX(amount) OVER (PARTITION BY user_id) AS user_max_amount
FROM orders;
这个查询返回的每一行都是订单明细,不会做行数压缩。user_max_amount 这一列是每一行所属用户的最大下单金额。这样可以直接做差额计算:
sql复制SELECT order_id,
user_id,
amount,
MAX(amount) OVER (PARTITION BY user_id) AS user_max_amount,
amount - MAX(amount) OVER (PARTITION BY user_id) AS diff_from_max
FROM orders;
4.2 MAX() OVER() 与 GROUP BY MAX() 的关键差异
| 对比维度 | GROUP BY + MAX() | MAX() OVER(PARTITION BY) |
|---|---|---|
| 返回行数 | 每组一行,明细被压缩 | 每行保留,明细不丢 |
| 典型用途 | 分组汇总报表 | 明细与聚合值同时展示 |
| 能否直接取整行记录 | 不能,需二次关联 | 能,原行列都在 |
| 执行引擎 | 常见于所有数据库 | 需要数据库支持窗口函数 |
窗口函数不是所有数据库都支持。MySQL 8.0 开始支持,5.7 及以下不行;SQL Server 从 2005 年开始支持;PostgreSQL 很早就支持;Oracle 更是把窗口函数玩出了花。如果你还在用 MySQL 5.7,遇到这个需求就老老实实走 3.3 的关联子查询方案。
4.3 累计最大值:MAX() OVER(ORDER BY ...) 的进阶玩法
MAX() 窗口函数不只能配 PARTITION BY,还可以配合 ORDER BY 做累计计算。比如求“截至当前日期的历史最高水位”,只需要把 PARTITION BY 去掉,只保留 ORDER BY:
sql复制SELECT trade_date,
closing_price,
MAX(closing_price) OVER (ORDER BY trade_date) AS historical_max_price
FROM daily_price;
在这个查询中,窗口范围默认是从分区起点到当前行。所以每一行输出的 historical_max_price 是从第一天到该日期的最大值,不是全表的最大值。这在股市回撤分析、库存水位监控、版本发布记录等场景很有用。
同理,如果想实现“该行之前 N 天内的最大值”,可以用 ROWS BETWEEN N PRECEDING AND CURRENT ROW 控制窗口范围,这在计算移动峰值、异常检测阈值时很常见:
sql复制SELECT trade_date,
closing_price,
MAX(closing_price) OVER (ORDER BY trade_date ROWS BETWEEN 30 PRECEDING AND CURRENT ROW) AS max_30d
FROM daily_price;
这个语句表达的意思就是“最近 30 天(含当天)的最高收盘价”,在量化分析里属于最常用的技术指标之一。
4.4 窗口函数包含 NULL 时的表现与处理
窗口函数中的 MAX() 也会忽略 NULL 值,但这里有一个容易踩坑的点:如果某一行自己要展示的值是 NULL,计算差值时会出现 NULL 传播。例如:
sql复制SELECT order_id,
amount,
MAX(amount) OVER (PARTITION BY user_id) AS user_max_amount,
amount - MAX(amount) OVER (PARTITION BY user_id) AS diff
FROM orders;
如果某行 amount 本身是 NULL,那么这一行的 diff 必然是 NULL。我在处理埋点数据时经常遇到这种场景,因为业务上报的金额偶尔为空。稳妥的做法是先对原始列做清洗,把 NULL 统一处理成 0 或剔除掉,再进窗口计算,避免下游报表出现大量空白:
sql复制SELECT order_id,
COALESCE(amount, 0) AS amount,
MAX(COALESCE(amount, 0)) OVER (PARTITION BY user_id) AS user_max_amount
FROM orders;
但要注意:COALESCE 之后,原本是 NULL 的订单金额会被当成“0 元订单”参与最大值的比较,如果业务上希望过滤脏数据而不是按 0 处理,那还是在 WHERE 阶段先把 amount IS NOT NULL 的行筛出来更合理。数据清洗策略没有银弹,我的建议是先把数据分布摸清楚再决定。
5. MAX() 的经典坑位:数据字典序、空字符串与隐式类型转换
5.1 字符串类型的 MAX() 是字典序而不是数值序
这一条非常容易出错,尤其是当“数字”被存成 VARCHAR 类型时。举一个我真实遇到的例子。某张日志表里有个字段 visit_duration,因为历史原因存的是字符串,里面是用户访问时长(秒)。我想查最长的访问时长是多少,就很自然地写了:
sql复制SELECT MAX(visit_duration) FROM page_visit_log;
结果返回的是 99。为什么?因为这列是 VARCHAR,MAX() 按字典序比较,而不是按数值大小。字符串排序规则下,“9”开头的字符串比“10”开头的字符串大,所以 99 排在了 1000 前面。这种错误一旦混进报表,很难被发现,因为 99 看起来也是一个合理数字。
解决办法是显式转换类型:
sql复制SELECT MAX(CAST(visit_duration AS INT)) FROM page_visit_log;
不同数据库的转换语法略有不同。MySQL 可以写成 MAX(visit_duration + 0) 或 MAX(CAST(visit_duration AS SIGNED));SQL Server 是 MAX(CAST(visit_duration AS INT));PostgreSQL 是 MAX(CAST(visit_duration AS INTEGER))。这里提醒一点:如果列里有非数字的脏数据,CAST 会直接报错或返回 NULL,因此在转换前最好先用正则把非数字内容清洗掉。
5.2 空字符串和 NULL 不是一回事
这是一个小坑,但也真能坑到人。很多业务系统在用字符串存储可选字段时,空值会有两种状态:一种是数据库的 NULL,一种是空字符串 ''。MAX() 聚合时,NULL 会被忽略,但空字符串不会——它会被当成一个普通的字符串参与比较。
比如某张表 remark 列有三行数据:'售后'、''、'退货'。那么 SELECT MAX(remark) 的结果是什么?按常见排序规则,空字符串字典序最小,所以最大值是 '售后'。但如果你设计的业务逻辑里,空字符串代表“无备注”,那这个最大值可能就不是你想要的。
更常见的是下面这种坑。如果某张表的 status_code 字段是 VARCHAR 类型,里面存储了 '1'、'2'、'10' 这样的状态值,同时还有大量空字符串和 NULL,那么:
sql复制SELECT MAX(status_code) FROM order_status;
这个最大值可能会让你一脸懵。因为字符串排序把所有一开头的都排在一起,'10' 会比 '2' 小。处理办法还是老套路:先统一清洗空字符串与 NULL,再考虑类型转换。比如:
sql复制SELECT MAX(CASE WHEN status_code IS NULL OR status_code = '' THEN '0' ELSE status_code END)
FROM order_status;
5.3 日期时间不等于字符串:日期列的 MAX() 是时间比较
日期列用字符串存还是用 DATE/DATETIME 类型存,直接影响 MAX() 的结果语义。如果列是真正的日期类型,MAX() 返回的是最接近当前的时间点,没问题。但如果你把日期存成 '2024-01-05' 这样的 VARCHAR,在格式统一为 YYYY-MM-DD 的情况下,字典序恰好等于时间序,倒也能凑合用;但只要格式混入 '2024/01/05'、'2024-1-5',字典序就彻底乱了。
所以最终建议非常明确:日期时间字段一定用数据库的日期时间类型存储,不要图省事存字符串。这不仅是为了 MAX(),更是为了所有后续时间计算。
5.4 MAX() 与 MIN()、COUNT()、SUM() 用在同一行的结果集特征
面试题里经常出现一个刁钻的问题:不写 GROUP BY,能不能在一条 SQL 里同时查出最大、最小、总条数和总和?
sql复制SELECT MAX(amount) AS max_amount,
MIN(amount) AS min_amount,
COUNT(*) AS total_count,
SUM(amount) AS total_amount
FROM payments;
答案是可以。因为 COUNT(*) 和 COUNT(column) 还是有一点区别的,值得在这里一并提醒:
COUNT(*)统计的是行数,不管有没有 NULL;COUNT(column)统计的是该列非 NULL 的行数;MAX()、MIN()、SUM()都忽略 NULL,但SUM()在全 NULL 时返回 NULL,不是 0。
如果全表没有任何匹配行,COUNT(*) 会返回 0,但 MAX(amount) 返回 NULL。报表开发时经常要把这种情况转成 0:
sql复制SELECT COUNT(*) AS total_cnt,
COALESCE(MAX(amount), 0) AS max_amount,
COALESCE(SUM(amount), 0) AS total_amount
FROM payments
WHERE status = 'refunded';
这样即使没有退款记录,也能输出一行全 0 的统计,不会因为 NULL 导致前端图表报错。
5.5 MAX() 取多列最大值:没有原生函数时怎么优雅处理
有几种场景,比如一张商品表里有 original_price 和 promotion_price 两列,想取“每个商品曾经出现过的更高价格”。表面上看,MAX() 函数一次只能传一个参数,不能直接 MAX(original_price, promotion_price)——在 MySQL/PostgreSQL/SQL Server 里,这样的写法会直接报错。
正确做法分数据库:
如果只是想比较同一行内两列谁更大,用 GREATEST():
sql复制SELECT product_id,
GREATEST(original_price, promotion_price) AS higher_price
FROM products;
MySQL、PostgreSQL、Oracle 都支持 GREATEST()。SQL Server 直到 2022 版本才引入 GREATEST(),旧版本只能用 CASE WHEN:
sql复制SELECT product_id,
CASE WHEN original_price > promotion_price THEN original_price
ELSE promotion_price END AS higher_price
FROM products;
如果需要跨行、跨分组比较多个列,那要先把列转成行(UNPIVOT),再用普通 MAX()。在 SQL Server 里可以写:
sql复制SELECT product_id, MAX(price) AS max_price
FROM (
SELECT product_id, original_price AS price FROM products
UNION ALL
SELECT product_id, promotion_price AS price FROM products
) t
GROUP BY product_id;
这里用 UNION ALL 而不是 UNION,是为了保留重复值,避免去重导致统计失真。类似的做法在 MySQL 和 PostgreSQL 里思路一致,只是语法略有差别。
6. 性能视角:MAX() 相关的慢 SQL 到底慢在哪
搜索热词里面“慢 sql 优化”“并行 sql 优化”“sql 优化”出现的频率不低,所以我用一整节讲 MAX() 相关的性能问题。这个函数看起来简单,但执行计划差别巨大,有的快得离谱,有的能把数据库拖垮。
6.1 为什么有时候 MAX() 快得像作弊
如果直接对某个列做 MAX(),而且这个列上有索引,数据库引擎通常不需要扫描整张表。以 MySQL InnoDB 为例,索引本身就是按顺序排列的,引擎取 B+ 树最右侧的叶子节点就能拿到最大值,复杂度是 O(log N),基本是毫秒级。如果没有任何索引,那只能走全表扫描,一行一行读完才能确定最大值。
所以第一个性能优化原则很简单:高频 MAX() 查询的列,尽量建索引。
sql复制CREATE INDEX idx_orders_created_at ON orders(created_at);
建立索引之后,下面的查询可以走索引有序性直接返回结果:
sql复制SELECT MAX(created_at) FROM orders;
MySQL 执行计划里会出现类似 Select tables optimized away 的字样,意思是优化器直接从索引里取到了结果,连表都没读,速度极快。
6.2 加了 WHERE 条件之后,MAX() 的性能点变了
MAX() 单独走索引很快,但加了一个筛选条件后,情况就复杂了。比如:
sql复制SELECT MAX(created_at)
FROM orders
WHERE status = 'completed';
如果 status 列选择性低(一个表里大部分订单都是 completed),优化器可能直接放弃索引,选择全表扫描。这里的问题不是 MAX() 本身慢,而是 WHERE status = 'completed' 需要扫描的数据量太大。
更微妙的是联合索引的选择。如果查询经常是“按用户查最近订单时间”,那么建联合索引时把等值条件放在前面、把排序/范围条件放在后面,效果更好:
sql复制CREATE INDEX idx_orders_user_created ON orders(user_id, created_at);
此时下面的查询可以快速定位到该用户的订单索引范围,再取最右边那条记录的时间,效率很高:
sql复制SELECT MAX(created_at)
FROM orders
WHERE user_id = 10086;
6.3 GROUP BY + MAX() 的慢 SQL 排查思路
让我设想一个真实的慢查询场景。一个电商后台要做“每个品类的最高折扣价”报表,SQL 长这样:
sql复制SELECT category_id, MAX(discount_price)
FROM products
GROUP BY category_id;
products 表有上千万行,category_id 和 discount_price 都没有索引,这条 SQL 大概率会全表扫描,扫描完还得做哈希分组,慢是一定的。优化思路按优先级排列:
第一,检查是否已经建了 (category_id, discount_price) 联合索引。如果建了,MySQL 可以用松散索引扫描(loose index scan)或索引分组扫描,避免读全表。
第二,如果业务允许,缩小扫描范围。例如只统计近 30 天上架的商品:
sql复制SELECT category_id, MAX(discount_price)
FROM products
WHERE created_at >= NOW() - INTERVAL 30 DAY
GROUP BY category_id;
第三,如果数据量实在太大,而且实时性要求不高,考虑物化视图或者定时任务把结果预先算好,查询直接读结果表。
6.4 并行 SQL 优化中的 MAX():数据仓库场景的分治思路
热词里出现“并行 sql 优化”,这通常出现在数据仓库或大规模分析场景。并行 SQL 的核心思路是把数据分片,每个分片独立计算,最后归并结果。MAX() 天生适合并行——每个分片算完局部最大值,再对所有局部最大值取一次最大值,结果就是全局最大值。这也是为什么 MAX() 在大数据引擎(Spark SQL、Hive、Presto)里执行效率通常不错。
但如果你自己写分片逻辑,有一个细节要注意:分片不是越多越好。每个分片的启动、调度、网络传输都有开销。表不大时,反而是一个简单串行扫描更快。我见过一个案例,有人把一张只有几十万行的表强行分了 200 个分片跑 MAX(),结果两分钟没跑完,而直接用单线程全表扫描只要 3 秒。分治只解决大问题,不解决小问题。
6.5 尽量别在 MAX() 里包一层函数
最后一条优化建议很基础,但经常被违反。对列做函数处理后再求最大值,会让索引失效。比如:
sql复制SELECT MAX(DATE(created_at))
FROM orders;
这个想表达的是“所有订单日期中的最大日期”。写的人的本意可能是从 DATETIME 里取日期部分再比较,但这样写导致 created_at 列被 DATE() 函数包裹,MySQL 很难直接倒序走索引,只能扫描全部行做函数计算后再比较。
正确的等价写法是:
sql复制SELECT MAX(created_at)
FROM orders;
如果确实只想取最大的日期而不是具体时间点,取到 MAX(created_at) 之后再在外面包一层 DATE(),而不是在列上先做函数再聚合:
sql复制SELECT DATE(MAX(created_at))
FROM orders;
这样 MAX() 可以正常走索引,最后对单个结果再做一次日期格式化,性能损失忽略不计。
7. 几个实战场景的完整 SQL 写法,可以直接抄
前面讲的都是原理和坑,最后这一节我整理几个高频实战场景的完整 SQL。这些都是我在日常开发中反复用到的模式,可以视为模板直接套用。
7.1 每个分类下最新一条记录
需求:商品分类表 + 商品操作记录表,取每个分类下最近一次操作的商品。
sql复制SELECT category_id, product_id, op_time
FROM (
SELECT category_id,
product_id,
op_time,
ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY op_time DESC) AS rn
FROM product_ops
) t
WHERE rn = 1;
不要试图用 GROUP BY category_id, product_id 去做,那样会把同一商品的多条操作压缩,得不到“分类下最新一条操作对应的商品”这个语义。
7.2 每笔订单金额与当日最大订单金额的差额
sql复制SELECT order_id,
order_date,
amount,
MAX(amount) OVER (PARTITION BY order_date) AS daily_max_amount,
amount - MAX(amount) OVER (PARTITION BY order_date) AS gap_from_daily_max
FROM orders
WHERE order_date BETWEEN '2025-01-01' AND '2025-01-31';
这个查询非常适合做运营分析:每天最大单值是多少,每一单离当天峰值差多少。MAX() OVER (PARTITION BY order_date) 不会压缩行数,因此每一行都能看到明细和聚合值的对比。
7.3 取某个时间点之前的最大版本号
在配置管理、数据同步场景里,经常需要找出某个时间点之前发布的最大版本号。假设版本号是数值型的 version_no:
sql复制SELECT MAX(version_no) AS max_version
FROM config_release
WHERE release_time <= '2025-06-01 00:00:00';
如果版本号是字符串型的 'v1.2.3',就不能直接 MAX(),要先转换成可比较的数值或按段拆分排序。简单场景下可以拆出主版本号来比较:
sql复制SELECT MAX(CAST(SUBSTRING_INDEX(version_no, '.', 1) AS UNSIGNED)) AS max_major_version
FROM config_release
WHERE release_time <= '2025-06-01 00:00:00';
7.4 用户最近一次有效登录与历史最长连续登录天数
把 MAX() 和窗口函数、日期函数组合使用,可以完成比较复杂的用户行为分析。比如计算每个用户最近一次登录,以及登录日期中距离最远的跨度:
sql复制SELECT user_id,
MAX(login_date) AS last_login_date,
DATEDIFF(MAX(login_date), MIN(login_date)) AS login_span_days
FROM user_login_record
GROUP BY user_id;
这里 MIN() 和 MAX() 一起使用,统计了每个用户第一次登录和最后一次登录之间隔了多少天。如果要做更复杂的连续登录天数,还需要借助窗口函数的 LAG() 或行号差值法,但基础聚合取首末时间这个动作,MAX()/MIN() 依然是起点。
7.5 每台服务器的资源峰值水位,用于容量规划
监控系统里最常见的一种查询:从历史监控数据中,找出每台服务器 CPU、内存、磁盘的峰值。
sql复制SELECT host_id,
MAX(cpu_usage) AS peak_cpu,
MAX(memory_usage) AS peak_memory,
MAX(disk_usage) AS peak_disk
FROM server_metrics
WHERE ts >= '2025-01-01 00:00:00' AND ts < '2025-02-01 00:00:00'
GROUP BY host_id;
这个查询能直接输出容量规划需要的峰值指标。如果还要知道峰值出现的具体时间点,那就是“取最大值所在行”的问题,可以参考 3.3 的窗口函数方案。
8. 关于 MAX() 的个人经验与一些容易被忽略的实操习惯
最后分享几个我在实际开发中养成的习惯,这些不是教科书内容,纯粹是踩坑踩出来的经验。
第一,生产环境里写 MAX() 之前,先确认列的数据类型。VARCHAR 里存数字这种设计在遗留系统里非常常见,查出来结果不对,不一定是你 SQL 写错,而是数据模型本身埋了雷。排查时先 DESC table_name 看字段类型,比反复看 SQL 语法高效得多。
第二,MAX() 返回的 NULL 要显式兜底。只要这条 SQL 可能查不到数据,或者列里有大量 NULL,就应该主动用 COALESCE(MAX(...), ...) 包一层,避免把 NULL 带给下游。这个习惯在写报表 SQL、数据接口 SQL 时尤其重要。
第三,窗口函数普及之后,能用 MAX() OVER() 就不要频繁用“分组后回表关联”的写法。不是说关联写法不对,而是窗口函数写出来意图更清晰,执行计划也往往更优。但前提是你的数据库版本支持窗口函数,MySQL 至少 8.0。
第四,跟 MAX() 有关的慢 SQL,不要只看 SQL 本身,先看执行计划。EXPLAIN 或 EXPLAIN ANALYZE 会明确告诉你有没有走索引、扫描了多少行。很多时候问题不是出在 MAX(),而是出在过滤条件的选择率和索引缺失上。
第五,字符串日期、字符串数字这种历史债务,能清就尽早清。就算你用 CAST() 临时解决了查询问题,后续每一次排序、关联、聚合都可能重新踩坑。字段类型设计正确,SQL 才能写得简单可靠。
如果你也被某个“看起来很简单但结果总不对”的 MAX() 查询困扰过,不妨按这个顺序排查一遍:字段类型、NULL 与空字符串、分组字段是否完整、窗口函数范围是否正确、执行计划是否走索引。八成的问题都能在这几步里找到答案。
