ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL报错ERROR 1055?一文搞懂only_full_group_by原理与三种解法

MySQL报错ERROR 1055?一文搞懂only_full_group_by原理与三种解法 中午刚帮同事排查了一个线上问题业务方发来一张截图核心报错就这一句ERROR 1055 (42000): Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column ... which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_modeonly_full_group_by一看就是sql_modeonly_full_group_by这个老熟人。同事说代码在本地跑得好好的一到测试环境就炸。我说你先别急这个报错不是偶发也不是数据问题而是 MySQL 5.7 及以上版本把 SQL 模式的默认值改了你的 SQL 写法触碰了严格模式的红线。这篇文章就把这个报错彻底讲透它为什么出现、底层逻辑是什么、有哪些解决办法、每种办法的适用场景和坑在哪里。不管你是在本地开发、部署测试还是在生产环境救火跟着我后面这套思路走基本都能在十分钟内定位并解决。1. 初见报错它到底在抱怨什么1.1 一个真实报错现场先还原一下报错现场。假设我们有一张订单表order_info结构大概是这样的CREATE TABLE order_info ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, city VARCHAR(32), amount DECIMAL(10,2), created_at DATETIME );业务要查“每个城市的总成交额和最近一次下单用户”于是很自然地写出了这样的 SQLSELECT city, user_id, SUM(amount) FROM order_info GROUP BY city;这条 SQL 在 MySQL 5.6 及更早版本里能跑但在 5.7 及以上版本里就会报出和标题一模一样的错误。原因在于SELECT列表里出现了user_id它既没有被SUM()这样的聚合函数包裹也不在GROUP BY子句里。这句话翻译成人话就是分组后每一组可能有多行数据MySQL 不知道该把这一组里的哪个user_id返回给你。与其给你一个不确定的结果不如直接报错逼你写清楚逻辑。1.2 only_full_group_by 到底是什么sql_mode是 MySQL 的一个运行时配置它控制 MySQL 在特定场景下的行为规则。only_full_group_by就是其中一个规则而且它的位置很特殊——在 MySQL 5.7 及以上版本中它默认就是开启的。你可以把sql_mode理解成 MySQL 的“交规”only_full_group_by是其中一条非常严格的条例如果你使用了GROUP BY做分组那么SELECT里出现的每一列要么被聚合函数SUM、MAX、MIN、AVG、COUNT包住要么必须在GROUP BY后面老老实实列出来。这里有个容易忽略的细节这条规则完全站在 SQL 标准的角度来约束你。它不关心你有多少年的经验也不关心你“觉得”这个查询结果应该是什么样的。它只认标准——分组查询的输出必须是确定的不能有模棱两可的列。我见过很多同学第一次遇到这个报错第一反应是“MySQL 出 bug 了”。其实 MySQL 没错是你的 SQL 写法不符合标准。只不过在 5.6 时代 MySQL 睁一只眼闭一只眼到了 5.7 就把这条标准严格执行起来了。2. 为什么 MySQL 要设置这个“不讲理”的模式2.1 一个生活化例子我用一个特别简单的例子来解释这个模式的必要性。假设你是一个班主任手头有全班学生的各科成绩单。现在你要求“按性别分组”然后打印出每组的信息。如果表格上写着“男生组”后面该填什么性别填“男”没问题这是确定的。但如果你想在男生组后面再填一个“学生姓名”——这群里面有张三、李四、王五填哪个这个场景放到 SQL 里就是所谓的“函数依赖于分组列”问题。性别这个字段是因为分组产生的信息是确定的。但姓名跟性别没有这种依赖关系它在一组里可能有多个值MySQL 没法确定该给你哪一个。only_full_group_by就是为了杜绝这种“不确定查询结果”而存在的。它逼着开发者明确表达意图要么你用聚合函数告诉 MySQL“这一组里我只要某一个人”要么你把这个字段加到GROUP BY里让它变成分组依据的一部分。2.2 5.7 版本把默认值改了老代码全炸了这个报错在近些年集中爆发根本原因是 MySQL 5.7 版本调整了sql_mode的默认值。5.6 及更早版本默认sql_mode是空的或只有少量宽松规则only_full_group_by默认不开启。从 5.7 开始官方把ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION设为了默认值。这就导致一个现象很多在 5.6 上跑了好几年的老系统一旦做数据库版本升级或者环境迁移到了 5.7 之后原本“看起来正常”的 SQL 开始集体报错。MySQL 8.0 延续了同样的默认配置所以这个坑在 8.0 上一样存在。顺带提醒一句网上搜索这个报错时经常能看到“把 sql_mode 里的 only_full_group_by 删掉就好了”的答案。这个办法确实有效但不是所有场景都适合直接改配置尤其是生产环境。改配置和改 SQL 的取舍我放在下一节细说。3. 三种解决思路一次说透3.1 方案一修改 SQL 写法推荐遇到这个报错我最推荐先改 SQL而不是改配置。因为这是从根上解决问题还能保证查询结果完全符合你的预期。针对前面那个订单表场景有两种改法。第一种用ANY_VALUE()明确告诉 MySQL 随便取一个值SELECT city, ANY_VALUE(user_id) AS user_id, SUM(amount) AS total_amount FROM order_info GROUP BY city;ANY_VALUE()是 MySQL 5.7 专门为这种场景提供的函数。它明确表达开发者的意图我知道这一组里有多个user_id我随便要其中一个就行。这样既绕过了only_full_group_by的检查又能让查询跑出一个确定的结果。第二种用子查询或者派生表把聚合逻辑和明细逻辑分开假设业务真正想要的是“每个城市成交额最高的那个订单”那 SQL 应该写成这样SELECT o.city, o.user_id, o.amount FROM order_info o JOIN ( SELECT city, MAX(amount) AS max_amount FROM order_info GROUP BY city ) t ON o.city t.city AND o.amount t.max_amount;这种写法的好处是结果完全符合业务语义——要的是每个城市里金额最大的订单而不是“随机取一行”。如果你直接用ANY_VALUE()解决虽然不报错了但取到的用户可能是随机的跟业务预期可能就不符了。所以在改 SQL 前先问自己一句这一列我到底想要哪个值是最大值、最小值、随机一个还是某个业务上有意义的行想清楚之后再动手改代码才不会留坑。3.2 方案二修改全局 sql_mode稳妥兜底如果项目里有大量历史 SQL而且很难逐一审查改动那么修改sql_mode配置是更现实的方案。有两种改法动态修改重启失效和持久化修改写入配置文件。动态修改直接在当前运行的 MySQL 实例里执行-- 查看当前 sql_mode SELECT global.sql_mode; SELECT session.sql_mode; -- 去掉 only_full_group_by保留其他严格模式 SET GLOBAL sql_mode STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION;注意SET GLOBAL只对之后新建的连接生效已经存在的连接不会受影响。如果你只想让当前会话生效用SET SESSION。持久化修改需要修改配置文件my.cnfLinux或my.iniWindows在[mysqld]段下加一行[mysqld] sql_modeSTRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION然后重启 MySQL 服务。这样配置就固定下来了不会因为重启而丢失。3.3 方案三临时会话模式与 Docker 下的注意点有时候只是临时跑个脚本、导个数据不想动全局配置也不方便重启服务。那直接用SET SESSION把当前连接的模式改掉就行SET SESSION sql_mode STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION;这个命令只对当前会话生效断开连接就失效非常安全适合临时救火。另外现在很多同学用 Docker 部署 MySQL改配置文件的方式略有不同。假设你的容器名是mysql57可以这样操作# 进入容器 docker exec -it mysql57 bash # 找到配置文件 cat /etc/mysql/my.cnf或者更推荐的做法在宿主机上挂载配置文件。启动容器时加上参数docker run -d \ --name mysql57 \ -v /my/custom/my.cnf:/etc/mysql/mysql.conf.d/my.cnf \ -e MYSQL_ROOT_PASSWORDyourpass \ mysql:5.7这样改宿主机上的my.cnf后重启容器就能生效。注意 Docker 镜像里的 MySQL 可能对配置文件目录有自己的一套约定不同镜像官方镜像、云厂商镜像之间会有差异建议进容器先确认实际的配置包含路径。4. 实操记录从报错到跑通4.1 定位当前状态我先查什么接到同事的报错后我没有急着改任何东西先做两步确认版本确认当前sql_mode。mysql -u root -p进入后执行SELECT VERSION(); SELECT global.sql_mode; SELECT session.sql_mode;在我这次排查的例子中版本是5.7.44sql_mode显示为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赫然在列。这里有个实操经验查sql_mode的时候global和session两个值都要看。有时候全局改了但某个连接是通过连接池创建的连接池初始化时把sql_mode改成了别的值或者连接是从旧配置继承的你光查global会被误导。4.2 修改 SQL 的具体推进过程我这次遇到的业务场景是日报表查询原始 SQL 大致长这样SELECT department_id, employee_name, COUNT(*) AS order_count, SUM(order_amount) AS total_amount FROM sales_order GROUP BY department_id;业务需求是“每个部门的总单量和总金额附带显示一个部门里任意员工的姓名作为示例”。这个语义其实不严谨——“一个部门里的员工”和“任意员工姓名示例”之间就没有明确的映射关系。但报表就想看个大概不需要精确到某个人。这种场景最适合用ANY_VALUE()SELECT department_id, ANY_VALUE(employee_name) AS sample_employee_name, COUNT(*) AS order_count, SUM(order_amount) AS total_amount FROM sales_order GROUP BY department_id;改完之后在测试库执行通过。再把 SQL 同步给业务方跑了一遍报表数据正常。这里有一个很重要的判断标准如果你的业务需求压根不关心这列具体取哪个值ANY_VALUE()是最高效的解法如果业务关心具体值就得改写 SQL 逻辑。后者我也遇到过比如查“每个城市最近一笔订单的收货人”这就不能用ANY_VALUE()糊弄了必须用窗口函数或者子查询SELECT city, recipient, order_time FROM ( SELECT city, recipient, order_time, ROW_NUMBER() OVER (PARTITION BY city ORDER BY order_time DESC) AS rn FROM order_info ) t WHERE rn 1;MySQL 8.0 直接支持窗口函数写起来很丝滑。如果还在用 5.7就用子查询 join 自己的方式实现逻辑一致。4.3 修改配置文件的完整流程再说另一个场景。去年有个老项目做迁移几十张报表 SQL 都涉及GROUP BY之后取非聚合列DBA 评估后决定从配置层面放行统一去掉only_full_group_by。当时处理过程是这样的首先备份原配置cp /etc/my.cnf /etc/my.cnf.bak_$(date %Y%m%d)然后编辑/etc/my.cnf在[mysqld]段下找到或新增sql_mode行[mysqld] sql_modeSTRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION注意是不是也别加引号。然后重启服务systemctl restart mysqld重启后再登录确认配置生效SELECT global.sql_mode;输出结果里已经没有ONLY_FULL_GROUP_BY符合预期。这里要特别强调一个坑改配置文件时如果sql_mode原本没写某些 MySQL 版本会直接采用默认值如果你写了则是完全覆盖式生效。所以你要把整串值写完整把需要的模式都列上而不是只写你想留下的几个模式。我见过有人只写了一个STRICT_TRANS_TABLES结果把其他默认模式全冲掉了导致NO_ENGINE_SUBSTITUTION等保护机制失效后面数据导入出了幺蛾子。5. 常见问题与排查实录5.1 改了配置为什么还是报错这是我被问得最多的问题“我明明执行了SET GLOBAL sql_mode...甚至重启了 MySQL为什么新连接还是报错”排查思路和结论分几种情况情况一你改的是session不是global。如果你执行的是不带GLOBAL关键字的SET sql_mode...它只对当前会话生效。整个项目用的是连接池新建的连接还是走全局配置。正确姿势是两条都改先SET GLOBAL确保后续连接正常再SET SESSION让当前连接立刻生效。情况二配置文件没生效。最常见的原因是改了/etc/my.cnf但 MySQL 实际启动时读的是另一个配置文件。用下面的命令直接查mysqld --verbose --help | grep -A 1 Default options或者mysqladmin variables | grep sql_mode确认服务实际读取的配置文件路径。有些发行版把 MySQL 配置拆成了多个文件/etc/my.cnf里如果有!includedir /etc/mysql/conf.d/你写的参数可能被其他文件覆盖。排查的时候把所有sql_mode相关的配置都搜一遍grep -r sql_mode /etc/mysql/ /etc/my.cnf情况三主从架构下只改了主库。如果业务在从库上做报表查询从库单独维护一份配置主库改了没用。5.2 改写 SQL 时的一些避坑经验如果你选择用修改 SQL 的方式解决这几个经验可以减少返工ANY_VALUE()返回的值没有确定性。同一组数据多次执行可能得到不同行写入报表或者做后续数据处理时要确认业务方接受这种不确定性。GROUP BY加列不是万金油。有些同学一看报错直接把SELECT里的列全部加到GROUP BY后面报错确实消失了但聚合结果完全变了——分组粒度变细了原本按城市聚合加上用户后变成按城市和用户双重聚合SUM 的值被拆碎了。这种错误特别隐蔽而且结果看起来“合理”业务方如果不够细心拿到错数据都不知道。解决这个问题的原则是先明确分组维度再想清楚非分组列怎么处理。分组维度是业务的自然口径不能为了通过校验而随意调整。5.3 生产环境的处理建议生产环境遇到这个报错我一般按这个优先级处理第一优先线上应急用SET GLOBAL sql_mode...临时去掉only_full_group_by让业务先恢复。同时把修复方案告诉开发团队。第二优先开发团队排查所有报错 SQL能改写的一律改写优先用ANY_VALUE()或改写聚合逻辑。改完后逐个验证结果和原先业务预期是否一致。第三优先如果真的存在大量历史 SQL 无法在短期内改造完再评估把配置变更写进配置文件让修改持久化生效。这里有个底线配置变更要经过 DBA 评审并且留下完整的变更记录。不要为了图省事绕过审批直接在生产库上SET GLOBAL万一后面有其他依赖默认模式的逻辑受影响排查起来会非常被动。修改配置不是一劳永逸的建议每次变更后在监控里留意两个指标慢查询数量和错误日志数量。如果变更后有一批 SQL 的执行计划被重排、查询性能波动需要及时回滚配置并重新评估方案。再分享一下个人经验在实际生产和开发中我一直坚持“能改 SQL 就别改配置”。sql_mode里的only_full_group_by是 MySQL 用来守护查询结果确定性的重要防线它是标准的一部分是帮我们写更规范 SQL 的助手不是敌人。把依赖它的 SQL 藏起来只会让问题沉淀为技术债等哪天代码重构或者版本升级的时候一并爆发。不管你现在是遇到这个报错急着解决还是刚好看到了提前预习希望这篇文章能让你彻底弄懂only_full_group_by的前因后果。操作上遇到任何和文中场景不完全一样的情况关键在于理解报错背后的那句潜台词MySQL 在提醒你你的查询结果在分组语义上是不确定的。想明白这一层所有解法都是顺理成章的事。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进