
1. 这个报错真不是SQL写错了先看它出现的典型场景做后端开发的朋友十有八九在MySQL 5.7以上版本里碰见过这样一条报错ERROR 1055 (42000): Expression #3 of SELECT list is not in GROUP BY clause and contains nonaggregated column test.user_table.user_name which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_modeonly_full_group_by第一次看到这串英文我一度以为是自己SQL里的分组字段写漏了。反复检查了好几遍SELECT后面的字段确实都在GROUP BY里怎么还是报错后来才明白这个报错跟字段在不在GROUP BY里没有直接关系真正的问题是SQL模式里的ONLY_FULL_GROUP_BY选项在起作用。这个报错的典型场景一般有这几类分组查询里SELECT了既不在GROUP BY中、又没有用聚合函数包裹的普通字段。使用了DISTINCT搭配ORDER BY排序字段不在SELECT列表中。存储过程、视图或者定时任务里执行了带分组的复杂查询。从MySQL 5.6升级到5.7或8.0之后原本运行正常的旧SQL突然开始报错。最让我印象深刻的一次是在接手一个老项目时数据库从5.6升到5.7后后台的报表模块几乎全部瘫痪。当时第一反应是升级过程出了问题折腾了整整半天才发现是SQL模式的默认策略变了。这种升级后突然报错的情况在现实中占比相当高。这篇文章要解决的问题很简单only_full_group_by报错的根因是什么有哪些主流解法每种解法的代价是什么以及我踩过哪些坑。如果你正在被这条报错折磨或者升级完MySQL后一堆老查询罢工这篇文章可以直接帮你省下排查时间。注文中所涉及的版本以MySQL 5.7为基础同时会提到8.0版本下的差异。我会把每一步操作和原理都拆开讲保证你照着做就能解决。2. ONLY_FULL_GROUP_BY到底在管什么一条SQL标准背后的语义之争想要彻底解决这个报错光会改配置不够你得先明白它在管什么。很多教程上来就让你删掉ONLY_FULL_GROUP_BY这能解决问题但你不知道这个选项存在的意义是什么后面遇到更复杂的查询还是会一头雾水。2.1 分组查询里的歧义问题先看一个最简单的例子。有一张订单表orders结构大致是这样字段名类型说明idint主键customer_idint客户IDtotal_amountdecimal订单金额created_atdatetime下单时间现在我想统计每个客户的订单总金额正常写SELECT customer_id, SUM(total_amount) FROM orders GROUP BY customer_id;这个没问题。但很多人会顺手写上客户姓名或者下单时间SELECT customer_id, customer_name, SUM(total_amount) FROM orders GROUP BY customer_id;问题来了如果同一个customer_id对应多个不同的customer_name比如客户改名了或者本来就是不同的用户数据库该返回哪一条customer_name这从逻辑上根本没法确定。在旧的MySQL 5.6及更早版本中数据库会随便选一条返回这种不确定性在当时被很多人忽略但它确实是SQL标准里明确不允许的。再极端一点SELECT出去的字段明明不在分组条件里但恰好每个组内这个字段的值都一样。比如按customer_id分组customer_name理论上可能因为各种原因不同但实际上恰好一样。旧版本MySQL在语义上完全不校验直接给你一个正确的结果这会让开发者产生错觉以为这么写没问题。直到某天数据里混入了不一致的取值结果就开始看心情了。ONLY_FULL_GROUP_BY就是一个强制校验开关。它要求SELECT列表中的每一个非聚合字段要么出现在GROUP BY子句中要么被聚合函数包裹要么在功能上依赖GROUP BY字段。这样就从根上杜绝了歧义取值。2.2 MySQL 5.7.5的默认策略调整这里有个关键的时间节点。MySQL 5.7.5版本开始ONLY_FULL_GROUP_BY被纳入了默认的sql_mode。也就是说只要你是5.7.5或更高版本只要没有自己显式修改过sql_mode这个校验就是默认开启的。8.0版本同样如此。这个默认值的调整本质上是MySQL向标准SQL规范靠拢。很多从5.6时代过来的老开发者都在这上面栽过跟头。新写的SQL在开发环境跑得好好的一上线到5.7的生产环境就报错这种前后版本不一致带来的体验确实让不少团队踩了很久的坑。另外要澄清一个常见误解only_full_group_by报错并不意味着你的SQL完全错误它只是检测到了SQL语义上存在不确定的取值。如果某个字段在功能上依赖于GROUP BY字段——比如按主键id分组然后SELECT该行其他的普通字段因为主键唯一其他字段的值天然是确定的——那么即使是ONLY_FULL_GROUP_BY模式下也是允许的。MySQL对功能依赖有检测逻辑不是死板地要求所有字段都必须在GROUP BY里。理解了这一点你就能明白报错的本质是数据库没法确定你要哪一条数据解决方案无非两条路——要么告诉数据库随便取一条就行放宽校验要么把SQL改成让数据库能确定取哪条消除歧义。3. 解法一调整sql_mode从全局和会话两个层面入手调整sql_mode是最直接的方案很多网上教程的完美解决指的就是这一招。它分为三种粒度仅当前连接生效、仅当前数据库实例生效、以及通过配置文件永久生效。3.1 先看清当前环境的sql_mode动手之前先查一下你现在数据库的sql_mode到底包含哪些选项SELECT sql_mode;或者SHOW VARIABLES LIKE sql_mode;以5.7默认配置为例你大概率会看到类似这样的结果ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION这个长串里除了ONLY_FULL_GROUP_BY其他几个选项也有各自的作用。比如STRICT_TRANS_TABLES是严格控制模式NO_ZERO_DATE禁止日期为零值。当你决定修改时建议保留其他项只去掉ONLY_FULL_GROUP_BY不要图省事把整个sql_mode清空。3.2 会话级修改快速验证的首选如果你的SQL只是偶发场景或者你不想影响全局可以在当前会话里临时修改SET SESSION sql_mode STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION;注意我把ONLY_FULL_GROUP_BY从列表里剔除了其他项原样保留。执行之后当前连接内再跑同样的分组查询报错就消失了。这个修改只对当前连接有效断开重连后会自动恢复默认。会话级修改非常适合以下几种情况调试阶段想快速确认报错是否真的由ONLY_FULL_GROUP_BY引起。在某个业务线程的数据库连接初始化时针对特定业务做定向放宽。不想改动生产环境全局配置只为了跑一次历史数据的临时修复脚本。但你要清楚这不是持久化的方案。如果应用重启、连接池重连修改就丢了。生产环境里指望靠SET SESSION来长期解决问题并不现实。3.3 全局级修改当前运行实例生效如果确定了全局都要放开这个校验可以执行SET GLOBAL sql_mode STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION;这里有一个特别容易踩的坑SET GLOBAL只对之后新建的连接生效已有的连接仍然使用旧值。你执行完SET GLOBAL后再用当前的客户端工具去查询可能感觉没效果原因就在这里——你得重新连接一下或者让应用重启连接池。全局级修改解决了所有新连接都生效的问题但实例一旦重启配置还是会被配置文件里的内容覆盖。要真正做到永久生效必须改配置文件。3.4 永久生效修改my.cnf/my.ini这一步是一劳永逸的关键。Linux环境下MySQL配置文件通常在/etc/my.cnf或/etc/mysql/my.cnfWindows环境则是my.ini。找到[mysqld]分段注意不是[mysql]不要搞混在下面加一行[mysqld] sql_mode STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION然后重启MySQL服务# systemd 管理的系统 systemctl restart mysqld # 或者传统 sysvinit service mysql restart # Windows 服务方式 net stop mysql net start mysql重启之后再查SELECT sql_mode;看到返回的列表里没有ONLY_FULL_GROUP_BY就说明永久生效了。这里有个细节值得注意直接在配置文件里写sql_mode这行实际上会整体覆盖默认值。所以你要把整串配置完整写出来不要只写一句sql_mode留空那等于把所有SQL模式校验都关了后果可比去掉一个ONLY_FULL_GROUP_BY严重得多。比如STRICT_TRANS_TABLES没了之后插入超长字符串会被截断而不是报错很容易造成数据静默丢失。另外MySQL 8.0里的默认sql_mode已经移除了NO_AUTO_CREATE_USER因为GRANT语句的行为变了。你在8.0上操作时直接沿用5.7那串配置可能反而会报错。建议8.0环境使用SELECT global.sql_mode;先把当前实例的完整值拉出来去掉ONLY_FULL_GROUP_BY后再写进配置文件。4. 解法二改写SQL不碰全局配置的高阶玩法比起直接改sql_mode我更推荐从SQL本身入手。原因很简单ONLY_FULL_GROUP_BY是SQL标准的合理要求你改了配置等于让数据库回到以前那个不严谨的时代今天不报错明天换个数据量可能就出脏数据。而把SQL改严谨对业务长期是好事。有三种改写思路按我的经验由易到难排列。4.1 使用ANY_VALUE()函数当确定组内某个字段取哪条都无所谓时可以直接用ANY_VALUE()告诉数据库随便挑一个值返回即可。比如前面那个例子SELECT customer_id, ANY_VALUE(customer_name), SUM(total_amount) FROM orders GROUP BY customer_id;ANY_VALUE()不是聚合函数它的作用就是放弃对这个字段的校验。MySQL 5.7.5及更高版本都支持。这个函数非常适合这个字段其实不会歧义但数据库不知道的场景。比如customer_id与customer_name在业务上确实是唯一对应的但MySQL的功能依赖检测识别不到这种业务约束——因为表上没建唯一索引此时用ANY_VALUE()是最优雅的。不过要记住如果这个字段真的可能在一个组内出现不同值ANY_VALUE()返回的是哪一条是不确定的只是想确认多行中是否有某一行满足条件、而对具体是哪一行不关心的时候才用它。如果业务需要的是某个特定规则下的值比如取最新的一条用ANY_VALUE()就会得到随机结果这时候应该用别的方案。4.2 用聚合函数消除歧义如果想取每个客户最近一笔订单的时间最原始的写法SELECT customer_id, created_at FROM orders GROUP BY customer_id;这在ONLY_FULL_GROUP_BY下必然报错。改用聚合函数SELECT customer_id, MAX(created_at) FROM orders GROUP BY customer_id;这里直接用MAX()表达取最新语义非常清楚。更复杂的场景比如想要最近一笔订单的金额不是时间就不能简单MAX()了。一种可行的写法是SELECT o.customer_id, o.total_amount FROM orders o INNER JOIN ( SELECT customer_id, MAX(created_at) AS max_created_at FROM orders GROUP BY customer_id ) t ON o.customer_id t.customer_id AND o.created_at t.max_created_at;子查询先找出每组最大的时间再回表取对应的金额。这个写法完全符合ONLY_FULL_GROUP_BY的要求而且结果确定。代价是SQL变长、多了一次关联当数据量特别大时性能要测试。4.3 把随便取变成明确取用关联子查询或窗口函数MySQL 8.0以上还提供了窗口函数处理取组内某条特定记录更加得心应手。比如同样的每组最新一笔订单需求用ROW_NUMBER()SELECT customer_id, total_amount, created_at FROM ( SELECT customer_id, total_amount, created_at, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn FROM orders ) ranked WHERE rn 1;这种写法的优点非常明显可读性好、结果确定、不受sql_mode影响。缺点就是需要MySQL 8.05.7版本用不了。如果你的项目还在5.7上优先考虑子查询关联方案。改写SQL的方案本质上是想清楚你究竟要哪一行。绝大多数报错场景都是因为当初写SQL的人自己也没想清楚只是碰巧在MySQL的宽松模式下能出结果。把这个问题想明白了SQL质量会有质的提升。5. 两种方案怎么选我的取舍逻辑和实测建议很多读者看到这里会问到底哪个方案更完美我的回答可能会让你意外——没有放之四海而皆准的答案但有清晰的取舍依据。我根据实际项目经验把两种方案做了个对比。对比维度修改sql_mode改写SQL改动范围全局/会话级配置单个SQL语句生效速度立即生效重启或重连后稳定立即生效对现有代码影响无需改代码存量SQL全部恢复需要逐条排查、逐条改写风险点放宽校验可能日后出现不确定结果改写逻辑出错结果不符合预期长期维护成本低但语义上退步高但语义清晰适用阶段紧急修复、存量系统升级过渡新开发、核心查询逻辑我个人的实操建议分三种情况来说第一种系统已经上线大量SQL在5.6时代写的升级到5.7或8.0后全部崩了。这种情况优先改sql_mode。理由很现实业务不能停你不可能一天之内排查完几千条SQL。先改配置恢复业务把ONLY_FULL_GROUP_BY去掉让系统跑起来。然后记录一份清单后续逐步优化关键查询再在有足够测试覆盖的前提下一段一段地把有隐患的SQL改严谨。最后当你确保所有存量SQL都符合规范时再把ONLY_FULL_GROUP_BY加回去。这是代价最小、风险可控的路径。第二种新项目、新开发的功能模块。直接用改写SQL的方案从一开始就把查询写严谨。不要贪图一时省事把sql_mode关掉因为你新写的SQL将来会在不同环境之间迁移每个环境的sql_mode不一定一致。与其赌环境不如让自己的SQL在任何模式下都能跑。第三种开发环境里的临时调试。用会话级修改就行不要改全局更不要动配置文件。开一个专门用于调试的连接SET SESSION sql_mode ...去掉ONLY_FULL_GROUP_BY调试完直接断开连接不留任何后遗症。我个人的经历比较曲折。早期我给一个电商后台加报表功能因为sql_mode问题被卡了两天。最初直接改了配置文件业务恢复很快但后来发现一个统计报表在特定数据分布下出现了错误的汇总数字——因为分组查询取了一条看心情的记录。排查了很久才发现是当初去掉ONLY_FULL_GROUP_BY埋下的隐患。从那以后我给自己定了个规矩配置可以改但每个被放宽的SQL都要有账可查。6. 实操中的避坑清单改完配置不等于万事大吉最后这部分我把自己这些年踩过的坑集中说一下。有些坑不是网上教程会告诉你的但踩一次真的会疼很久。6.1 只改配置不检查其他SQL模式选项开头我提到过sql_mode是一整串选项拼接起来的。只去掉ONLY_FULL_GROUP_BY、保留其他选项是正确的做法。但如果你图省事直接写了sql_mode 这就等于把所有模式约束全部关闭了。后果是STRICT_TRANS_TABLES没了插入超长字符串不再报错而是静默截断NO_ZERO_DATE没了你可以插入0000-00-00这种日期ERROR_FOR_DIVISION_BY_ZERO没了除零操作不报错而是返回NULL。这些宽松累积起来对数据质量的破坏是缓慢但致命的。正确做法是先用SELECT sql_mode;查出当前完整值复制出来删掉ONLY_FULL_GROUP_BY这一项把剩下的字符串原样写进配置。6.2 SET GLOBAL不生效的误解前面提过SET GLOBAL修改后已存在的连接不会立即更新。如果你在一个长连接里执行了SET GLOBAL sql_mode然后立刻测试SELECT SESSION.sql_mode;看到的还是旧值就很容易误以为操作没生效。实际上新开的连接就会拿到新值。解决这个问题的方法很简单执行完SET GLOBAL后重新建立一个连接再验证。如果你用的是连接池比如HikariCP、Druid应用里的连接通常不会因为MySQL的SET GLOBAL而重建。最稳妥的办法是重启应用或者通过连接池管理接口手动清空连接。6.3 MySQL 8.0的雷区MySQL 8.0和5.7在sql_mode上不完全一样。8.0默认值里已经没有了NO_AUTO_CREATE_USER因为8.0里GRANT语句不能隐式创建用户了。如果你从网上复制一段5.7的配置直接改都没改就放进8.0的配置文件里MySQL可能直接拒绝启动或者启动后出现奇怪的授权行为。我的建议是在8.0环境里永远先查自己的global.sql_mode再基于当前实例的值来改。不要背一份通用配置走天下。6.4 从云端数据库和容器化环境的差异如果你用的是云数据库比如阿里云RDS、腾讯云CDB配置文件往往不能直接改。这类云产品通常在控制台提供了参数设置入口你需要在参数列表里找到sql_mode修改后提交有些还需要重启实例。注意云数据库经常会在运维操作时重新拉取参数你要确保修改已经提交而不是只保存。Docker部署的MySQL也是一个重灾区。很多容器启动命令只设置了MYSQL_ROOT_PASSWORD没有挂载配置文件导致容器内部用的是内置默认配置。修改sql_mode时要么启动时挂载自定义配置文件docker run -d \ --name mysql \ -e MYSQL_ROOT_PASSWORDyour_password \ -v /path/to/my.cnf:/etc/mysql/conf.d/my.cnf \ -p 3306:3306 \ mysql:5.7要么进入容器后临时修改但容器重建后配置就丢了。生产环境建议务必用挂载的方式管理配置这样配置变更才能纳入版本控制容器重建也不怕丢。6.5 改完SQL别忘了验证执行计划不管是改sql_mode还是改写SQL改完之后都不要急着上线。尤其是改写SQL方案中加子查询、Join关联的一定要用EXPLAIN看一下执行计划。我曾经试过把一条简单的分组查询改成关联子查询后跑出了百万行级的中间结果集查询时间从0.1秒直接飙到30秒。加了合适的索引后才恢复正常。EXPLAIN SELECT o.customer_id, o.total_amount FROM orders o INNER JOIN ( SELECT customer_id, MAX(created_at) AS max_created_at FROM orders GROUP BY customer_id ) t ON o.customer_id t.customer_id AND o.created_at t.max_created_at;重点看type是否达到ref或eq_refrows是否在合理范围Extra列有没有出现Using filesort或Using temporary这些都可能提示性能隐患。6.6 测试环境和生产环境的sql_mode一致性这点最容易被忽视。很多团队开发环境用的MySQL是8.0测试环境是5.7生产环境又是某个云厂商的定制版本。三个环境的sql_mode各不相同。开发时一条SQL因为开发环境关闭了ONLY_FULL_GROUP_BY而跑得好好的上了测试环境就开始报错。建议所有环境的sql_mode保持一致最好都保留MySQL的默认值只做极少数必要的调整。可以通过配置管理工具比如Ansible统一分发配置文件避免人为手工修改导致环境漂移。我自己就吃过这个亏开发环境一切正常测试环境惨不忍睹最后花了整整一个下午才找出是sql_mode配置不一致。7. 最后的实操心得回到标题里完美解决方案这个词。做开发时间久了你会发现所谓的完美方案往往不是那个一步到位、以后再也不报错的方案而是你真正理解问题本质之后能在几分钟内判断出该用哪种方式处理的方案。我的体会是多数情况下改SQL比改配置要高级但也更费时。紧急修复用配置长期维护靠SQL。如果项目里有大量遗留SQL先改配置救火再列个技术债清单逐步消化。对于新写的代码从第一天起就严格遵守ONLY_FULL_GROUP_BY的规则写分组查询时先问自己这个字段的取值数据库能确定吗如果不能是应该聚合、还是应该ANY_VALUE()、还是应该子查询取特定行遇到这个报错不要急按顺序来先SELECT sql_mode;确认当前模式再判断是临时救火还是长期修复最后动手。整个过程我一般能在十分钟内完成希望对你有帮助。