MySQL 1055 报错:ONLY_FULL_GROUP_BY 与 SQL 重写
这问题我第一次遇到是在给某套统计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_modeonly_full_group_by这段报错分三层信息Expression #2指SELECT列表里第 2 个表达式也就是order_nononaggregated column这个字段没有被聚合函数包裹又不是分组列incompatible with sql_modeonly_full_group_by报错的直接原因就是当前会话的 SQL 模式开了ONLY_FULL_GROUP_BY很多人第一次看到Expression #2会懵以为这是什么特殊语法。其实它就是从左往右数第几个 SELECT 项的意思。调整 SQL 后这个数字会变排查时先对上号能少走不少弯路。1.2 排查链路四步定位根因我复盘了一下当时的排查顺序建议你也按这个链路走拿到报错先确认错误码1055专门对应分组查询里出现非聚合、非分组字段查看当前库的sql_modeSELECT GLOBAL.sql_mode, SESSION.sql_mode;两条一起看因为全局和会话可能不一致。如果看到一串模式里包含ONLY_FULL_GROUP_BY那根因基本确定。把出错 SQL 的SELECT列表和GROUP BY列表并排列出来逐个字段对照。凡是既不在聚合函数里、也不在 GROUP BY 里的字段就是候选问题字段。再判断候选字段和分组列之间有没有函数依赖。如果不是那它就是引发 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_BYSELECT 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 BYSELECT 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 BYSELECT 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.75.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 排查一遍比升级后一个个报错慢慢修要省力得多。我的做法分三步在测试环境把sql_mode改成和生产目标版本一致跑一遍核心业务用例。这是最直接的验金石。报错日志里凡是出现 1055 的 SQL全部记下来。对慢日志和审计日志做抽样分析抓出含GROUP BY的查询人工审核SELECT列表中是否有非聚合字段。没有现成工具的话一行正则也能筛出大部分。用EXPLAIN审查改造后的 SQL 是否还能用上索引特别是子查询 JOIN 场景避免为了合规而引入性能回退。注意不要让开发库和测试库的sql_mode长期不一致。如果开发还是 5.6 老配置测试已经切了严格模式开发者本地永远复现不了测试环境的 1055只会互相甩锅。5.3 改配置之后的持久化和连接池细节如果你确实需要临时关闭严格模式别只改会话也别只改全局。只改全局不落盘MySQL 一重启就回到默认。5.7 里要把sql_mode写进配置文件[mysqld] sql_modeSTRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION8.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不是数据库在跟你作对它只是把你以前稀里糊涂写出来的歧义摊开在阳光下。与其纠结怎么绕过它不如把每条分组查询都写成能经得起追问的样子。