
1. 先搞懂 ONLY_FULL_GROUP_BY 到底管什么1.1 一个 sql_mode 引发的“工伤”如果你的项目里突然出现一条报错内容核心是Expression #... is not in GROUP BY clause and contains nonaggregated column...别慌这不是你的 SQL 写错了多少而是 MySQL 的sql_mode里多了一把锁——ONLY_FULL_GROUP_BY。这事在开发圈太常见了。老项目从 MySQL 5.6 升到 5.7或者新同学拿到一套 8.0 的库之前跑得飞起的 SQL 一夜之间全报错。最典型的场景是统计每个用户的最近订单、每个商品的最新价格这类“分组后取组内某一行”的查询十有八九栽在这里。这个模式的核心逻辑一句话就能概括在SELECT列表、HAVING条件、ORDER BY子句里出现的列要么出现在GROUP BY后面要么被聚合函数SUM、MAX、MIN等包裹。二者都不满足就报错。先记住一个通俗类比。把一家人按“户口本”分组每组只能回答“这户一共几口人”“户主是谁”这种全组统一的结论如果你想问“这户里年龄第二大的人叫什么”那不是分组能直接回答的问题必须先把“年龄第二大”这个规则写清楚。ONLY_FULL_GROUP_BY强制你遵守这套逻辑不允许你含糊地选中组里“不知道哪一行”的数据。1.2 为什么 MySQL 5.7 开始默认开启它很多老程序员会吐槽5.6 时代我这么写从没报过错凭什么 5.7 就不行这不是 MySQL 故意找茬而是它想把历史欠账补上。MySQL 5.6 及更早版本默认不启用ONLY_FULL_GROUP_BY导致大量开发者写了“看似合法、实则随机”的 SQL。同一个查询在数据量小的时候返回的可能是你潜意识里想要的那一行数据量一大、索引一变、执行计划一调整返回的行可能就变了。这种“不确定行为”在金融、订单、库存类系统里是致命的。5.7.5 版本开始MySQL 默认把ONLY_FULL_GROUP_BY加进sql_mode8.0 延续了这套默认配置。所以这个模式不是“限制你”而是在保护你。它把你的 SQL 从“凭运气取数”变成“明确语义取数”代价就是你要多写几行代码。理解这一层你后面面对报错的心态会完全不一样——不是“怎么绕过去”而是“我该怎么把这个需求写严谨”。1.3 功能依赖GROUP BY 主键时不报错在讲解决方案之前必须先补一个关键概念功能依赖Functional Dependency。MySQL 5.7 的检测是带智能的不是死板地查“列名在不在 GROUP BY 里”。如果你的GROUP BY后面带的是主键或唯一键那么其他普通列可以直接放在SELECT列表里而不报错。原因是主键唯一确定了一整行这一行的order_no、user_name都是确定的不存在“模糊选择”的问题。SELECT user_id, user_name, MAX(order_amount) FROM t_order GROUP BY user_id;假设user_id是主键user_name不写聚合也能通过检查因为user_id确定后user_name就被“功能依赖”决定了。这也是官方对齐 SQL 标准时给出的善意豁免。理解这一点后很多报错你会豁然开朗不是所有非聚合列都不行关键是“这个列的值是不是被 GROUP BY 的列唯一确定”。2. 报错信息全拆解读懂 10552.1 一条真实的报错现场这是我在处理一个订单统计需求时遇到的真实报错当时要查“每个用户最近一次下单的金额和订单号”SELECT user_id, order_no, order_amount, MAX(create_time) AS last_create_time FROM t_order GROUP BY user_id;跑出来直接报了一长串[Err] 1055 - Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column testdb.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 of SELECT list报错的是SELECT列表里的第 2 个表达式也就是order_no。注意这里的序号从 1 开始数一下你的查询列表就能定位。is not in GROUP BY clause and contains nonaggregated column这个列既没出现在GROUP BY后面也没被聚合函数包裹属于“裸奔列”。this is incompatible with sql_modeonly_full_group_by这么写违反了当前sql_mode中的ONLY_FULL_GROUP_BY约束。2.2 报错里的三个关键词第一个关键词是nonaggregated column表示“非聚合列”。只要看到这个词就说明你的查询里有一列是直接裸露的。第二个关键词是functionally dependent你可以把它当成“被前面分组条件唯一确定”——如果报错里出现which is not functionally dependent说明 MySQL 认为你的非聚合列没有“安全感”。第三个关键词是incompatible with sql_mode翻译成大白话就是当前的严格模式不允许你这么查。顺便说一个新手容易忽略的坑报错不只出现在SELECT列表里HAVING、ORDER BY同样会触发。比如HAVING order_amount 100里的order_amount没有聚合一样报 1055。所以排查 SQL 时三个子句都要扫一遍别只盯着SELECT后面看。2.3 常见变体HAVING 和 ORDER BY 也会触发SELECT user_id, SUM(order_amount) AS total FROM t_order GROUP BY user_id HAVING order_amount 100;这条 SQL 的HAVING order_amount 100就是违规的因为order_amount没有聚合。正确写法是HAVING SUM(order_amount) 100如果你想表达“总额大于 100”的话如果确实想表达“任意一笔大于 100”那用MAX(order_amount) 100。还有一类更隐蔽的ORDER BY里放非聚合列。比如GROUP BY user_id ORDER BY create_timeMySQL 同样报错。它的逻辑是分组后create_time这个列在组内根本不唯一你让它排序它不知道该听谁的。这种查询的真实意图通常是“取每个组最新的记录”后面我会专门讲怎么改写。3. 立竿见影的救火方案处理 sql_mode3.1 先查看当前的 sql_mode不管你想怎么处理第一步一定是先看当前环境的完整sql_mode。用下面的 SQLSELECT GLOBAL.sql_mode; SELECT SESSION.sql_mode;MySQL 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_SUBSTITUTIONMySQL 8.0 默认值会略有差异去掉了一些废弃项但ONLY_FULL_GROUP_BY依然在列。看清楚你的完整值后面无论是“去掉一个模式”还是“整体替换”你心里都要有底。3.2 会话级、全局级、配置文件三级修改救火最快的是会话级只对当前连接生效不影响别人SET SESSION sql_mode (SELECT REPLACE(sql_mode, ONLY_FULL_GROUP_BY, ));这条命令的意思是把当前会话的sql_mode里的ONLY_FULL_GROUP_BY字符串替换成空。替换后可能残留两个连续逗号MySQL 解析时会自动忽略空字段实测不影响使用。如果你比较讲究可以这样写SET SESSION sql_mode ( SELECT TRIM(BOTH , FROM REPLACE(sql_mode, ONLY_FULL_GROUP_BY, )) );全局级则影响所有新连接已经建立的旧连接不受影响SET GLOBAL sql_mode (SELECT REPLACE(sql_mode, ONLY_FULL_GROUP_BY, ));注意SET GLOBAL只对之后的连接生效DBA 排查问题时常在这里踩坑明明改了现有终端还是报错因为终端里的旧连接没有刷新。如果是生产环境官方推荐把修改写进配置文件这样重启后仍然有效。Linux 一般是/etc/my.cnf或/etc/mysql/mysql.conf.d/mysqld.cnfWindows 是my.ini。在[mysqld]段落里写[mysqld] sql_mode STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION这里有一个细节MySQL 8.0 中NO_AUTO_CREATE_USER已经被移除如果你把 5.7 的默认值原样抄到 8.0 配置里MySQL 可能拒绝启动或报警合并时务必以SELECT sql_mode查出来的实际值为基准。3.3 修改 sql_mode 的适用场景与风险我必须把丑话说在前面修改sql_mode是治标不治本的救火方案它不会让你的 SQL 变得更严谨只是让 MySQL 放弃检查你。什么情况下适合改老系统几百条 SQL 都有同类问题业务逻辑复杂短期内无法逐一整改这时候先放宽模式保证系统跑起来是正确的止损操作。什么情况下不建议改新项目、新 SQL有时间和精力改写就不要动全局配置老老实实把 SQL 写规范。还有一个现实中的隐患如果团队里有人用了 MySQL 5.6 的库默认关闭有人用了 5.7 的库默认开启同一套代码在不同环境行为不一致这比“哪个版本更好”更容易引发线上事故。我见过不止一次开发环境跑得好好的一上生产全挂最后发现生产库升级过版本sql_mode默认值变了。所以我的建议是所有环境的sql_mode显式统一别依赖版本默认值。4. 根治你的 SQL写法整改与优化4.1 方案一把非聚合列加进 GROUP BY最直白的做法报错说哪个列不在GROUP BY里就把它加进去。以最开始的查询为例SELECT user_id, order_no, order_amount, MAX(create_time) AS last_create_time FROM t_order GROUP BY user_id, order_no, order_amount;这样确实不报错了但思考一下语义变了。原来的意图是“每个用户取一条”现在变成“每个用户 订单号 金额的组合取一条”如果同一个用户下了三笔不同金额的订单结果会出来三行而不是一行。所以这个方案只适合“所有列的组合本身就是唯一粒度”的场景比如查“每个用户的每个订单类型的小计”。4.2 方案二聚合函数包一层先想清楚语义如果业务上需要的是“每个用户的最大金额”那直接把order_amount改成MAX(order_amount)就完了SELECT user_id, MAX(order_amount) AS max_amount FROM t_order GROUP BY user_id;这个方案技术上讲最干净但很多人的困惑在于order_no怎么办比如“我想查每个用户最大金额对应的那笔订单号”这里有一条关键经验先用子查询定位再关联回原表取整行而不是试图在一个 GROUP BY 里塞进所有列。真正容易翻车的是很多人图省事这样写SELECT user_id, MAX(order_no) AS order_no, MAX(order_amount) AS max_amount FROM t_order GROUP BY user_id;这样不报错但MAX(order_no)取的是“字典序最大的订单号”和你“最大金额对应的订单号”可能根本不是同一笔。这种“字段级聚合”的写法是数据正确性的隐形杀手我强烈建议写完后自问一句这个聚合函数的值和其他列的值真的来自同一行吗4.3 方案三ANY_VALUE 和子查询如果你的需求是“分组后任意一行都行”MySQL 5.7 提供了ANY_VALUE()函数。它告诉优化器这个列我不在乎取组内哪个值你随便给我一个。注意这里强调的是“业务上真的不在乎”用错地方会掩盖逻辑问题。SELECT user_id, ANY_VALUE(order_no) AS order_no, ANY_VALUE(order_amount) AS order_amount FROM t_order GROUP BY user_id;如果需求是“每个用户最近一笔订单”那就别用ANY_VALUE老老实实走“子查询找最大时间再关联取整行”的路子SELECT a.user_id, a.order_no, a.order_amount, a.create_time FROM t_order a INNER JOIN ( SELECT user_id, MAX(create_time) AS max_time FROM t_order GROUP BY user_id ) b ON a.user_id b.user_id AND a.create_time b.max_time;这里还有个衍生坑如果同一个用户在同一秒下了两笔订单关联条件create_time max_time可能返回两行结果出现“重复”。严格的做法是给 JOIN 条件加上唯一键或者用窗口函数带排序号。4.4 一个完整案例取每个用户最近一笔订单MySQL 8.0 里最优雅的写法是用窗口函数彻底绕开分组纠结SELECT user_id, order_no, order_amount, create_time FROM ( SELECT user_id, order_no, order_amount, create_time, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY create_time DESC) AS rn FROM t_order ) t WHERE rn 1;PARTITION BY user_id相当于“按用户分窗”ORDER BY create_time DESC是窗内排序ROW_NUMBER()给每一行编序号最后取序号为 1 的那行。整个查询的语义非常直白每个用户按时间倒序排取第一行。对比一下这个场景如果强行用GROUP BY ANY_VALUE也能跑但“最近一笔”和“任意一笔”的本质区别是需求层面的不同不是写法层面的妥协。判断标准就一条结果是否可以被容忍地随机。ANY_VALUE不能保证你要的那一行窗口函数可以。5. 常见问题与避坑记录5.1 为什么同一个 SQL 在不同环境结果不一样这是群里被问了无数次的问题。最典型的是开发库不报错生产库报 1055。排查方向按优先级排两边的sql_mode是否一致。执行SELECT sql_mode;对比绝大多数情况差在ONLY_FULL_GROUP_BY这一项上。两边 MySQL 版本是否一致。5.6 默认关闭该模式5.7 和 8.0 默认开启跨版本是重灾区。连接参数是否一致。有些中间件、连接池会在建立连接后执行SET sql_mode导致同一个连接池里的两个连接行为不同。这一类问题最隐蔽建议先检查项目里有没有类似的初始化 SQL。我处理过最离谱的一例开发同学本地用的 MySQL 5.6连的是本地库测试环境却是 5.7而且测试库的sql_mode被上一个人改过一半剩下四个连接走的是不同sql_mode。最后统一在配置文件里显式指定才彻底结束这场“薛定谔的报错”。5.2 GROUP_CONCAT 的迷惑行为GROUP_CONCAT本身是聚合函数用它包裹的列不会触发ONLY_FULL_GROUP_BY报错但很多人在使用时遇到“排序不生效”的问题SELECT user_id, GROUP_CONCAT(order_no ORDER BY create_time DESC) AS order_list FROM t_order GROUP BY user_id;注意ORDER BY必须写在GROUP_CONCAT的内部写在GROUP_CONCAT外部的ORDER BY是用来排分组结果的和组内拼接顺序无关。如果写错了你会发现订单列表顺序是乱的但 MySQL 不报任何错这个属于逻辑层面的坑比 1055 更难察觉。还有一个容易被忽略的GROUP_CONCAT默认长度上限是group_concat_max_len默认 1024超过之后会被静默截断。排查这种问题通常很耗时间建议在用到长文本拼接时主动SET SESSION group_concat_max_len 1048576;。5.3 修改了 sql_mode 没生效排查方向先确认你是改了SESSION还是GLOBAL。只改GLOBAL当前连接不生效重开连接才行。配置文件改完必须重启 MySQL 服务不同操作系统重启方式有差异Windows 是net stop mysql、net start mysqlLinux 一般是systemctl restart mysqld。改完以后用SELECT GLOBAL.sql_mode;验证别只看配置文件。有些云数据库RDS 类的sql_mode参数在控制台配置直接改my.cnf重启后会被覆盖务必以云控制台的参数组为准。如果配置里写了多个实例共用的!include文件注意后面加载的配置可能覆盖前面排查时把最终生效值打印出来别只盯一个文件。5.4 几条写在代码注释里的团队约定踩过足够多的坑之后我建议团队在项目文档里固定这几条约定能省掉大量无意义的“为什么他跑得动我跑不动”的争吵所有环境的sql_mode显式统一不依赖版本默认值。新写的 SQL 必须符合ONLY_FULL_GROUP_BY规则开发环境一律开启该模式不给自己留后门。涉及“分组后取某一行”的需求优先用窗口函数MySQL 8.0或关联子查询明确语义后再动手写。需要“任意一行”且确实不在乎具体值的地方才用ANY_VALUE()并且要在 SQL 注释里写明原因防止后来的人误读。凡是改过sql_mode的脚本必须提交到版本库不能只在个人终端里执行。我个人在实际操作中的体会是ONLY_FULL_GROUP_BY这一拳打醒了很多“SQL 手感流”选手。它逼着你重新审视每一个分组查询的语义把“我以为”变成“SQL 表达出来的”。最初遇到 1055 时我也觉得烦但后来接手过几个数据错乱的项目排查下来全是历史 SQL 里“随机取一行”埋的雷。所以如果你现在正被这个报错折磨我的建议很直接先按第三部分把系统救起来然后按第四部分逐条改 SQL最后把团队规范立起来。这三步都走完你不仅会写正确的 SQL还会在写之前多想三秒这行数据到底是哪一行