资讯详情

资讯详情

MySQL ERROR 1055深度解析:ONLY_FULL_GROUP_BY报错原因与五种解决方案

干后端这几年MySQL 报错里跟开发者打交道最频繁的我觉得 1055 得排前三。尤其是项目从 5.6 升到 5.7 之后原来跑得好好的统计查询突然一夜之间全炸了控制台刷出一串ERROR 1055 (42000)后面还挂着一长串Expression #2 of SELECT list is not in GROUP BY clause。第一次见到的人基本都会懵我 SQL 一个字没改怎么突然就不让跑了其实这个报错就是 MySQL 的ONLY_FULL_GROUP_BY模式在起作用。它不是什么洪水猛兽而是 MySQL 5.7 之后默认开启的一种 SQL 规范性检查专门盯着用了GROUP BY的查询防止你查出不明确的数据。说难听点就是 MySQL 在帮你避坑只不过它选了最不近人情的方式告诉你。这篇文章我就把这个报错掰开揉碎讲清楚包括底层逻辑、五种解决方案、以及我在真实项目里踩过的各种坑。不管你是刚学 SQL 的新手还是被线上报错搞得焦头烂额的在职开发这篇应该都能给到一些思路。1. 这个报错到底在说什么1.1 报错信息逐段拆解先看完整报错长什么样我用一个很常见的订单表来演示SELECT user_id, user_name, level, SUM(amount) FROM orders GROUP BY user_id;这条 SQL 在 MySQL 5.6 里能跑但到了 5.7 及以上版本大概率直接报ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column test_db.orders.user_name which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_modeonly_full_group_by这段信息看着长关键点其实就四个ERROR 1055是错误码(42000)是 SQLSTATE 类别编号后者可以不用管。Expression #2告诉你出问题的是 SELECT 列表里的第 2 个字段也就是user_name。这个编号是 1 开始的数到几就是第几个字段。not in GROUP BY clause说明这个字段没出现在 GROUP BY 里。contains nonaggregated column说明它不是聚合函数SUM、COUNT、MAX 这些包出来的结果是一个裸出来的原始字段。not functionally dependent on columns in GROUP BY clause这句最核心意思是数据库认为这个字段跟 GROUP BY 的字段之间没有稳定的一对一对应关系。把这些串起来理解就一句话你 SELECT 了一个既不在 GROUP BY 里、又不是聚合计算的字段MySQL 不知道这个字段该取哪一条记录的值所以拒绝执行。1.2 最容易触发这个错的几个场景根据我自己的实战经验1055 报错高发的场景非常典型第一统计报表类的查询。比如按用户分组统计消费总额同时还想带出用户名、用户等级、注册时间这些附属字段。这是最典型的翻车场景因为报表查询几乎必然要 SELECT 一些非聚合字段。第二分组字段和排序列不一致。比如按user_id分组但ORDER BY create_time那么create_time也得处理否则同样报错。第三视图或子查询里做了分组外层恰好引用了没被分组的列。这种隐蔽性更强因为报错可能指向外层查询的某个字段但问题根源在内层。第四DISTINCT 和 GROUP BY 混用。有些老代码为了去重把两个语法叠一起用这种 SQL 在 5.6 里没事在 5.7 里经常一起爆出来。记住一个规律只要 SELECT 列表里出现非聚合的普通字段并且这个字段跟 GROUP BY 字段之间没有主键级别的绑定关系1055 就会找上门。2. 为什么 MySQL 非要多管闲事2.1 SQL_MODE 是什么要理解 1055 的根源必须先明白sql_mode是什么。它是一组 MySQL 运行时的行为开关决定了服务器在哪些场景下严格报错、哪些场景下睁一只眼闭一只眼。你可以通过下面的 SQL 直接查看当前环境用的什么模式SELECT sql_mode;在我本机的 MySQL 8.0 环境里默认值长这样ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION重点看我加粗的第一个ONLY_FULL_GROUP_BY。这个开关从 MySQL 5.7 开始默认开启而 5.6 及更早的版本默认是关闭的。这就是为什么同样是GROUP BY user_id然后 SELECTuser_name5.6 能跑、5.7 直接报错。MySQL 官方文档说了ONLY_FULL_GROUP_BY的存在是为了保证查询结果符合 SQL 标准语义。GROUP BY user_id之后结果集里每个user_id只有一行那一行里的user_name应该是哪个如果分组内有多个不同的user_name取第一个取随机一个取最大的你根本说不清规则查出来的数据自然也就有不确定性。2.2 5.6 时代到底做了什么MySQL 5.6 以及更早的版本遇到这种情况的处理方式简单粗暴随便挑一行返回然后不管你。听起来好像很爽但实际使用中这就是个数据大坑。我在一家公司做数据报表的时候曾经踩过一次按商品类目分组统计销量顺手 SELECT 了一个商品名称当时 5.6 跑出来看起来没问题结果后面复盘发现部分类目下的商品名称跟实际对不上——因为它取的是分组内的任意一行而不是你心里想的那一行。这还不是最可怕的最可怕的是这种错误往往悄无声息不上线评审根本发现不了。你拿一份看似正确实则随机的报表去做商业决策风险有多大不用我说了吧。所以ONLY_FULL_GROUP_BY的开启本质是 MySQL 在帮你拦截这种无意识的不确定性逼着你在写 SQL 的时候就明确告诉数据库这个字段到底该怎么取。2.3 函数依赖报错里的一线生机报错信息里有一句which is not functionally dependent on columns in GROUP BY clause这里有一个反常识的好消息如果字段跟分组字段之间存在函数依赖Functional Dependency那么即使它不在 GROUP BY 里MySQL 也允许你 SELECT。函数依赖的意思是只要确定了分组字段的值另一个字段的值就是唯一确定的。最典型的场景就是主键。举个例子SELECT user_id, user_name, level, SUM(amount) FROM orders INNER JOIN users ON orders.user_id users.id GROUP BY users.id;如果users.id是主键那么user_name和level都因为users.id唯一而变成函数依赖的字段。这种情况下 MySQL 5.7 能自动识别出依赖关系上面的查询可以正常通过即使user_name和level没有出现在 GROUP BY 里。这一点非常实用很多人不知道。如果你 GROUP BY 的是主键附带字段是可以名正言顺地直接 SELECT 的。但注意MySQL 的函数依赖检测是有限的它只认主键、唯一键这类声明过的约束不会去猜测我觉得这两个字段应该有对应关系。所以如果分组字段不是主键/唯一键就把要显示的字段老老实实加进 GROUP BY或者用聚合函数包起来。3. 五种解决方案按推荐程度排序3.1 方案一把漏掉的列补进 GROUP BY最正规、最符合 SQL 标准的做法就是让 SELECT 列表里的非聚合字段全部出现在 GROUP BY 里SELECT user_id, user_name, level, SUM(amount) FROM orders INNER JOIN users ON orders.user_id users.id GROUP BY user_id, user_name, level;这样做的逻辑是既然你要按user_id分组又想把user_name和level一起展示出来那就把分组粒度放大到这三个字段的组合。只要这三个字段在同一个分组内始终保持一致查询结果就是确定的、可解释的。代价自然也有分组字段变多分组粒度变细SUM(amount)的聚合范围也被拆得更细了。原本一个user_id一行现在可能变成user_id user_name level多行。如果user_name或level在不同订单里内容不同比如用户改了昵称、等级变了那同一个用户就会拆成多行。这个方案只适合那些字段跟着分组键走、不会变化的场景一定要想清楚再下手。3.2 方案二用 ANY_VALUE() 明确随便取一个MySQL 5.7.5 开始提供了ANY_VALUE()函数语义就是告诉数据库我知道这个字段可能有多个值但我现在不关心取哪个你随便给我一个就行。SELECT user_id, ANY_VALUE(user_name) AS user_name, ANY_VALUE(level) AS level, SUM(amount) FROM orders INNER JOIN users ON orders.user_id users.id GROUP BY user_id;相比于把字段加进 GROUP BYANY_VALUE()的好处是不改变分组粒度聚合结果跟原来保持一致。它本质上是把 5.6 时代的随便取一行行为显式地写出来了——不一样的是这次是开发者自己决定承担不确定性而不是 MySQL 偷偷帮你做决定。使用这个函数要认清楚一个适用前提你确实不在乎这个字段取哪条记录的值。举例来说按用户分组统计订单总额附带一个用户昵称而你觉得昵称大不了取某一个订单里的值就够了那就用ANY_VALUE()。但如果你想展示的是用户最新的昵称或者下单时用户所在等级那这个函数就不合适了你应该去用关联子查询或者窗口函数。这中间的取舍一定要在业务层面想清楚。3.3 方案三用 MAX()/MIN() 等聚合函数包一层如果你不希望ANY_VALUE()那种随机性又不想改分组粒度那就在语义上做聚合对这个字段用MAX()、MIN()等聚合函数。比如按用户分组时要展示用户名可以取用户名里字典序最大的那个SELECT user_id, MAX(user_name) AS user_name, MAX(level) AS level, SUM(amount) FROM orders INNER JOIN users ON orders.user_id users.id GROUP BY user_id;注意MAX(user_name)在同一个用户昵称相同的情况下返回的就是那个值效果跟user_name没区别但如果昵称真的存在多个版本返回的是排序后的最后一个至少结果是可确定的。这种方案的适用范围比ANY_VALUE()广一些因为它返回的结果有明确规则。实际项目中如果你的字段值在同一分组内基本一致用MAX()或MIN()包一下是最省事的。另一个常见变体是GROUP_CONCAT()如果你想在分组内把多个值都列出来比如一个用户的所有标签拼起来它比子查询方便得多。3.4 方案四改 sql_mode兜底方案不建议长期用改 SQL 模式能让你回到 5.6 的宽松行为具体有两种改法。临时改只对当前会话生效SET SESSION sql_mode STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION;永久改写入配置文件。在 MySQL 的配置文件Linux 下通常是/etc/my.cnf或/etc/mysql/mysql.conf.d/mysqld.cnf里找到[mysqld]段落加入一行[mysqld] sql_modeSTRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION然后重启 MySQL 服务使配置生效。但我必须给出一个明确的建议这个方案只能用来临时止血千万不要当成长期的解决方案。原因有两点。第一1055 报错本身就是在提醒你 SQL 写得有歧义。你把报错关掉不等于歧义消失了只是把问题从报错变成了返回不确定的结果。这在同一个团队里特别危险你关掉了模式同事用 Navicat 连上去也查不出问题但线上跑出来的数据可能对错参半而且这种数据错误极难定位。第二团队里如果有新人接手他们默认会认为 MySQL 的宽松行为是正常的写出来的 SQL 在其他 5.7 环境里直接跑不通代码可移植性几乎为零。我见过不止一个项目为了省事关掉ONLY_FULL_GROUP_BY后来换机器、升级环境、新同事搭本地库全都在报错上卡壳最后还是要回来补 SQL省的事全得加倍还回去。如果真的是历史遗留项目、几千条 SQL 一时改不完我的建议是先全局关掉模式紧急恢复线上同时开一个专项任务慢慢梳理改造全部改完之后再把模式打开。注意排查的时候要按线上真实的 sql_mode 跑别只凭记忆猜。3.5 方案五用派生表或子查询封装某些场景下你需要的字段并不是分组字段本身而是分组内某一行的属性这时候用派生表Derived Table更合理。比如你要按用户分组统计消费总额同时展示用户最近一笔订单的下单时间SELECT o.user_id, u.user_name, t.total_amount, t.last_order_time FROM ( SELECT user_id, SUM(amount) AS total_amount, MAX(order_time) AS last_order_time FROM orders GROUP BY user_id ) t INNER JOIN users u ON t.user_id u.id LEFT JOIN orders o ON o.user_id t.user_id AND o.order_time t.last_order_time;这里内层先做聚合统计外层再通过 join 把需要的字段补回来。逻辑上非常清晰而且每个字段的取值规则都是确定的。缺点是 SQL 结构复杂了一些写的时候容易顺手把ON条件写错导致数据重复。我在实际项目里用这种方案解决了不少既要聚合、又要明细的矛盾场景只要你能接受多一层嵌套它可以说是最严谨的解法。还有一个类似的思路是窗口函数Window Function。MySQL 8.0 支持ROW_NUMBER()、FIRST_VALUE()这些配合子查询可以精准拿到分组内满足特定条件的记录。这个方案最灵活但要求数据库版本是 8.0 或更高而且对执行计划的复杂度有一定影响查询量大的时候要注意压测。4. 一次真实排障记录与踩坑复盘4.1 从报错到定位的完整过程去年我在一个电商后台项目里做过一次典型的 1055 排障过程很有代表性分享出来给大家做个参考。当时线上某个运营后台的订单统计接口突然开始报 500日志里就是 1055 错误。奇怪的是这个接口运行大半年了之前一直很稳定。为什么突然挂了排查的第一步我先确认数据库是不是切换过环境。查了一下才发现原来项目刚做了一次整库迁移从一台旧服务器迁到了新集群而新集群用的是 MySQL 5.7旧服务器是 5.6。代码一行没改但默认的sql_mode变了原来能跑的 SQL 在 5.7 的严格模式下面直接失效。定位到问题之后就简单了。我当时把那条接口执行的 SQL 原样拉出来看它长这样SELECT o.order_status, u.user_name, COUNT(*) AS order_count FROM orders o LEFT JOIN users u ON o.user_id u.id GROUP BY o.order_status ORDER BY o.order_status;一眼就能看出问题u.user_name既不在 GROUP BY 里也不是聚合函数的结果。按一个订单状态分组组内可能关联到多个用户取哪个用户名完全没依据。虽然这个 SQL 在 5.6 下跑得欢但它本质上是有缺陷的代码碰上了宽松的环境。4.2 我踩过的三个关键坑第一个坑是改了sql_mode但连接不生效。那次我先通过客户端执行了SET GLOBAL sql_mode ...结果应用还是继续报 1055。原因很简单应用连接池里的连接是之前建立的它们保存的还是旧的会话变量。SET GLOBAL只对新建立的连接生效。要么重启应用要么让连接池主动回收旧连接否则改了半天等于白改。第二个坑配置文件修改在了错误的段落下。自己在/etc/my.cnf里找了一圈把sql_mode加在了文件顶部结果重启服务之后模式没变。为什么因为 MySQL 只认[mysqld]段里的配置写在[client]或者[mysql]段里都不会对服务端生效。看起来是个小问题但在紧急排障那种状态下白折腾了二十多分钟。第三个坑也是最容易忽视的改了模式只是把报错藏起来了数据可能已经错了。那次我为了临时恢复线上把新集群的sql_mode关掉了。过了一天再检查某些统计口径发现部分数据跟旧服务器对不上。因为宽松模式下同样的GROUP BY查询可能随机取不同的行新旧环境查出来的值不一致。最后我们的处理方式不是改回模式而是把相关统计 SQL 全部重写成严格模式兼容的版本然后再把ONLY_FULL_GROUP_BY重新打开。经过这几次折腾我现在定了一条团队的硬性规矩所有涉及GROUP BY的查询上线前必须在一台开启ONLY_FULL_GROUP_BY的 MySQL 5.7 及以上环境里跑一遍。本地开发可以用 Docker 直接起一个docker run --name mysql-test -e MYSQL_ROOT_PASSWORDyourpassword -d mysql:5.7用这套环境跑过一遍没报错再上别的环境基本不会出幺蛾子。5. 常见问题速查与排查套路5.1 高频报错场景对照表报错场景根本原因推荐解法SELECT 里有个字段没在 GROUP BY 中非聚合字段裸奔补充 GROUP BY、ANY_VALUE() 或聚合函数包装聚合结果里有多个值取哪个不确定长尾字段与分组键无函数依赖子查询 窗口函数精确定位修改 sql_mode 后重启又恢复原状配置文件段落写错或没生效检查 my.cnf 的 [mysqld] 段并重启服务应用改了全局模式仍报 1055连接池旧连接保留了旧会话模式重启应用或回收连接池5.6 迁移到 5.7 / 8.0 后大量报表失败新版本默认开启 ONLY_FULL_GROUP_BY按本文 3.1~3.3 的方案逐步改写 SQL视图或存储过程里报 1055定义时的 SQL 就不符合严格模式重写视图定义注意 CREATE OR REPLACE 后重新编译DISTINCT 非聚合字段组合查询失败5.7 对 DISTINCT 和 GROUP BY 的检查更严格改用子查询或去重逻辑重写这张表基本上覆盖了我碰到过的绝大多数情况。有一条值得单列出来讲就是视图和存储过程。它们的问题最隐蔽创建视图的时候如果环境是宽松模式定义就能成功后来环境变严格了查询视图时才报 1055。这种报错往往让人觉得莫名其妙因为你查的是一个视图并非自己手写的 SQL。处理办法是拿到视图的定义语句重新梳理它的 SELECT 列表把非聚合字段处理一遍然后DROP VIEW再CREATE VIEW或者用CREATE OR REPLACE VIEW。5.2 高效排查四步走遇到 1055 报错我建议按照这个顺序排查能省不少时间第一步先把报错信息里的Expression #N看清楚N 对应 SELECT 列表的第几个字段。这一步能直接锁死目标字段避免在一大段 SQL 里大海捞针。第二步分析这个字段跟 GROUP BY 字段的关系。如果分组字段是主键或唯一键确认函数依赖是否成立不是的话就直接进入第三步。第三步根据业务语义选方案。字段值在同一分组内必然相同就补进 GROUP BY不关心取哪个就用ANY_VALUE()需要明确的取值规则就用聚合函数或子查询。第四步修改后在严格模式下执行一遍确认不报错再把 SQL 提交到代码评审。这一步不能跳过尤其注意别在自己的 5.6 本地库上测试那样测不出任何 1055 问题。5.3 关于新老版本的额外提醒MySQL 8.0 比 5.7 更严格的地方不止ONLY_FULL_GROUP_BY一个。8.0 默认还有STRICT_TRANS_TABLES以及一组关于整数显示宽度、时区处理的行为变化。我遇到过有人把 5.7 的标准 SQL 迁到 8.0 后又出现新报错的情况报错信息单独看没问题拼在一起才发现是老的sql_mode没带齐。如果你要跨大版本升级数据库记得先统一两边环境的sql_mode再做全量 SQL 回归测试别只盯着 1055 一个错误。还有一个容易被忽略的点MySQL 对函数依赖的检测是字面级的。就算user_name在业务逻辑上确实由user_id唯一决定只要表结构里没建唯一索引或主键MySQL 就不会认为它函数依赖成立。想在数据库层面把这个关系表达清楚最简单的做法是给users.id加上主键或唯一索引。反过来也提醒我们表设计阶段把主键和唯一键规划好能省掉后面大量改 SQL 的功夫。最后再说几句操作层面的经验我自己现在写聚合查询脑子里会默认过一遍这个清单SELECT 列表里出现的每一个非聚合字段我要么把它放进 GROUP BY要么包一层聚合函数要么明确它跟分组键存在函数依赖否则绝不提交。这个习惯帮我挡掉了至少十几次线上事故包括很多不太容易第一时间发现的看起来能跑的 SQL。如果你所在项目里大量 SQL 已经带病运行我的建议是别试图一次性全部改完那样风险太高。按接口或报表模块拆成小批次每一批改完都在严格模式下回归测试同时对比改前改后的聚合结果确认数据一致再继续下一批。整个过程不要开新的ONLY_FULL_GROUP_BY豁免一旦开了回归就失去了意义。另外记得排查手法上多用EXPLAIN看看执行计划。很多时候 1055 只是表象背后可能是索引设计不合理、关联顺序有问题导致的大查询。把 SQL 写规范了、执行计划看明白了你会发现这类报错的排查速度会快很多。报错不可怕可怕的是用关掉检查的方式来解决报错那才是给自己埋雷。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →