ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MySQL ONLY_FULL_GROUP_BY报错详解:从原理到排查与解决

MySQL ONLY_FULL_GROUP_BY报错详解:从原理到排查与解决 凌晨两点接到值班电话开发说线上 MySQL 突然开始报错this is incompatible with sql_modeonly_full_group_by。我一边开电脑一边脑子里已经快速过了一遍接下来的动作查当前实例的 sql_mode、定位报错 SQL、判断是改配置还是改语句。这个问题在 MySQL 5.7 之后非常常见尤其是从 5.6 升级上来的业务或者习惯把 GROUP BY 写得很随意的团队几乎都会撞上。这篇文章就把这个报错讲透从原理到解决思路再到具体的操作顺序一次说清楚。1. 先搞清楚这个报错到底是什么1.1 一条会炸的 SQL 长什么样先说结论这个报错不是你语句写错了而是数据库开启的ONLY_FULL_GROUP_BY模式不认你的写法。它要求SELECT后面出现的列要么出现在GROUP BY后面要么被包在聚合函数里比如MAX()、MIN()、SUM()、COUNT()。一个典型例子SELECT department_id, last_name, MAX(salary) FROM employee GROUP BY department_id;这条语句在 MySQL 5.6 默认配置下能跑但在 MySQL 5.7 及之后的版本里会直接报错因为last_name既不在GROUP BY里也不是聚合函数包住的列。完整报错信息一般长这样ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column employee.last_name which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_modeonly_full_group_by很多同学只看懂了最后一句this is incompatible with sql_modeonly_full_group_by就开始到处找“关掉这个模式的办法”。其实更合理的思路是先弄明白为什么 MySQL 要这么检查。1.2 为什么 5.6 时代没事5.7 之后才开始“发难”GROUP BY的本质是把多行数据压缩成一行。比如按部门分组一个部门里有几十个员工最后输出一行那这行的last_name到底取谁的严格从语义上讲MySQL 不知道也不该随便取。ONLY_FULL_GROUP_BY做的就是这件事它把这种“语义不明确”的 SQL 挡在门外要求开发者自己想清楚每一列怎么处理。MySQL 5.6 之前这个模式默认不开启所以上面那条 SQL 能执行。数据库只是从每组里随机选一条经验值展示每次执行可能拿到不同的行这就是“不确定查询”。业务数据量一大或者索引变了结果就可能变线上问题就是这样一点一点埋下的。5.7 起ONLY_FULL_GROUP_BY被放入默认的sql_mode中8.0、8.4 LTS 同样如此。也就是说这不是某一次升级碰巧搞出来的 Bug而是 MySQL 在向标准 SQL 靠拢。它宁可报错也不允许你糊里糊涂地拿一个不确定的结果。还有一点要注意云数据库厂商的默认配置可能不一样。有些云 RDS 为了兼容旧业务默认把ONLY_FULL_GROUP_BY去掉了。所以同一套代码本地 5.7 一跑就报错部署到 RDS 反而正常这也是很常见的现象。排查之前先确认当前实例到底开没开这个模式再谈后续。2. 解决方案怎么选四类思路逐一拆解2.1 改 SQL 写法从根上解决这是我最推荐的方式也是所有方案里唯一的“正解”。它的核心原则是让 SELECT 里的每一列都有明确的语义。针对最开始的报错 SQL有三种改法-- 方式1把 last_name 加进 GROUP BY SELECT department_id, last_name, MAX(salary) FROM employee GROUP BY department_id, last_name; -- 方式2如果不需要 last_name直接删掉 SELECT department_id, MAX(salary) FROM employee GROUP BY department_id; -- 方式3如果只是想要部门里某个员工的姓名用分组合并思路 SELECT department_id, MAX(last_name), MAX(salary) FROM employee GROUP BY department_id;方式3需要谨慎。MAX(last_name)取的是字符串排序最大的那个姓名跟MAX(salary)对应的员工不一定是同一个人。如果业务要的是“工资最高员工的姓名”这三种写法全都不对。正确的写法是子查询关联或者用窗口函数。以 MySQL 8.0 为例SELECT department_id, employee_name, salary FROM ( SELECT department_id, employee_name, salary, ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rn FROM employee ) t WHERE rn 1;如果是 MySQL 5.7没有窗口函数就用关联子查询SELECT e.department_id, e.employee_name, e.salary FROM employee e JOIN ( SELECT department_id, MAX(salary) AS max_salary FROM employee GROUP BY department_id ) t ON e.department_id t.department_id AND e.salary t.max_salary;注意这里如果同一部门存在相同最高工资会返回多行。业务上如果不接受还要再补一个唯一排序条件。我见过太多人为了省事直接加ANY_VALUE()或者干脆改全局配置结果 SQL 跑出来的数据是错的还不好排查。改 SQL 虽然前期成本高但每个字段的语义都是确定的以后无论换版本、换数据库、做迁移都不会再在这个问题上栽跟头。2.2 ANY_VALUE()代价最小的临时补丁ANY_VALUE()是 MySQL 5.7 给出的一个“绕过校验”的函数字面意思就是你随便给个值吧我不在乎。SELECT department_id, ANY_VALUE(last_name), MAX(salary) FROM employee GROUP BY department_id;这样写能过校验也能出结果。但你要清楚ANY_VALUE(last_name)返回的分组内哪个姓名是完全不可预测的。MySQL 会在分组内挑一个它认为最方便的值在数据分布均匀、索引不同时结果都可能不一样。所以这个函数我只建议用在两种场景分组后这个字段的值本来就确定一样。比如按订单号分组顺便取用户ID一个订单只属于一个用户那ANY_VALUE(user_id)没问题。临时救急线上已经挂了开发来不及改 SQL先让业务恢复事后马上排期改。从长期维护角度看ANY_VALUE()是给“技术债”打补丁不是还债。项目里如果大量出现这个函数要警惕——它不是聚合函数不保证任何确定性后续排查数据问题时很容易翻车。2.3 修改 sql_mode会话级和全局级怎么选修改sql_mode是网上铺天盖地的“标准答案”但我要先说一句能不动全局尽量别动全局。先看会话级只对当前连接有效SET SESSION sql_mode STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION;注意我上面这一长串是“去掉ONLY_FULL_GROUP_BY后的典型默认值”。千万不要直接写SET SESSION sql_mode 。把sql_mode设成空会连带关闭STRICT_TRANS_TABLES等保护项插入超长字符串会静默截断插入非法日期也不再报错等于把 MySQL 的“安全护栏”全拆了这是高风险操作。会话级改动的特点是不需要重启不影响其他连接当前连接马上生效。适合排查问题、验证假设或者给某个专门的运维账号用。再看全局级它影响之后新建的所有连接SET GLOBAL sql_mode STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION;注意两个细节已经存在的连接不会刷新必须重连才生效。很多同学改了 GLOBAL 之后用原来的 Navicat 窗口再执行一遍报错 SQL发现还是报错就以为没改成功其实只是没重连。SET GLOBAL在部分云数据库上会被禁止或者重启后被参数模板覆盖。云 RDS 需要在控制台的“参数组”里改sql_mode再提交生效。全局改动能快速止血但代价是它让整个实例的 SQL 校验降级了。所有不规范查询都会重新放行短期看是“恢复正常”长期看是“慢性病复发”。2.4 四类方案对比与落地优先级做一个简单对照方便你根据现场情况选方案方案改造成本数据风险生效速度推荐场景修改 SQL 语法高低慢需要开发改代码新功能上线、永久修复增加 ANY_VALUE()低中快改动一行少量语句快速绕过修改会话级 sql_mode低低最快无需重启排查问题、临时验证修改全局/配置文件 sql_mode低高中等需重启或重连存量系统短期过渡我的建议是处理顺序按上面表格从下往上来如果线上正在报错先用会话级或全局级把系统拉起来保证业务可用然后立即建立“问题 SQL 清单”排期让开发按标准写法修复最后等修复完再把sql_mode恢复成默认值。整个过程不能只做一半只关校验不修 SQL等于把雷留在生产环境里不知道哪一天被谁踩爆。3. 完整走一遍排查与处理流程3.1 第一步确认当前环境的 sql_mode不管线上发生了什么第一件事永远是确认现状。我习惯执行两条命令SELECT GLOBAL.sql_mode; SELECT SESSION.sql_mode;GLOBAL.sql_mode是实例级配置SESSION.sql_mode是当前连接继承下来的值。如果全局没有ONLY_FULL_GROUP_BY但会话里有那可能是某个中间件或 ORM 框架在建立连接时手动设置了会话级sql_mode这种情况改配置文件也没用。也可以用这条命令快速检查SHOW VARIABLES LIKE sql_mode;它默认展示会话级的值。排查时这两条都执行一下能帮你判断问题出在哪个层级。3.2 第二步把报错 SQL 完整捞出来很多时候你手里只有一句“MySQL 报错this is incompatible with sql_modeonly_full_group_by”但不知道是哪条 SQL 触发的。生产环境开general_log是很重的操作一般不建议直接搞。我常用的路径有这几条如果应用层有完整报错日志直接搜关键字ERROR 1055或only_full_group_by往往能拿到完整语句。如果日志里只打印了部分 SQL配合应用代码里的 MyBatis 或 ORM 日志去补全。数据库侧可以查performance_schema里最近执行过的语句比如SELECT DIGEST_TEXT, COUNT(*) AS cnt FROM performance_schema.events_statements_history_long WHERE DIGEST_TEXT LIKE %GROUP BY% GROUP BY DIGEST_TEXT ORDER BY cnt DESC LIMIT 20;但要注意events_statements_history_long默认记录条数很少生产库的实时负载又很高这条路径通常只能抓到最近一小段时间的语句。要覆盖更大范围建议在测试环境或低峰期开一段时间的general_log记录到表里SET GLOBAL general_log ON; SET GLOBAL log_output TABLE;等抓够了语句立刻执行SET GLOBAL general_log OFF。然后把mysql.general_log表里包含GROUP BY且和报错特征匹配的语句导出来按次数统计就能知道哪些 SQL 受影响最严重。不管用哪种方式最终都要落到一个“SQL 责任清单”上哪条 SQL 不规范、由哪个系统发出、当前负责人是谁、准备怎么改。没有这个清单后面的修复就是盲人摸象。3.3 第三步按业务影响选择方案并落地具体落地分两种情况。第一种问题影响面小只有一两个查询在报错。那就走正路直接修 SQL。开发在本地复现按第 2 节讲的方式改写测试通过之后正常发版。改的时候注意三点先确认GROUP BY字段有没有覆盖业务想表达的分组粒度SELECT里每个非聚合字段的取值语义是否确定如果需要“分组内取出最大/最小对应的整行”用窗口函数或关联子查询不要图省事用ANY_VALUE凑。第二种问题影响面大几十条 SQL 同时报错业务已经挂了。那就先止血在云数据库控制台或者直接用SET GLOBAL去掉ONLY_FULL_GROUP_BY等业务恢复后再排期修 SQL。止血过程中记录好改动时间、修改前后配置值并通知所有开发同学“当前全局校验已临时放宽请自查近期上线 SQL避免引入更多不规范写法”。同时建一个定时任务比如两周后检查问题 SQL 的修复进度全修完之后再把ONLY_FULL_GROUP_BY加回去。这里要特别强调临时放宽配置不是目的是手段。如果团队没有后续修复计划我建议宁可顶着报错也不要把全局配置长期放在弱校验状态。3.4 第四步验证结果与观察影响改完之后不能只看那条 SQL 不报错就完事。我会做这几件事重新执行刚才报错的 SQL确认不再报错且结果符合预期。如果是通过修改 SQL 修复的还要用EXPLAIN看一眼执行计划确认没有因为新加的字段顺序、排序规则导致索引失效。观察连接池行为。全局配置改了之后连接池里的旧连接不会自动刷新需要等连接超时重建或者主动重启应用。如果改了配置仍然报错优先怀疑这里。监控数据库变更。放宽ONLY_FULL_GROUP_BY之后原本被拦截的不规范 SQL 会重新被执行慢查询数可能短暂上升。留意慢查询日志和 CPU 使用率。关注主从一致性。如果启用了主从复制主库的sql_mode改了从库也要同步修改否则从库执行相同 SQL 时可能因为模式不一致产生同步中断。4. 改完还报错常见坑与排障清单4.1 配置已改却仍然报错的四个原因这是我在运维里最常听到的一句话“我明明把 only_full_group_by 去掉了怎么还报错”逐个排查这四个位置基本都能找到问题第一配置文件改了但 MySQL 没重启。sql_mode写在my.cnf的[mysqld]段绝大多数情况下需要重启才生效。我遇到过有人把配置文件改好了却因为systemctl restart mysqld出现权限或启动失败的问题服务其实没重启成功自然还是旧配置。用SHOW VARIABLES LIKE sql_mode一看就知道。第二连接没重连。这个问题前面提过改的是全局配置但当前连接是旧的。Navicat、DBeaver 里连着的查询窗口如果不重开会一直用旧会话配置。判断方法是执行SELECT SESSION.sql_mode看里面的ONLY_FULL_GROUP_BY是否还在。第三多个实例只改了一个。很多公司的一套业务连着多个 MySQL 实例读写分离的场景下报错可能来自从库或某个分片。你改了主库业务走的却是另一个节点。解决方法是登录所有相关实例批量执行查询确认每个节点的sql_mode。第四ORM 或中间件在连接初始化时覆盖了配置。比如 MyBatis 里配置了sql_mode或者在连接池初始化 SQL 中执行了SET SESSION sql_mode ...那你在数据库层怎么改都会被应用层覆盖回去。这种情况要改的是应用配置不是数据库配置。4.2 去掉 only_full_group_by 后查询结果会变乱吗这是业务方最关心的问题也是 DBA 最难解释清楚的问题。我的回答是结果不一定乱但不可预测。ONLY_FULL_GROUP_BY只负责“拦截语义不明确的 SQL”它不负责“决定取哪一行”。当你把模式关掉后MySQL 会退回到 5.6 时代的行为对于SELECT last_name ... GROUP BY department_id它会在每个部门分组里随意挑一条记录的last_name返回。注意“随意”不是“随机”它可能遵循某种内部顺序也可能因为数据插入、索引变更、执行计划变化而改变。所以如果你问“会不会出错”答案是在当前数据分布下可能不会出错但只要数据一变就可能翻车。比如分组内有两条记录一条是active状态一条是cancelled状态这次执行返回active下次执行可能返回cancelled业务逻辑如果因为看到cancelled而走了退款流程问题就大了。这是我反复强调“配置可以临时改SQL 必须修”的原因。数据库层放行不代表业务语义正确它是一种“技术性豁免”。4.3 函数依赖为什么 GROUP BY 主键时不炸MySQL 5.7 的ONLY_FULL_GROUP_BY并不是无脑拦截所有不满足“分组列 聚合列”的查询。它对主键分组有一个例外叫“函数依赖”。举个例子SELECT user_id, user_name, COUNT(*) FROM user GROUP BY user_id;如果user_id是主键user_name和其他字段都由user_id唯一确定那么即使这些字段没被聚合也没出现在GROUP BY里MySQL 也不会报错。因为主键唯一性保证了每个分组只有一行取值是确定的不存在歧义。这个设计很聪明让很多合理查询免于重写。但要注意两个坑GROUP BY联合主键的一部分不适用。比如主键是(order_id, item_id)你只GROUP BY order_id这时item_id无法确定SELECT里如果出现了其他非聚合字段照样报错。部分旧版本对函数依赖的判断存在边界场景比如通过DISTINCT、别名、函数表达式产生的列识别逻辑可能不同。遇到拿不准的情况别钻牛角尖直接把列加进GROUP BY最稳妥。4.4 版本差异5.7.44、8.0、8.4 LTS 默认值都是什么MySQL 5.7 和 8.0 的默认sql_mode都包含ONLY_FULL_GROUP_BY。8.4 LTS 也一样。具体默认配置是ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE, NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION有些同学会纠结网上流传的“5.7.44 之后官方怎么变成 5.7.43 了”其实这类版本号变化是官方发布节奏的细节和sql_mode行为没有任何关系。5.7 系列从 5.7.44 起不再有功能更新安全和行为层面的检查规则早就在 5.7 生命周期里稳定了。换句话说只要你的实例是 5.7无论小版本是 5.7.26 还是 5.7.44这个报错的处理方式完全一致。8.0 以后MySQL 还新增了窗口函数这让“分组内取特定行”的问题有了更优雅的解法。如果你的业务已经跑在 8.0 上遇到ONLY_FULL_GROUP_BY报错时优先考虑用ROW_NUMBER()这类窗口函数重写而不是回到 5.7 年代的子查询思路。5. 两个容易忽略的隐患场景5.1 5.6 升 5.7 的存量业务提前排雷很多公司现在还在跑 MySQL 5.6但 5.6 已经停止维护升级是大势所趋。5.6 默认不带ONLY_FULL_GROUP_BY所以存量系统里积压了大量不规范 SQL。升级到 5.7 或 8.0 的那天就是集中爆雷日。我建议升级前做三件事第一搭建一个 5.7 的测试实例把业务流量灰度切一部分过去或者用测试环境重放历史流量。观察错误日志里有没有ERROR 1055有就及时抓出来。第二低峰期在 5.6 老实例上开一段时间的general_log把带GROUP BY的 SQL 全部抓下来在 5.7 测试库上逐一执行看哪些会报错整理成改造清单。第三升级窗口内不要“裸奔”。如果开发团队来不及把所有 SQL 修完可以在升级时临时把ONLY_FULL_GROUP_BY去掉但必须同步建立改造任务明确时间表否则这个“临时”往往就变成永久的。升级数据库不是换个版本那么简单它是对过去技术债的一次总清算。早发现早改代价最小等到线上挂了再救火成本会高很多。5.2 GROUP BY 搭配 ORDER BY 或 HAVING 时的正确姿势ONLY_FULL_GROUP_BY不仅管SELECT列表也管HAVING和ORDER BY。这里有个非常典型的错误SELECT user_id, MAX(amount), create_time FROM orders GROUP BY user_id ORDER BY create_time DESC;这条 SQL 想表达“按用户分组按最近的订单时间排序”但create_time没在GROUP BY里也没被聚合会报错。常见的修复是SELECT user_id, MAX(amount), MAX(create_time) FROM orders GROUP BY user_id ORDER BY MAX(create_time) DESC;这样能通过校验但语义要小心CREATE_TIME 取的是“分组内最大的创建时间”即最近一单的时间MAX(amount) 取的是“分组内最大的金额”。这两个值来自不同订单如果业务想表达“用户最近一单的金额”这个写法是错的。正确做法是先找到每组最近一单再回原表取金额。MySQL 8.0 用窗口函数SELECT user_id, amount, create_time FROM ( SELECT user_id, amount, create_time, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY create_time DESC) AS rn FROM orders ) t WHERE rn 1;MySQL 5.7 用关联子查询SELECT o.user_id, o.amount, o.create_time FROM orders o JOIN ( SELECT user_id, MAX(create_time) AS max_time FROM orders GROUP BY user_id ) t ON o.user_id t.user_id AND o.create_time t.max_time;HAVING也一样。比如按用户分组后筛选状态为“已支付”的用户SELECT user_id, COUNT(*) FROM orders GROUP BY user_id HAVING status paid;这个写法会报错因为status不在分组里也不是聚合结果。正确做法是把筛选条件放到WHERE里或者改成HAVING SUM(status paid) 0这类聚合表达式。说到底ONLY_FULL_GROUP_BY逼着你想清楚一个核心问题每个输出的字段它代表的到底是一行里的一个值还是一个组里的一个聚合结果。想清楚了这个类似报错就都不存在了。我在实际处理这类问题的经验是先把问题面摸清楚再决定动配置还是动代码。线上已经崩了那就先做最小干预把服务拉起来但心里要清楚这只是缓兵之计。SQL 的规范化和语义澄清才是真正需要落地的改进。每次看到有人为了图省事永久删掉ONLY_FULL_GROUP_BY我都替他们捏把汗——数据库退回到 5.6 的宽松模式看起来是“解决了报错”实际上是把一批不确定查询重新放回了生产环境。最后再分享一个小技巧不管你动了sql_mode还是改了 SQL验证的时候一定要新开一个连接窗口再执行别用之前的旧连接很多“改完还报错”的案例死就死在没重连这一步上。
返回列表