1. 线上取数错了一整列:一个被 max() 坑过的真实场景
先说结论:GROUP BY和MAX()放在同一条SELECT里,绝大多数人第一反应是"取每个分组里最大的那条记录",但 MySQL 实际给你的,是"每个分组里最大的那个值"再加上"随便某一行的其他列"。这两件事中间差着一个巨大的坑,而它最恶心的地方在于——SQL 不报错,结果还看起来挺像回事。
我遇到的第一个案例是做电商订单统计。需求很朴素:查出每个用户金额最高的那一笔订单,展示订单号、金额、下单时间。当时写的 SQL 大概是这样:
SELECT user_id, order_no, amount, created_at FROM orders GROUP BY user_id;跑出来居然有结果,而且是每个用户一行。当时团队里没人多想,直接拿去出报表了。半个月后财务对账发现,订单号跟金额对不上——金额确实是该用户的最大值,但订单号是另一个订单的,下单时间又对不上。整张报表全废。
这个场景不是个例。只要你在 MySQL 里写过"分组取最新一条""分组取最大值整行"这类需求,几乎必然会踩一次。它涉及的坑至少有三层:第一层是 SQL 语义理解偏差,第二层是 MySQL 历史遗留的宽松模式,第三层是 5.7 之后ONLY_FULL_GROUP_BY带来的行为变化。下面我按这三层往下拆,把每种写法的实际行为、正确方案、性能差异全都摊开讲。
这篇文章适合三类人看:写过GROUP BY但没细想过非聚合列取值的后端开发;正在做报表、看板、数据导出,被结果"看对眼但其实错"坑过的人;以及面试里被问过"MySQL 里GROUP BY非聚合字段取哪一行"但答得含糊的同学。我会从原理讲到解法,再讲到性能取舍和排查技巧,尽量让你看完就能直接改自己手上的 SQL。
2. 先把原理说清楚:非聚合列的值到底从哪来
2.1 分组之后,那些没被聚合的列去哪了
GROUP BY的本质是把行集合按某个键切成若干子集,然后每个子集只输出一行。问题就出在"只输出一行"这个动作上:如果SELECT里出现了没有被聚合函数包起来的列,MySQL 必须从该子集里挑一个值出来充当这一行的输出。
关键在于,标准 SQL 从来没规定过它该挑哪一个。挑第一行、最后一行、随便一行,都算合法。MySQL 在很长一段时间里选择的做法非常"随意":它按执行计划扫描到的顺序,取分组过程中遇到的第一行的值。这个"第一行"依赖存储引擎的扫描顺序、索引选择、甚至优化器版本,完全不受你控制。
所以MAX()给你的是确定的最大值,而旁边那一列给你的是不确定的随机值。两者被并排放进同一行输出,读者自然以为它们来自同一条记录,这就是所有误会的源头。
用生活类比:你让助理从一堆发票里找出金额最大的那张,然后把"最大金额"和"随便一张发票的编号"抄在同一行报给你。金额没错,编号也没造假,但它俩根本不是同一张票。你的报表就变成了一个精心包装的谎言。
2.2 ONLY_FULL_GROUP_BY:从"不报错"到"报错"的分水岭
MySQL 5.7 之后默认开启了sql_mode中的ONLY_FULL_GROUP_BY,这条规则要求:出现在SELECT、HAVING、ORDER BY里的非聚合列,必须出现在GROUP BY中,或者被聚合函数包裹,否则直接报错。
报错长这样,很多人应该眼熟:
ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'db.orders.order_no' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by注意这里的措辞——"functionally dependent",函数依赖。如果GROUP BY的列是主键或唯一键,那么其他列在函数上依赖它,这时ONLY_FULL_GROUP_BY会放行。比如GROUP BY id(id 是主键),SELECT id, name是允许的,因为每个 id 只对应一行,不存在取值歧义。
但GROUP BY user_id时,一个 user_id 对应多行订单,order_no 不函数依赖于 user_id,于是报错。这个报错其实是 MySQL 在帮你,它把"结果不确定"这件事显式地摆到了你面前。
麻烦的是,很多老项目、老服务器、或者被人手动改过sql_mode的环境里,ONLY_FULL_GROUP_BY是关掉的。关掉之后那条 SQL 就能跑,且返回值看起来正常。这类环境是坑的高发区——代码在开发机上报错,到了生产环境跑得通,于是有人干脆把生产的sql_mode也改宽松了,顺手把坑留了下来。
2.3 为什么"看起来对"比"直接报错"更危险
这里必须强调一个判断:报错是好事,静默是错误的灾难。
报错会强迫你面对语义问题,你要么改成正确写法,要么显式告诉 MySQL 你想取哪一行。而静默的错误结果会一路流进报表、流进结算、流进运营的决策看板,等到有人对账才发现,中间可能已经过了好几轮业务决策。
我见过更隐蔽的情况:某个用户的订单恰好按 id 递增插入,而MAX(amount)对应的那笔刚好是最新一笔,于是"随机取第一行"碰巧取到了正确记录。测试环境数据量小、插入顺序规整,全部通过。上线后数据一乱,结果就开始飘。这种"间歇性正确"最难排查。
所以第一个实操心得是:把sql_mode检查加进你的项目启动自检里,或者至少在 Code Review 时对GROUP BY + 非聚合列的组合红色标记。别指望测试环境能替你发现它。
3. 三种典型错误写法,逐个拆开看它们错在哪
3.1 裸写 GROUP BY,指望它自动配对齐整行
最常见的就是开头那种写法:
SELECT user_id, order_no, amount, created_at FROM orders GROUP BY user_id;ONLY_FULL_GROUP_BY关闭时它才跑得动。结果是每个用户一行,amount 是最大值,order_no 和 created_at 来自分组内扫到的第一行。你想要的"最大金额那一单"的三个字段根本不在同一行。
有人会想,那我给GROUP BY加上order_no不就行了?GROUP BY user_id, order_no确实是合法 SQL,但它改变了分组粒度——现在每个用户每单一行,等于没分组,MAX(amount)也退化成了每行自己的金额。需求直接丢了。
3.2 用 ORDER BY + LIMIT 取全局,而不是分组内
第二种错误出现在"取每个用户最新一单"的需求上,有人写成:
SELECT user_id, order_no, created_at FROM orders ORDER BY created_at DESC LIMIT 1;这条只返回全表最新的一条,不是每个用户的最新一条。如果需求是"每个用户各取最新一单",这条 SQL 从语义上就跑了偏。想靠它凑出分组效果,只能在应用层循环每个 user_id 各查一次,N+1 查询,数据量一上来就崩。
3.3 把 MAX 和普通列混在 HAVING 里,误以为 HAVING 能过滤出行
第三种更隐蔽,出现在想"只保留最大值那一行"的场景:
SELECT user_id, order_no, amount FROM orders GROUP BY user_id HAVING amount = MAX(amount);这段在宽松模式下也可能跑通,但语义是混乱的:HAVING里的amount引用的是分组内那个不确定的非聚合值,MAX(amount)是组内最大值,两者相等只在"恰好选中了最大值那一行"时成立。一旦第一行不是最大值行,条件为假,整组被滤掉——你会丢数据,而且丢得毫无规律。
注意:
HAVING是用来过滤分组的,不是用来在分组内挑选行的。想挑选行,应该在分组之前用 WHERE、相关子查询或窗口函数解决。
这三种写法的共同点是:作者在脑子里把"分组"和"取某一行"两件事混成了一件事。想清楚这两件事的本质区别,所有正确解法都是自然推导出来的。
4. 正确解法一:关联子查询和自连接,老版本也能稳
4.1 关联子查询:语义最直白,适合中小数据量
思路非常朴素:先对每个用户求出最大金额,再回过头去订单表里找出金额等于该最大值的那些行。
SELECT o.user_id, o.order_no, o.amount, o.created_at FROM orders o WHERE o.amount = ( SELECT MAX(o2.amount) FROM orders o2 WHERE o2.user_id = o.user_id );执行逻辑是:外层扫描每一行,对每行拿它自己的 user_id 去子查询里算该用户的最大金额,相等就留下。结果是完整的整行,字段天然对齐。
这个写法有几个必须提醒的点。第一,如果某个用户有多笔金额相同的最大订单,会全部返回,这是符合语义的——你确实有多个"最大"。如果业务上只想要一条,得再补ORDER BY created_at DESC LIMIT 1或者加唯一性条件。第二,o2.user_id上如果没有索引,外层每行都要全表扫一遍,量一大就是灾难。第三,子查询里的MAX可能被优化成相关子查询的重复计算,实际执行次数等于外层行数,性能敏感场景要谨慎。
我在百万级以下的表上常用这个写法,因为可读性最好,出问题一眼能看出语义。前提是user_id必须有索引。
4.2 自连接(JOIN):本质相同,但写法能更灵活
把上面那个子查询思路改成 JOIN,性能特征类似,但更方便扩展多条件:
SELECT o.user_id, o.order_no, o.amount, o.created_at FROM orders o JOIN ( SELECT user_id, MAX(amount) AS max_amount FROM orders GROUP BY user_id ) m ON o.user_id = m.user_id AND o.amount = m.max_amount;派生表m里每用户一行,存着该用户的最大金额,再和原表按"用户 + 金额"双条件关联,命中的就是那些完整行。这个写法的好处是派生表只算一次分组,不像相关子查询那样每行重算,通常比关联子查询更快。
但它也有代价:如果最大金额在表里分布很广,JOIN 出来的中间结果集会膨胀。更麻烦的是派生表在 MySQL 5.7 之前无法下推条件下沉,可能物化成临时表再 JOIN,内存和 IO 都不小。MySQL 8.0 的派生物化优化改善了不少,可以放心一些。
提示:给派生表起别名不能省,MySQL 要求每个派生表都必须有别名,这是硬性语法要求。
4.3 处理"并列最大"的业务决策
两条 SQL 都能返回并列最大值行,接下来是业务问题:你到底想保留哪一条。常见策略有三类,我在不同项目里都落过地。
一是加"时间最新"做二次排序,用ORDER BY created_at DESC配上LIMIT 1放在最外层,但LIMIT会破坏"每个用户一行"的分组结构,必须在应用层或子查询里逐组处理,不太优雅。二是用窗口函数解决,这是下一节的主角。三是在关联子查询里再嵌一层排序取第一条的关联条件,写法会很啰嗦,不推荐。
我的经验是:一旦需求里出现"最大值并列时怎么选",就说明这个需求本质上是在"排序取 top1",而不是在"取最大值"。请直接切到窗口函数的思路,别在聚合函数上硬撑。
5. 正确解法二:窗口函数,MySQL 8.0 之后的首选
5.1 ROW_NUMBER 按组排序取第一行
GROUP BY + MAX()真正想表达的语义,用窗口函数表达是最精确的:
SELECT user_id, order_no, amount, created_at FROM ( SELECT user_id, order_no, amount, created_at, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY amount DESC, created_at DESC) AS rn FROM orders ) t WHERE rn = 1;PARTITION BY user_id把数据按用户切组,ORDER BY amount DESC, created_at DESC在组内排序,ROW_NUMBER()给组内每行一个序号。外层只取rn = 1,得到的就是每个用户金额最大、并列时取最新的那一行,且所有字段都来自同一条记录。
这个写法同时解决了两件事:整行对齐,以及并列时的确定性选择。ORDER BY里多加几个字段就能把并列处理得越来越细,比如ORDER BY amount DESC, created_at DESC, order_no DESC,保证结果完全可复现。
5.2 ROW_NUMBER、RANK、DENSE_RANK 的区别要选对
这三个函数在"取最大值整行"场景里经常被混用,要分清:
| 函数 | 并列处理 | = 1时会返回 | 适用场景 |
|---|---|---|---|
ROW_NUMBER() | 并列也强行编号,必不重复 | 只有 1 行 | 必须唯一取一条 |
RANK() | 并列共享名次,之后跳号 | 并列的全部行 | 想拿到所有并列最大的行 |
DENSE_RANK() | 并列共享名次,之后不跳号 | 并列的全部行 | 同 RANK,但名次连续 |
如果你要"每个用户的最大金额整行,并列全都要",就用RANK() = 1。如果要"每个用户只留一条",用ROW_NUMBER() = 1。别拿RANK去替ROW_NUMBER,否则并列时会多返回行,下游再做去重代码就更乱。
5.3 窗口函数不能写在 WHERE 里的原因
新手最容易踩的坑是直接这么写:
-- 这是错的,执行会报错 SELECT user_id, order_no, amount FROM orders WHERE ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY amount DESC) = 1;窗口函数的计算发生在WHERE、GROUP BY、HAVING之后,SELECT阶段才产生。WHERE执行时它还不存在。所以必须套一层子查询或 CTE,在外层过滤rn。这个执行顺序是固定的:FROM → WHERE → GROUP BY → HAVING → 窗口函数 → SELECT → ORDER BY → LIMIT,理解了顺序,很多"为什么不能写在这"的问题都迎刃而解。
用 CTE 改写会更清爽:
WITH ranked AS ( SELECT user_id, order_no, amount, created_at, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY amount DESC, created_at DESC) AS rn FROM orders ) SELECT user_id, order_no, amount, created_at FROM ranked WHERE rn = 1;MySQL 8.0.1 起支持 CTE,配合窗口函数可读性非常好。如果团队还在 5.7,这条得改用前面的关联子查询或 JOIN 方案。
6. 正确解法三:GROUP_CONCAT 和排序技巧,特殊场景的备选
6.1 GROUP_CONCAT 取组内目标字段
GROUP_CONCAT的思路是:把组内某个字段拼成一个字符串,配合ORDER BY让它按你想要的顺序拼接,再取第一个,就能拿到"组内最大值对应的那个字段"。
SELECT user_id, SUBSTRING_INDEX( GROUP_CONCAT(order_no ORDER BY amount DESC, created_at DESC), ',', 1 ) AS top_order_no, MAX(amount) AS max_amount FROM orders GROUP BY user_id;原理是GROUP_CONCAT支持在括号内排序,先按金额降序排,再拼接订单号,拼出来第一个就是目标订单号。SUBSTRING_INDEX(..., ',', 1)取逗号前的第一段。MAX(amount)单独求最大值,两者语义上是配对的。
这个写法有几个硬限制必须知道。第一,GROUP_CONCAT默认最大长度受group_concat_max_len控制,默认 1024 字节,超了会被静默截断——截断后你取到的可能还是对的(因为取第一个),但如果字段本身很长或者组内行数极多,字符串构建的内存开销很可观。第二,如果字段值里本身含有逗号,SUBSTRING_INDEX会切错,得换一个绝对不会出现在数据里的分隔符。第三,只适合取一两个字段,要取一整行十几个字段就很别扭了。
6.2 用变量模拟窗口函数(仅限 5.7 及更早)
在 MySQL 8.0 之前,窗口函数不可用,社区常用用户变量模拟:
SET @prev_user := NULL; SET @rn := 0; SELECT user_id, order_no, amount, created_at FROM ( SELECT user_id, order_no, amount, created_at, @rn := IF(@prev_user = user_id, @rn + 1, 1) AS rn, @prev_user := user_id AS dummy FROM orders ORDER BY user_id, amount DESC, created_at DESC ) t WHERE rn = 1;这套写法的核心是依赖ORDER BY的执行顺序,配合IF判断当前行是否换组,换组就把序号重置为 1。必须把赋值放在子查询里、排序放在子查询里,外层再过滤rn,顺序错了结果就错。
我得说清楚:这种写法在 MySQL 8.0 之后官方明确不保证行为,优化器可能打乱赋值顺序,导致结果不可靠。它属于"能不用就不用"的方案。如果现在还在用,请优先推动升级到 8.0.1 以上,或者改用第 4 节的关联子查询方案,稳定性高得多。
6.3 三种解法横向对比
| 方案 | 版本要求 | 整行对齐 | 并列处理 | 性能特征 | 推荐度 |
|---|---|---|---|---|---|
| 关联子查询 | 全版本 | 是 | 全部返回 | 需索引,否则每行重算 | 中小数据量推荐 |
| JOIN 派生表 | 全版本 | 是 | 全部返回 | 分组算一次,8.0 前可能物化 | 大批量推荐 |
| 窗口函数 | 8.0.1+ | 是 | 可控 | 单次扫描,效率高 | 首选 |
| GROUP_CONCAT | 全版本 | 部分 | 需配合排序 | 字符串开销,有长度上限 | 特殊场景备选 |
| 用户变量 | 5.7 及以下 | 是 | 可控 | 依赖执行顺序,不稳定 | 不推荐 |
选型时我的判断顺序是:先看版本,8.0 直接用窗口函数;5.7 看数据量,小表用关联子查询,大表用 JOIN 派生表;只有确实需要"组内某字段拼一串"的需求才用GROUP_CONCAT。用户变量那条路,除非被迫维护历史代码,否则我不会主动写。
7. 性能实测与索引配套,别让正确写法跑成慢查询
7.1 索引没配对的代价
上面所有方案要跑得快,都绕不开一个索引:(user_id, amount)。这个组合索引让 MySQL 在按 user_id 分组或分区时能走索引有序扫描,同时MAX(amount)可以直接从索引末端拿,不用回表。
我做过对比测试,在一张约 300 万行的订单表上执行 5.1 节的窗口函数方案:
| 索引情况 | 执行耗时(约) | 扫描方式 |
|---|---|---|
无(user_id, amount)索引 | 6.2 秒 | 全表扫 + 文件排序 |
有(user_id, amount) | 0.9 秒 | 索引扫描 + 少量回表 |
有(user_id, amount, created_at) | 0.7 秒 | 覆盖排序键,减少回表 |
差距接近一个数量级。原因不难理解:窗口函数按PARTITION BY user_id ORDER BY amount DESC需要数据按键有序,如果没有匹配索引,MySQL 得先把整个结果集排序(filesort),这个排序在几百万行时非常贵。有了索引,数据天然有序,窗口函数可以边流式处理边编号,省掉了大排序。
7.2 用 EXPLAIN 确认执行计划
改完 SQL 别急着上线,先跑 EXPLAIN。窗口函数版本要看几个信号:
type最好是index或range,出现ALL就是全表扫。Extra里出现Using filesort往往意味着排序没走索引,是大数据量下的性能红灯。rows评估值如果接近全表行数,说明过滤没生效。
对于窗口函数,MySQL 8.0 的 EXPLAIN 在 8.0.18 之后还能用EXPLAIN ANALYZE看到实际执行行数和耗时,比单纯的估算 rows 更准。我在调优时习惯先EXPLAIN看计划,再EXPLAIN ANALYZE看真实成本,两者对照着改索引。
提示:加索引前想清楚写入代价。
(user_id, amount, created_at)这种三列索引会拖慢订单写入,如果订单表是高频写入场景,请评估查询频率与写入频率的平衡,别为了一个报表查询拖垮写入链路。
7.3 分区与数据量再大一级怎么办
当单表到了几千万行,索引也开始吃紧,这时候可以考虑按时间分区,把查询限定在近期分区内。但要注意分区键的选择——如果分区键是created_at而查询条件是user_id,分区裁剪就用不上,得扫描所有分区,反而更慢。
我一般的做法是:报表类查询单独建一张汇总表,用定时任务把"每个用户的最新订单"物化进去,查询时直接查汇总表。这类需求对实时性要求通常不高,离线算好再读,比每次现场聚合要稳得多。报表查询和在线业务查询混在同一张热表上跑,本身就是隐患。
8. 排查实录:几个真实踩过的坑和速查表
8.1 坑一:开发环境报错,生产环境静默
现象:本地跑 SQL 报ONLY_FULL_GROUP_BY错误,生产同样的语句正常返回。排查第一步是查两边的sql_mode:
SELECT @@sql_mode;大概率生产的sql_mode里没有ONLY_FULL_GROUP_BY。这时候千万不要为了"让本地不报错"去关本地或生产的这个模式。正确做法是把 SQL 改成合法写法,让两个环境行为一致。顺手把生产sql_mode恢复成官方默认,避免更多人踩坑。
8.2 坑二:加了 GROUP BY 反而让索引失效
现象:本来走索引的查询,为了"分组取最大"加上GROUP BY后变成全表扫。原因是GROUP BY会触发隐式排序,如果分组列和排序列没有匹配索引,优化器就会放弃索引扫描改走临时表加排序。
排查思路是看 EXPLAIN 的Extra里有没有Using temporary; Using filesort。有的话,检查是否有以分组列为前缀的索引。如果没有,考虑加索引;如果加了还是慢,评估用窗口函数重写。
8.3 坑三:窗口函数结果不稳定
现象:同一个查询跑两次,每个用户的最新订单在不同调用之间偶尔变化。常见原因是ORDER BY里只有amount DESC,而存在金额并列的行,此时ROW_NUMBER()的编号在并列行间是不确定的。
解决办法是给ORDER BY补一个能唯一确定顺序的字段,通常是主键:ORDER BY amount DESC, created_at DESC, id DESC。加上主键之后,排序完全确定,结果每次一致。这是窗口函数场景里最容易被忽略的稳定性问题。
8.4 常见问题速查表
| 现象 | 可能原因 | 排查动作 | 解决方向 |
|---|---|---|---|
报not in GROUP BY clause | ONLY_FULL_GROUP_BY开启 | 查@@sql_mode | 改用合法写法,别关模式 |
| 结果里字段对不上 | 非聚合列取自随机行 | 检查 SELECT 列表 | 换关联子查询或窗口函数 |
| 查询突然变慢 | 分组列无索引,触发 filesort | EXPLAIN看 Extra | 补索引或用窗口函数 |
| 结果每次不一样 | 排序键不唯一 | 检查 ORDER BY 字段 | 补主键做最终排序键 |
| 返回值被截断 | GROUP_CONCAT超长 | 查group_concat_max_len | 调大长度或换方案 |
| 少量数据正常,线上飘 | 随机取值恰好命中 | 看是否依赖插入顺序 | 一律改成确定写法 |
8.5 一个排查小技巧
怀疑某条 SQL 存在非聚合列取值问题时,可以临时把它拆成两步验证:先跑一遍GROUP BY只输出分组键和聚合值,确认聚合值没问题;再对其中一个分组单独跑不带GROUP BY的明细,手工对比字段。如果明细里的对应字段跟分组结果不一致,坑就坐实了。
这个手工对比听起来笨,但在定位"报表数字对不上"这类问题时特别有效。自动化工具不一定能发现语义错误,因为 SQL 本身不报错。人工抽样核对几个分组,往往五分钟就能确认问题。
9. 我个人的几条实操原则
写到最后,分享几条这些年攒下来的判断习惯,都是踩坑踩出来的。
第一条,看到GROUP BY里的非聚合列,第一反应就问自己"这一行到底代表哪条记录"。如果答不上来,这段 SQL 就是错的。GROUP BY输出的每一行代表一个分组,不是代表一条原始记录;除非分组键是唯一键,否则非聚合列没有唯一确定的值。
第二条,需要"整行"就去用窗口函数或 JOIN,别指望聚合函数顺带把整行带出来。MAX只对单列负责,它没有义务也没能力保证同行其他列的来源。想清楚你要的是"最大值的那个数"还是"最大值所在的那条记录",这两个需求写法完全不同。
第三条,能让数据库报错就让它报错。ONLY_FULL_GROUP_BY是个好东西,它把不确定的语义提前暴露出来。任何"把模式关掉就没事了"的处理方式,本质上都是把风险推到未来。我宁愿本地报错一次,也不愿意线上对账一次。
第四条,排序键一定要能唯一确定顺序。不管是窗口函数的ORDER BY,还是普通查询里的ORDER BY ... LIMIT,只要出现并列值,结果就不稳定。补主键做兜底是最省事的做法。
最后再给一个实用建议:如果你们团队还在 MySQL 5.7 上挣扎,可以把窗口函数和 CTE 作为升级 8.0 的一个具体理由提出来。这类"分组取最新一条"的报表查询几乎每个业务系统都有,8.0 能让这些 SQL 从"绕来绕去"变成"一眼看懂",代码维护成本下降得很明显。我在推动升级时,就是用一条真实报表 SQL 的前后对比说服了团队的——同样的结果,一个三十行子查询嵌套,一个八行 CTE 加窗口函数,差距摆在眼前,没什么可争的。