在接手一套跑了三年的老系统时,我碰到了职业生涯里最典型的一次 MySQL 报错潮:明明在测试环境跑得好好的 SQL,一上生产就抛 ERROR 1055 (42000),字面意思是“this is incompatible with sql_mode=only_full_group_by”。当时第一反应是“哪个同事又把 sql_mode 给改了”,结果一查,生产库用的是 MySQL 5.7 默认配置,压根没人动过。问题出在老系统是 MySQL 5.6 时代写的,一堆 GROUP BY 语句钻了宽松模式的空子,到了 5.7 全暴露了。 这个 ONLY_FULL_GROUP_BY 就是 MySQL 在 5.7 版本开始默认开启的一种 SQL 模式,它的核心约束很直接:SELECT 出来的非聚合列,必须出现在 GROUP BY 子句里,否则 SQL 直接报错。刚接触时觉得这模式纯粹是找麻烦,但真实项目里被它救过几次之后,我只想说一句:这东西不是 Bug,是 MySQL 在替你的数据准确性把关。 这篇文章我不会讲太多纯理论,重点放在三类内容:ONLY_FULL_GROUP_BY 到底在卡什么、真实项目里怎么安全地和它共存、以及迁移老系统时怎么把报错 SQL 批量治理掉。无论你是刚接触 MySQL 的新手,还是正在为升级 5.7/8.0 头疼的维护者,这篇都能给你一套能直接抄的作业。 ## 1. ONLY_FULL_GROUP_BY 到底在卡什么 ### 1.1 一个 SQL 语句为什么会被拒 先看一个最简单的例子。有一张订单表: ```sql SELECT user_id, SUM(amount) FROM orders GROUP BY user_id;这个语句没问题,因为 SELECT 里的 user_id 出现在 GROUP BY 里,SUM(amount) 是聚合函数。
再看下面这条 SQL :
SELECT user_id, order_status, SUM(amount) FROM orders GROUP BY user_id;在 MySQL 5.6 及更早版本里,这条 SQL 能跑,返回的 order_status 是“该分组内的任意一条记录的 order_status”,具体是哪一条完全由存储引擎和查询执行计划决定,同一个 SQL 在不同数据量下可能返回不同结果。ONLY_FULL_GROUP_BY 开启后,这条 SQL 直接报错,原因是 order_status 既不在 GROUP BY 中,也没有被聚合函数包裹。
从数据库语义来看,GROUP BY 之后每个分组已经变成了一行,如果 SELECT 中出现了“分组内多行取值不一致的列”,数据库根本不知道该显示哪一行的值。MySQL 5.6 的宽松模式本质上是让数据库帮你“随便挑一个”值,这种不确定性在报表统计、对账场景里是致命的。
1.2 主键函数依赖:唯一的特例
ONLY_FULL_GROUP_BY 不是一刀切。如果一个列在 GROUP BY 中,那么以它为前缀的主键列,可以直接出现在 SELECT 里,这个叫函数依赖(functional dependency)。
举个复合主键的例子:
CREATE TABLE order_items ( order_id INT, product_id INT, quantity INT, PRIMARY KEY (order_id, product_id) ); SELECT order_id, product_id, SUM(quantity) FROM order_items GROUP BY order_id;按理说 product_id 不在 GROUP BY 里,应该报错。但因为 order_id 和 product_id 共同组成主键,只要确定了 order_id,对应的所有 product_id 都是唯一的,直接查询不会产生歧义。MySQL 在 5.7 版本引入的这个依赖检测机制,比很多开发者想象中要智能。
实际工作中,这一规则可以适当利用,但别过度依赖。因为它只对主键和唯一键生效,普通索引哪怕唯一性再好,也不会被识别。我曾经试图把唯一索引当作主键用,结果照样报错,查了 MySQL 官方文档才发现这条规则的边界。
1.3 默认开启背后的版本差异
MySQL 5.7 及以上版本,sql_mode 默认包含了 ONLY_FULL_GROUP_BY,这是官方为了让数据库行为更贴近 SQL 标准做的一个重大调整。MySQL 5.6 时代,默认的 sql_mode 是空字符串,也就是完全不限制 GROUP BY 行为。
这个差异直接影响升级路径。从 5.6 升到 5.7 的典型场景里,最常见的兼容性崩溃不是字符集、不是存储引擎,就是这种平时压根注意不到的 SQL 写法问题。2016 年前后一波升级潮中,网上满屏都是 ERROR 1055 的提问,可以说是 MySQL 历史上最大规模的一次 SQL 行为兼容性事故。
2. 接手老项目时的迁移踩坑实录
2.1 老系统批量翻车的过程
去年我接手一个维护了多年的订单统计系统,技术栈是 PHP + MySQL 5.6,库里有两百多张表,统计 SQL 大多是拼接出来的,其中不少 GROUP BY 语句极其奔放。项目要迁移到 MySQL 5.7,我提前在测试库开 ONLY_FULL_GROUP_BY 模拟,结果一跑,单日报错日志刷了三百多条。
典型的问题 SQL 分三类。第一类是 SELECT 了多余的非聚合列,比如按用户统计时顺手把用户的手机号也查出来;第二类是 GROUP BY 的列非常少,SELECT 却查了五六列;第三类是 GROUP BY 用了别名或表达式,但 SELECT 用了原始列名。每一条放到 5.6 里都不会吭声,放到 5.7 就是一顿报错。
2.2 迁移前做一次 SQL 健康检查
面对这种存量系统,我强烈建议先做一次 SQL 审核,而不是等到迁移之后被线上报错追着跑。实操思路是这样的:
先拿生产库的慢查询日志、通用日志或者从应用层收集最近一个月的 SQL 样本,放进开启了 ONLY_FULL_GROUP_BY 的测试库跑一遍。跑的时候不要只盯报错,还要看执行计划。很多 SQL 在 5.6 里能走索引,到了 5.7 加了 SQL_MODE 之后执行计划变了,这种隐藏问题比直接报错更坑。
具体操作可以建一个专门做兼容性测试的库,把生产库导一份最新的数据过去,表结构和索引不变,然后开启 ONLY_FULL_GROUP_BY,用脚本把收集到的 SQL 全部执行一遍。执行完收集报错 SQL,按应用模块分组,统计影响范围,再决定是改 SQL 还是调整配置。
2.3 临时兜底手段:修改 sql_mode
如果业务实在紧急,可以临时把 ONLY_FULL_GROUP_BY 从 sql_mode 中移除。针对当前会话:
SET SESSION sql_mode = (SELECT REPLACE(@@sql_mode, 'ONLY_FULL_GROUP_BY', ''));针对全局配置:
SET GLOBAL sql_mode = (SELECT REPLACE(@@sql_mode, 'ONLY_FULL_GROUP_BY', ''));这种方法我建议只在过渡期使用,千万别当成长期方案。关掉 ONLY_FULL_GROUP_BY 等于重新回到 5.6 的宽松模式,数据统计结果不可控的隐患会一直存在。而且很多云数据库产品默认配置里已经写死了 sql_mode,你改了全局配置,实例重启之后又被拉回去,治标不治本。
3. 正确治理姿势:改写 SQL 的六种方法
3.1 最省事的方案:该加的全加进 GROUP BY
把 SELECT 中非聚合列全部放进 GROUP BY,这是最符合 SQL 语义的做法,也是优先选用的方案。
下面这条有问题的 SQL :
SELECT user_id, user_name, SUM(amount) FROM orders JOIN users USING(user_id) GROUP BY user_id;改成:
SELECT user_id, user_name, SUM(amount) FROM orders JOIN users USING(user_id) GROUP BY user_id, user_name;注意一点,如果 user_name 和 user_id 在业务上是一对一的,加上去之后数据结果不会变化,只是多了一个分组维度,性能会受一点影响。如果 user_name 对 user_id 是多对多的,那这条 SQL 本身就有业务逻辑问题,不应该这么写。
3.2 语义明确的聚合:用 MAX、MIN 包一层
很多场景下,选出分组里的某个字段只是为了展示,并不关心是哪一条记录的值。比如订单按用户分组,想看用户最近一次的订单备注,那可以把备注用 MAX 包起来:
SELECT user_id, MAX(IF(order_time = (SELECT MAX(order_time) FROM orders o2 WHERE o2.user_id = orders.user_id), remark, NULL)) AS last_remark, SUM(amount) FROM orders GROUP BY user_id;这个写法虽然能过 ONLY_FULL_GROUP_BY,但性能很差,子查询会在每个分组里再扫一遍。如果对性能敏感,我一般建议子查询单独先聚合,再 JOIN 到主查询,效率能高一个量级。
3.3 有争议但实用的 ANY_VALUE
MySQL 5.7 提供了 ANY_VALUE 函数,可以绕过 ONLY_FULL_GROUP_BY 的检查,把不确定的取值直接忽略掉:
SELECT user_id, ANY_VALUE(user_name), SUM(amount) FROM orders JOIN users USING(user_id) GROUP BY user_id;ANY_VALUE 起作用的原理是:告诉 MySQL“这部分偏差我知道,你随便取个值就行”。它和 5.6 时代的“娶一个值”本质上是同一种行为,只是从隐式变成了显式。
我实际用下来,ANY_VALUE 适合那种“分组后字段值在业务上必然一致”的场景,比如通过 user_id 关联的 user_name,如果业务上保证 user_id 唯一对应一个 user_name,用 ANY_VALUE 是安全的。但如果字段值在分组内确实不一样,ANY_VALUE 返回的值到底是谁是没保证的,这时用了就是在埋雷。
3.4 先排序后聚合:五版本时代的替代写法
如果业务语义是“取分组内最新的一条记录的某字段”,正确写法是用子查询先排序后取分组。MySQL 5.7 之前没有窗口函数,可以这样写:
SELECT o1.user_id, o1.remark, o1.amount FROM orders o1 JOIN ( SELECT user_id, MAX(order_time) AS max_time FROM orders GROUP BY user_id ) o2 ON o1.user_id = o2.user_id AND o1.order_time = o2.max_time;这个写法在 MySQL 5.6/5.7/8.0 上都能跑,而且能吃上索引,性能稳定。唯一的坑是如果同一个 user_id 下有完全相同的 order_time,这个方法会查出重复行,需要在业务上做去重,或在 JOIN 条件里再加一个唯一字段。
3.5 窗口函数:8.0 时代的利器
如果已经升级到 MySQL 8.0,窗口函数是解决这类问题的最优解:
SELECT user_id, remark, amount FROM ( SELECT user_id, remark, amount, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time DESC) AS rn FROM orders ) t WHERE rn = 1;这个方案的优势是逻辑清晰、可读性强,而且只扫一次表,性能通常优于子查询 JOIN 的方式。我在一个三千多万行的订单表上对比过,窗口函数方案比 JOIN 方案快了差不多 20%,具体数值和索引设计有关,但趋势是明确的。
3.6 动 SQL 不如动表结构:冗余列与生成列
如果某个非聚合列经常和分组一起查询,且在分组内必然一致,可以考虑把该列冗余进事实表,然后用 GROUP BY 冗余列的方式规避检查。比如用户姓名,可以冗余到订单表:
ALTER TABLE orders ADD COLUMN user_name VARCHAR(50) NULL; UPDATE orders o JOIN users u ON o.user_id = u.user_id SET o.user_name = u.user_name;之后所有按用户的统计都可以直接 GROUP BY user_id, user_name,不仅满足 ONLY_FULL_GROUP_BY,还省掉一次 JOIN,查询性能会明显提升。MySQL 5.7 还支持生成列,可以配合索引做到自动维护,适合更多复杂的冗余场景。
4. 常见问题与排查技巧实录
4.1 典型报错速查表
| 报错场景 | 典型错误信息 | 解决方案 |
|---|---|---|
| 非聚合列没在 GROUP BY 中 | ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column | 把该列加入 GROUP BY,或用 ANY_VALUE 包裹 |
| GROUP BY 中使用了别名 | ERROR 1055: Expression #1 of SELECT list is not in GROUP BY clause | 在 GROUP BY 中使用原始列名,或 mysql 8.0 支持的别名写法 |
| 使用 DISTINCT + GROUP BY 混用 | ERROR 1055 或结果集异常 | 去掉 DISTINCT,用纯 GROUP BY 或纯 DISTINCT,不要混用 |
| 聚合字段外嵌套表达式 | ERROR 1055: Expression #1 of ORDER BY clause is not in GROUP BY clause | 确保 ORDER BY 的列要么聚合、要么在 GROUP BY 中 |
4.2 排查时最容易被忽略的 ORDER BY 坑
ONLY_FULL_GROUP_BY 不只会检查 SELECT 列表,还会检查 ORDER BY。因为 ORDER BY 也在查询结果集上投影,非聚合字段出现在 ORDER BY 里同样会被拒:
SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id ORDER BY user_name;这条 SQL 在 5.6 里能跑,在 5.7 里照样报错。哪怕 user_id 和 user_name 一一对应,MySQL 也不认这个账,必须把 user_name 加进 GROUP BY,或者用 ANY_VALUE(user_name) 包一层。
4.3 索引失效疑云:模式迁移后性能不升反降
ONLY_FULL_GROUP_BY 本身不直接导致索引失效,但它逼着你改 SQL。很多人图省事,把所有非聚合列全塞进 GROUP BY,结果明明之前能走索引的语句变成了 filesort。
我在实际项目中遇到过这样的案例:订单表 GROUP BY user_id 本来走 user_id 索引很顺利,为了凑 ONLY_FULL_GROUP_BY 加了一个 remark 字段进 GROUP BY,执行计划立刻变了,变成了 Using temporary + Using filesort。原因很简单,GROUP BY 的列越多,索引匹配难度越高,MySQL 只能建临时表来排序分组。
这种情况我会优先考虑:如果 remark 字段只是展示用的,用 ANY_VALUE 包一层;如果业务上要求严格取最新 remark,用 3.5 的窗口函数方案。两种方式都比盲目加列靠谱。
4.4 排查流程:直接定位问题 SQL 的三板斧
生产环境报错时,别一行一行翻日志。先执行:
SELECT @@sql_mode;确认 ONLY_FULL_GROUP_BY 是否真的打开。如果确认开启,接下来三步定位:
第一步,用 SHOW ENGINE INNODB STATUS 配合慢查询日志,把报错 SQL 完整捞出来;
第二步,把报错 SQL 里的表结构全部查一遍,重点看哪些列是主键、哪些是普通索引、哪些是冗余列;
第三步,用 EXPLAIN 查看执行计划,确认 ON 条件和 GROUP BY 列是否都能走索引。
这套流程我用了很多次,基本能在半小时内定位根因。真正的难点往往不在报错本身,而在于同一个 SQL 可能被多条业务链路复用,改了一个地方会影响另一个功能,改 SQL 之前一定要先全局搜索应用代码里的使用位置。
4.5 用漏网之鱼检查:定期巡检,避免二次爆发
很多业务迁移上线之后,应用发版还在持续产生新 SQL。如果开发环境用的是宽松模式,新 SQL 一旦带上不合规写法,上线测试又测不出来,等到生产才炸。我建议在测试环境直接全局开启 ONLY_FULL_GROUP_BY,让开发阶段就暴露问题。
再多说一个自动化小技巧:在测试数据库上周期执行一次审计查询,从 information_schema 中拉出所有含 GROUP BY 的视图定义,静态扫描一下视图里的 SELECT 列是否都包含在 GROUP BY 中。视图是重灾区,因为视图定义一旦固定,很多人根本没意识到它的 sql_mode 在起作用。
5. 写在最后:踩过这么多坑之后的真心话
ONLY_FULL_GROUP_BY 是我见过的最“反人性”但又最“负责任”的数据库特性。说它反人性,是因为它逼着你去改那些本来能跑的查询,尤其是存量系统,一开就是一个大工程;说它负责任,是因为它把所有不确定性直接挡在数据库外面,绝不让一条语义含糊的结果集蒙混过关。
我的态度很明确:既然 MySQL 从 5.7 开始默认开启这个模式,那就说明官方对 SQL 标准的态度是坚持的,后续 8.0 版本也一直在往这个方向强化。做新项目时一定要从一开始就默认开启 ONLY_FULL_GROUP_BY 作为开发约束,而不是关掉它来给自己省事。做老项目迁移时,也不要把“关模式”当第一方案,花点时间把 SQL 一条条改干净,后面会省十倍的心。
最后分享一个我自己的习惯:每次写完一条 GROUP BY 查询,我都会下意识地自查一遍——SELECT 里的非聚合列是不是都能被“分组内唯一确定”。这个习惯养成之后,ONLY_FULL_GROUP_BY 的报错几乎就再也没在你身上出现过。能写出这条模式的精髓,你才算真正理解了 MySQL 分组查询的语义边界。