☰
MySQL 1055 报错:ONLY_FULL_GROUP_BY 与 SQL 重写
2026/10/11 20:41:09 网站建设 项目流程

这问题我第一次遇到,是在给某套统计SQL做环境迁移的时候。开发库里同一个GROUP BY查询跑得好好的,一搬到严格模式的测试库,立刻甩出一行ERROR 1055,指着我SELECT列表里一个"既没被聚合、也没写进GROUP BY"的字段。后来查了一圈才明白,真正的原因在sql_mode的ONLY_FULL_GROUP_BY参数。

如果你也写过这类查询:

SELECT user_id, order_no, SUM(amount) FROM t_order GROUP BY user_id;

那你完全可能踩进同一个坑。这篇博文不打算只告诉你"改个配置就行",而是把1055报错背后的 SQL 语义、函数依赖、版本差异和重写方案全部讲透,顺便把我当时排查的完整思路和长期取舍也写出来。适合正在做 MySQL 5.6 升 5.7/8.0 的人,也适合那些被报错搞懵、想彻底搞懂ONLY_FULL_GROUP_BY的开发者。

1. 报错现场复盘:为什么同一句SQL在开发库能跑、测试库炸了

1.1 完整报错长什么样

先看一眼典型的报错原文。假设有张订单表t_order,字段包含user_id、order_no、amount,执行:

SELECT user_id, order_no, SUM(amount) FROM t_order GROUP BY user_id;

在sql_mode包含ONLY_FULL_GROUP_BY的实例上,会得到:

ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'test.t_order.order_no' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by

这段报错分三层信息:

  • Expression #2:指SELECT列表里第 2 个表达式,也就是order_no
  • nonaggregated column:这个字段没有被聚合函数包裹,又不是分组列
  • incompatible with sql_mode=only_full_group_by:报错的直接原因,就是当前会话的 SQL 模式开了ONLY_FULL_GROUP_BY

很多人第一次看到Expression #2会懵,以为这是什么特殊语法。其实它就是"从左往右数,第几个 SELECT 项"的意思。调整 SQL 后这个数字会变,排查时先对上号,能少走不少弯路。

1.2 排查链路:四步定位根因

我复盘了一下当时的排查顺序,建议你也按这个链路走:

  1. 拿到报错先确认错误码:1055专门对应"分组查询里出现非聚合、非分组字段"
  2. 查看当前库的sql_mode:
SELECT @@GLOBAL.sql_mode, @@SESSION.sql_mode;

两条一起看,因为全局和会话可能不一致。如果看到一串模式里包含ONLY_FULL_GROUP_BY,那根因基本确定。

  1. 把出错 SQL 的SELECT列表和GROUP BY列表并排列出来,逐个字段对照。凡是"既不在聚合函数里、也不在 GROUP BY 里"的字段,就是候选问题字段。

  2. 再判断候选字段和分组列之间有没有"函数依赖"。如果不是,那它就是引发 1055 的元凶。

很多教程跳过了第 4 步,直接叫人改配置或加ANY_VALUE(),结果遇到"按主键分组却报错"的假象时,又解释不通。所以这块我会在下一节重点拆开讲。

1.3 一个很常见的业务现场

我当时那套 SQL 来自一个统计报表:按用户统计订单总额,同时想带出用户名。业务同事的习惯性写法是:

SELECT u.id, u.user_name, SUM(o.amount) FROM t_user u LEFT JOIN t_order o ON u.id = o.user_id GROUP BY u.id;

开发环境 5.6 一直没报过错,上线到 5.7 测试环境就炸了。这里有意思的点是:user_name明明"逻辑上被u.id唯一决定",理论上属于函数依赖,为什么还是报错?原因是 MySQL 的检测能力有限,跨表关联时并不会自动推导"主表主键决定主表字段"这个结论。最简单的修法是把u.user_name加进GROUP BY,或者干脆改成子查询聚合外层 JOIN——具体写法第四节里给。

2. 报错背后的分组语义:SQL标准、聚合函数与函数依赖

2.1 GROUP BY 到底在约束什么

很多人把GROUP BY理解成"把相同字段的行合并",这么想不能说错,但容易漏掉关键语义:分组之后,组内通常还有多行明细。此时SELECT列表里那些"既不是分组列、也没有被聚合"的字段,对应的是多个候选值,数据库到底该返回哪一行?

SQL 标准的回答是:不能返回,必须报错。因为一旦允许,同一个查询在不同时间、不同索引、不同数据分布下,可能返回不同的行。结果不可复现,这在实际业务里是非常危险的事。

MySQL 5.6 及更早版本恰恰是那个"不守规矩"的。默认sql_mode为空,遇到上面那种查询,会直接从分组里随机挑一行返回。很多老开发者觉得"MySQL 本来就该这么灵活",其实是拿正确性换了手感和惯性。

2.2 函数依赖:为什么按主键分组时不报错

先看这个例子:

SELECT id, order_no, amount, status, created_at FROM t_order GROUP BY id;

在开启ONLY_FULL_GROUP_BY的库上,这句不报错。原因就是报错信息里那句functionally dependent——功能依赖。

t_order.id是主键,主键唯一确定一行,所以order_no、amount这些字段都"功能依赖于"id。按主键分组时,每个组其实只有一行,取这行的任何字段都是明确的,不存在歧义。

这个特性非常有用。比如你想对全表按某个唯一键去重,然后带出整行明细,直接GROUP BY主键即可,不需要把所有字段塞进GROUP BY。但要清楚,MySQL 能识别的函数依赖有限,通常只在以下情况成立:

  • 分组列是主键
  • 分组列是非空的唯一索引列
  • 分组列包含了联合唯一索引的全部列,且这些列都非空(具体行为依赖版本,别过度依赖)

普通字段之间是不会被识别为函数依赖的。业务同事觉得"用户名肯定属于这个用户",逻辑上没错,但 MySQL 不会做这种跨表推导。

2.3 MySQL 为什么默认开严格模式

从 5.7.5 开始,ONLY_FULL_GROUP_BY被放进默认sql_mode。官方意图很明确:向 SQL 标准靠拢,把过去"隐式随机取行"的历史包袱甩掉。

这个决定在社区里争议很大。反对的人认为它破坏了大量存量 SQL;支持的人觉得它逼着大家把查询语义写清楚,避免线上数据悄悄出错。我的立场是支持,理由后面专门讲。这里先记住一个事实:5.7 和 8.0 的默认行为都一样,别指望升级到 8.0 能变宽松。

3. 四条解决思路:改配置、ANY_VALUE、重写SQL,还有一条"危险捷径"

3.1 方案A:从 sql_mode 里摘掉 ONLY_FULL_GROUP_BY

这是网上搜到最多的答案,操作也最简单。先看当前值,再替换掉ONLY_FULL_GROUP_BY:

SELECT @@sql_mode; SET GLOBAL sql_mode = (SELECT REPLACE(@@sql_mode, 'ONLY_FULL_GROUP_BY', '')); SET SESSION sql_mode = (SELECT REPLACE(@@sql_mode, 'ONLY_FULL_GROUP_BY', ''));

注意三点:

  • SET GLOBAL只影响新建立的连接,已存在的连接不会变,需要重连。
  • 如果只改了全局不写配置文件,MySQL 重启后又恢复原样。
  • 5.7 里改配置文件的路径一般是[mysqld]段下的sql_mode。8.0 可以用SET PERSIST持久化到磁盘。

这个方案最大的问题不在于操作,而在于代价:关掉ONLY_FULL_GROUP_BY后,老 SQL 虽然能跑,但它返回的明细字段又一次变成了"随机取一行"。你只是把报错藏起来了,数据质量问题还在,而且更难发现。

提示:如果是升级迁移过程中,临时关掉让存量系统先跑起来,可以接受。但一定要建一个整改清单,后续把问题 SQL 逐步修掉,再重新开启严格模式。把"关配置"当最终方案,等于给后面的人埋雷。

3.2 方案B:不确定取哪条时用 ANY_VALUE() 明确态度

ANY_VALUE()是 MySQL 5.7.5 引入的函数,作用很直接:告诉优化器,这个字段在分组里随便取一条就行,麻烦别报错。

SELECT user_id, ANY_VALUE(order_no), SUM(amount) FROM t_order GROUP BY user_id;

适用场景有两类:

  • 分组内该字段值都是一样的,比如你用user_id分组,同时想带出user_name,而user_name在同一个user_id下永远不会变。这时ANY_VALUE(user_name)是合理写法。
  • 业务上只需要"任意一条样例",不在乎具体是哪条。比如查看某个分组下的备注信息片段,只要一个参考值。

不适用的情况也很明显:分组内该字段各不相同,而你又拿它做报表、做对账、做下游判断,那ANY_VALUE等于把随机性正式写进了业务逻辑。等哪天数据对不上,排查会非常痛苦。

我自己的习惯是:能不用就不用。如果字段真的一样,把它加进GROUP BY也一样能把语义写清楚;如果不一样,说明查询设计本身需要重新想。

3.3 方案C:重写SQL保留完整语义

这是我最推荐的方向,核心思路是"明确表达你要什么"。按业务诉求分三种做法。

做法一:把这个字段加入 GROUP BY

SELECT user_id, order_no, SUM(amount) FROM t_order GROUP BY user_id, order_no;

语义立刻变了:从"每个用户一行"变成"每个用户的每个订单号一行"。行数会变多,但查询合法且结果确定。如果业务上确实需要按两个维度汇总,这就是正确写法。

做法二:用聚合函数包装字段

如果只是想要一个确定值,可以直接用MAX、MIN之类:

SELECT user_id, MAX(order_no), SUM(amount) FROM t_order GROUP BY user_id;

这里要小心:MAX(order_no)返回的是"最大的订单号",不代表它同时是某个业务意义上的"最新订单"。很多人用MAX(id)配合ORDER BY id DESC试图取最新记录,其实这个逻辑并不成立。分组查询永远只能直接表达"组内聚合值",要取"某个具体明细行",得用下面的做法三。

做法三:子查询聚合,外层 JOIN

比如"每个用户的总金额,以及这个用户的名字":

SELECT u.id, u.user_name, s.total_amount FROM t_user u JOIN ( SELECT user_id, SUM(amount) AS total_amount FROM t_order GROUP BY user_id ) s ON u.id = s.user_id;

把聚合逻辑放进子查询,外层只负责把明细字段关联回来。这个方法能保持原语义不变,代价是多一层嵌套,需要EXPLAIN确认聚合子查询能走索引。

3.4 方案D:临时会话修改,只建议在紧急恢复时用

有次线上数据核对,需要临时跑一条旧系统遗留的"不规范 SQL",拿来比对历史数据。我不想动全局配置,就只对当前会话做了修改:

SET SESSION sql_mode = (SELECT REPLACE(@@sql_mode, 'ONLY_FULL_GROUP_BY', ''));

这个操作只影响当前会话,断开重连后自动恢复。非常适合 DBA 临时排查、紧急数据导出。

但要注意连接池的问题:如果你在应用代码里执行这条语句,然后又把连接还回连接池,其他请求可能复用这个被改了模式的连接,行为就不可控了。所以这条命令只建议在独立的mysql客户端会话里用,别写进业务代码。

4. 几种高频业务场景的 SQL 改造前后对照

4.1 场景一:每个分组里取金额最大的那条记录

这个需求的经典写法在 5.6 里长这样:

SELECT user_id, order_no, amount FROM t_order GROUP BY user_id ORDER BY amount DESC;

旧版 MySQL 可能"碰巧"返回每个用户金额最大的那行,但这是未定义行为,不能依赖。严格模式下它直接报错。

严格模式下的正确写法是自连接:

SELECT a.user_id, a.order_no, a.amount FROM t_order a JOIN ( SELECT user_id, MAX(amount) AS max_amount FROM t_order GROUP BY user_id ) b ON a.user_id = b.user_id AND a.amount = b.max_amount;

这个写法的前提是amount在同一个用户里没有重复。如果有重复,同一个用户会返回多行,此时需要再加一层去重逻辑。

MySQL 8.0 起可以用窗口函数写得干净得多:

SELECT user_id, order_no, amount FROM ( SELECT user_id, order_no, amount, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY amount DESC) AS rn FROM t_order ) t WHERE rn = 1;

窗口函数本质上把"分组"和"取明细"分开了,不存在 1055 的争议点,升级到 8.0 后遇到这类需求建议优先用它。

4.2 场景二:联表统计用户维度报告

报错写法:

SELECT o.user_id, u.user_name, SUM(o.amount) FROM t_order o LEFT JOIN t_user u ON o.user_id = u.id GROUP BY o.user_id;

直接修就是把user_name加进GROUP BY:

SELECT o.user_id, u.user_name, SUM(o.amount) FROM t_order o LEFT JOIN t_user u ON o.user_id = u.id GROUP BY o.user_id, u.user_name;

这个写法语义上没问题,因为user_name确实由user_id决定。但有些 MySQL 版本对跨表函数依赖识别不稳定,加上GROUP BY里多写一列会更稳妥,代价是分组粒度变细,实际上不会增加行数,因为user_id到user_name是一对一的。

如果想保持严格的"按用户一行",还可以用子查询:

SELECT u.id, u.user_name, s.total_amount FROM t_user u LEFT JOIN ( SELECT user_id, SUM(amount) AS total_amount FROM t_order GROUP BY user_id ) s ON u.id = s.user_id;

这个写法不依赖 MySQL 的函数依赖检测,只要u.id是主键,user_name就一定安全。

4.3 场景三:HAVING 和 ORDER BY 也会被波及

很多人以为只有SELECT列表会被检查,其实HAVING和ORDER BY里的非聚合字段同样会被ONLY_FULL_GROUP_BY约束。

HAVING 报错示例:

SELECT user_id, SUM(amount) FROM t_order GROUP BY user_id HAVING order_no = 'o001';

改成聚合写法:

SELECT user_id, SUM(amount) FROM t_order GROUP BY user_id HAVING MAX(order_no) = 'o001';

这里要注意语义变化:MAX(order_no) = 'o001'表达的是"该用户存在至少一单订单号等于 o001"。如果你要表达"该用户的所有订单都是 o001",应该写成MAX(order_no) = 'o001' AND MIN(order_no) = 'o001'。

ORDER BY 同理:

SELECT user_id, SUM(amount) FROM t_order GROUP BY user_id ORDER BY order_no;

需要改成ORDER BY MAX(order_no)或干脆按聚合字段排。这个细节很容易被忽略,尤其是老系统里大量GROUP BY ... ORDER BY 3这种位置写法,改造时一定要逐个翻出来看。

5. 版本迁移的大坑:从 5.6 升 5.7 或 8.0 的老项目里有哪些炸弹

5.1 各版本默认 sql_mode 对比

先把差异摆出来:

MySQL 版本默认是否开启 ONLY_FULL_GROUP_BY遇到非聚合字段的行为相关能力
5.6 及更早否(默认空)任取一行,不报错无 ANY_VALUE,无窗口函数
5.7(5.7.5 起)是报 1055有 ANY_VALUE,无窗口函数
8.0是报 1055有 ANY_VALUE、窗口函数、SET PERSIST

看到这张表就明白,为什么很多老项目从 5.6 升 5.7 后会突然冒出一大批 1055 报错。不是业务逻辑变了,是数据库突然开始较真了。

8.0 相比 5.7 默认sql_mode还少了NO_AUTO_CREATE_USER等项,但ONLY_FULL_GROUP_BY和STRICT_TRANS_TABLES这两项对业务影响最大的一直都在,没有放松。

5.2 升级前如何自动扫描不兼容 SQL

升级前把存量 SQL 排查一遍,比升级后一个个报错慢慢修要省力得多。我的做法分三步:

  1. 在测试环境把sql_mode改成和生产目标版本一致,跑一遍核心业务用例。这是最直接的验金石。报错日志里凡是出现 1055 的 SQL,全部记下来。

  2. 对慢日志和审计日志做抽样分析,抓出含GROUP BY的查询,人工审核SELECT列表中是否有非聚合字段。没有现成工具的话,一行正则也能筛出大部分。

  3. 用EXPLAIN审查改造后的 SQL 是否还能用上索引,特别是子查询 JOIN 场景,避免为了合规而引入性能回退。

注意:不要让开发库和测试库的sql_mode长期不一致。如果开发还是 5.6 老配置,测试已经切了严格模式,开发者本地永远复现不了测试环境的 1055,只会互相甩锅。

5.3 改配置之后的持久化和连接池细节

如果你确实需要临时关闭严格模式,别只改会话,也别只改全局。只改全局不落盘,MySQL 一重启就回到默认。5.7 里要把sql_mode写进配置文件:

[mysqld] sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION

8.0 可以直接:

SET PERSIST sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';

它会写入mysqld-auto.cnf,重启后依然生效。

还有一层坑:连接池。我之前遇到过一个问题,全局模式改成严格后,应用侧没报错,但偶尔出现奇怪行为。查了半天发现是老连接没断开,还带着旧的会话sql_mode。改全局模式后,一定要让应用连接池重建连接,最稳妥的方式是滚动重启应用实例,而不是等连接自然过期。

6. 我最终选择保留严格模式:三点理由和一套上线自检清单

6.1 为什么我最终保留这个模式

经历过几次"旧写法在 5.6 上跑出神秘数据"的排查后,我彻底倒向了保留ONLY_FULL_GROUP_BY。

第一,确定性。同一条 SQL,在全公司任何环境跑结果都应该一致。关闭严格模式等于允许"看运气"的查询存在,这在数据仓库、报表、对账场景里是致命的。

第二,可维护性。规范的 GROUP BY 查询语义自解释。后来接手的人看到SELECT user_id, MAX(order_no)就知道这是取最大单号,而看到SELECT user_id, order_no配GROUP BY user_id只会觉得莫名其妙。

第三,整改成本是递减的。存量 SQL 第一次改造会痛,但改完后问题就清零了。如果图省事关掉模式,新 SQL 会继续以不规范的方式写出来,等下次大版本升级、换团队、换 DBA,同一个坑还要再踩一遍。

6.2 上线前的自检清单

如果你也决定保留严格模式,下面这套清单是我每次发布前的固定动作:

  • 测试库的sql_mode与生产完全一致,重点对比ONLY_FULL_GROUP_BY和STRICT_TRANS_TABLES
  • 所有含GROUP BY的新 SQL 过一遍审核,SELECT、HAVING、ORDER BY三处都不能出现裸非聚合字段
  • 子查询重写后用EXPLAIN确认聚合内层能走索引,外层 JOIN 不产生意外全表扫描
  • 涉及金额、计数聚合的查询,改造前后跑一遍数据比对,确认行数和汇总值一致
  • 连接池侧确认模式变更后所有连接都有重连机制,防止旧会话残留

清单不复杂,但每一条都能对应到实际踩过的坑。

6.3 最后分享一个排查习惯

我后来写任何GROUP BY查询时,都会先问自己一句:SELECT列表里除了分组列和聚合列,还有别的吗?如果有,那要么这列本来就被分组列唯一决定,要么就得把它变成聚合结果,或者搬到外层 JOIN 里。

这个习惯帮我避免了很多次"上线后才发现数据对不上"的状况。ONLY_FULL_GROUP_BY不是数据库在跟你作对,它只是把你以前稀里糊涂写出来的歧义摊开在阳光下。与其纠结怎么绕过它,不如把每条分组查询都写成能经得起追问的样子。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询