ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

MySQL ERROR 1055根因分析:ONLY_FULL_GROUP_BY下的分组查询规范

MySQL ERROR 1055根因分析:ONLY_FULL_GROUP_BY下的分组查询规范

先说结论:如果你在 MySQL 5.7 及以上的版本里碰到ERROR 1055 (42000),先别急着骂数据库,更别一上来就找配置文件把ONLY_FULL_GROUP_BY关掉。这条错误本质上是 MySQL 在提醒你:分组查询写得不够严谨。

前几天我刚好处理过这么一件事。项目里负责统计功能的同事提交了一个查询,本地跑得好好的,结果一部署到测试环境,整个接口直接报错。他把日志截图甩过来,内容就是下面这条:

ERROR 1055 (42000): Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'mall_order.user_name' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by

报错信息写得其实挺直白:SELECT列表里的第 1 个字段user_name,既不在GROUP BY子句里,也不是聚合函数(COUNT、SUM、MAX这些)的入参。在启用了ONLY_FULL_GROUP_BY这种 SQL 模式的情况下,MySQL 直接把这条 SQL 拒了。

当时同事的代码大概长这样:

SELECT user_name, COUNT(*) AS order_count, SUM(order_amount) AS total_amount FROM mall_order WHERE order_status = 1 GROUP BY user_id;

他当时的想法很典型:我按user_id分组,然后顺手把对应的user_name查出来,反正一个user_id只对应一个user_name,取哪条都一样。这个业务逻辑单看没问题,但 MySQL 没法证明user_id和user_name一定是一对一。除非你有明确的唯一键约束,并且查询里能推导出这种依赖关系,否则它只能按“不确定字段”处理,然后给你一个 1055。

这个错误名字听起来挺唬人,但绝大多数情况不是 SQL 语法错了,而是 SQL 语义在“分组之后我能访问哪些列”这个问题上不符合标准。MySQL 5.7 之后把ONLY_FULL_GROUP_BY放进了默认 SQL 模式,大量从 5.6 及更早版本迁过来的应用第一次跑就翻车,基本都在这个点上。

这篇随记想把 1055 的根因、排查路径和长期应对办法一次性说透。如果你刚接触 MySQL,或者正被线上分组查询报错折磨,看完应该能省不少时间。

1. 一场凌晨的发布事故:1055报错长什么样

先说回同事遇到的那次发布。

现象是:接口在本地开发环境一切正常,到了测试环境就挂。日志里没有别的异常,就一条 1055。由于是统计查询,SQL 本身不复杂,问题出在哪里其实一眼就能定位。但真正让我觉得值得写下来的,不是这条报错本身,而是团队里没人意识到“不同版本的 MySQL 对分组查询的容忍度不一样”。

补充一条更完整的报错信息,方便搜索引擎能命中、也方便你对着排查:

ERROR 1055 (42000): Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'mall_order.user_name' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by

有个细节值得注意:报错信息里写了Expression #1,意思是SELECT列表的第 1 个位置出了问题。如果你的查询有 10 个字段,可能第 5 个字段才是不合规的那个。排查的时候从#N这个序号往前数 SELECT 列表就行,准确率很高。

这种报错的“味道”,和平时常见的语法错误完全不一样。语法错误是一眼假的:少了逗号、多了括号、关键字拼错,MySQL 在解析阶段就会告诉你。但 1055 是语义层面的审查,它出现在查询分析阶段。也就是说,你的 SQL 语法没问题、表存在、字段也存在,执行计划都快要生成了,MySQL 突然踩了刹车。

1055 常见于三类场景:

  • 老项目从 MySQL 5.6 或更早版本迁移到 5.7+;
  • 新项目使用了 5.7+ 版本,但团队成员的习惯还停留在“宽松模式”;
  • 原本没开ONLY_FULL_GROUP_BY的实例,因为配置基线的调整被打开了。

这三类场景我都踩过。所以后面的内容,我尽量按实际项目里最可能的出现顺序来讲。

2. 别急着关SQL模式:理解ONLY_FULL_GROUP_BY的来龙去脉

sql_mode是 MySQL 提供的一组行为开关,每种模式代表一种约束或者兼容性策略。它不是一整个开关,而是一串模式名组合在一起。正常情况下,你执行下面这条语句就能看到当前生效的组合:

SELECT @@GLOBAL.sql_mode; SELECT @@SESSION.sql_mode;

结果大概是这样的字符串:

ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION

而 1055 的报错,就是其中ONLY_FULL_GROUP_BY这一项在起作用。

很多人不理解的是:为什么 MySQL 5.7 之后非要把这个模式默认打开?过去不是跑得好好的吗?

这里要说一个背景。在 MySQL 5.6 及更早的版本里,ONLY_FULL_GROUP_BY默认是不开启的。这意味着你可以写出下面这种非常随意的查询:

SELECT user_id, user_name, user_phone, COUNT(*) AS order_count FROM mall_order GROUP BY user_id;

user_name和user_phone既没出现在GROUP BY里,也不是聚合函数的结果。MySQL 不报错,而是直接从分组里“随便挑一行”给你返回。关键是,它挑哪一行,不受你控制。

拿现实生活打个比方:全班按小组分组做项目汇报,评委要求每组选一个人上台。正常流程是小组内部确定人选,再由这个人上台。但宽松模式下的 MySQL 不这样,它直接在组里随便抓一个人推上台。这个人是谁?取决于数据页里谁先被扫到、索引怎么走、连接顺序怎么定。稍后同一条 SQL 再跑一次,可能上台的又是另一个人。

这种灵活性表面上是方便,实际上埋了两个雷:

第一,结果不稳定。索引选择、数据分布一变,同一个分组内返回的“那一条”可能就变了,报表前后对不上,测试环境与生产环境结果不一致。

第二,没有任何提示。开发者会误以为自己的写法是被支持的,直到某一天数据量变大,执行计划发生变化,突然出现诡异的数据波动,排查成本极高。

所以 MySQL 从 5.7.5 开始,把ONLY_FULL_GROUP_BY纳入默认 SQL 模式。它的要求很明确:所有在SELECT、HAVING、ORDER BY中出现的列,要么出现在GROUP BY子句里,要么被聚合函数包裹,要么能证明与分组列存在函数依赖关系。

这里“函数依赖”是个关键概念。比如user_id是用户表的主键,user_name就函数依赖于user_id。既然一个user_id决定唯一一行数据,那么在这一行里取user_name是确定的、不会有歧义。MySQL 允许在这种情况下省略user_name的聚合处理。

举个合法例子:

SELECT user_id, user_name, COUNT(*) AS order_count FROM user_info GROUP BY user_id;

因为user_id是主键,user_name函数依赖于它,这条 SQL 在ONLY_FULL_GROUP_BY下是可以通过的。这一点很多人不知道,也不愿意查文档,一遇到报错就跑去改全局配置,错过了 MySQL 本来允许的合法路径。

理解到这个层面,你就明白:1055 不是 MySQL 故意刁难开发者,而是它想让你把“分组后取哪个值”这个意图表达得足够清楚。

场景MySQL 5.6(默认)MySQL 5.7+(默认)
SELECT 非分组列直接返回任意一行,不报错不通过,报 1055
SELECT 函数依赖列允许允许
SELECT 聚合函数允许允许
结果稳定性可能不稳定严格约束,稳定可预期

3. 三条主攻路线:改SQL、改配置、改查询逻辑,到底怎么选

1055 的解决方案,网上能搜到的基本集中在三类。但很多帖子只讲操作,不讲取舍,导致不少人跟着抄完了才发现坑更深。我这里按实用优先级逐一拆开讲。

3.1 方案一:重写 SQL,让每个非聚合列都有明确归属

这是我最推荐的第一步,也是根治路径。如果你能改业务代码里的 SQL,优先在这里解决。

核心思路就一句话:让每个非聚合列要么进GROUP BY,要么套聚合函数,要么通过子查询把分组和明细拆开。

还是拿刚才那个例子,业务诉求是“按用户分组统计订单数、订单总额,同时展示用户名”。如果确认用户名在同一用户下是不变的,可以直接套聚合函数:

SELECT MAX(user_name) AS user_name, COUNT(*) AS order_count, SUM(order_amount) AS total_amount FROM mall_order WHERE order_status = 1 GROUP BY user_id;

MAX(user_name)在业务上等价于“从该用户所有订单里取最大用户名”。如果用户名确实固定,结果就和直接取user_name一样。这种写法改变了字段的语义,但仍然清晰、可预期、MySQL 也接受。

如果嫌MAX语义绕,MySQL 5.7.5 之后还提供了ANY_VALUE():

SELECT ANY_VALUE(user_name) AS user_name, COUNT(*) AS order_count, SUM(order_amount) AS total_amount FROM mall_order WHERE order_status = 1 GROUP BY user_id;

ANY_VALUE(user_name)的意思是“这个分组里任意一条的用户名都行”。它比MAX(user_name)更诚实——我不关心是哪一个,我只想消除语法报错。代价同样是结果不确定。如果只在同一分组内取一个业务上完全相等的字段,问题不大;如果这个字段在不同行里有不同值,那么ANY_VALUE就是在掩盖数据质量问题。

更严谨的做法,是通过子查询把“先分组算汇总,再回表取明细”两步分开:

SELECT u.user_name, t.order_count, t.total_amount FROM ( SELECT user_id, COUNT(*) AS order_count, SUM(order_amount) AS total_amount FROM mall_order WHERE order_status = 1 GROUP BY user_id ) t JOIN user_info u ON u.user_id = t.user_id;

这一步的逻辑就非常标准了:子查询只汇总user_id维度的数据,外层再通过关联拿用户名。用户名的来源是user_info表的主键关联,不存在任何歧义。代价是多了一次关联,但因为user_id上有索引,性能通常可以接受。如果后续要加筛选条件,也更不容易被ONLY_FULL_GROUP_BY规则绊住。

3.2 方案二:调整 sql_mode,把报错临时压下去

这种做法的核心是去掉ONLY_FULL_GROUP_BY。网上一搜一大把,但我必须把后果说清楚。

先讲怎么操作。如果你是应急处理,可以在当前会话级别执行:

SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';

注意这条命令只影响当前连接,不会影响其他用户。如果你连的是业务连接池,改动不会持久,服务一重启就还原。这种做法的用途仅限于你手动连上数据库做一次临时验证,看看是否真的是模式开关导致的报错。

如果想全局生效,必须改成:

SET GLOBAL 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,Windows 下一般是my.ini,在[mysqld]段里写:

[mysqld] sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION

保存后用systemctl restart mysqld重启实例。

这玩意儿的代价是什么?你只是把报错藏了起来,并没有把 SQL 写对。一旦分组查询里出现了“随机挑一行”的用法,线上数据就可能出现难以解释的跳动。我之前接触过一家公司的报表系统,隔三差五有人反馈“同一个 SQL 上午查和下午查结果不一样”。最后定位到根因,就是有人在迁移时为了兼容第三方系统的历史 SQL,把ONLY_FULL_GROUP_BY关掉了,结果分组查询里几十个裸字段开始随机取值。数据从哪一行来的,谁都不知道。

所以我的建议非常明确:ONLY_FULL_GROUP_BY只适合在迁移过渡期临时关闭,或者作为历史遗留 SQL 改造完成之前的“缓刑期”手段。它不该是一个长期存在的配置。

3.3 方案三:利用函数依赖,让查询合法化

前面提过,MySQL 允许查询里出现函数依赖于分组列的字段。这个特性在日常查询里很有用,尤其在联合查询时,可以省掉很多多余的聚合函数。

一个我会经常使用的示例:

SELECT o.user_id, u.user_name, u.user_phone, COUNT(*) AS order_count FROM mall_order o JOIN user_info u ON u.user_id = o.user_id GROUP BY o.user_id;

这里user_name、user_phone都不在 GROUP BY 中,但它们函数依赖于user_info表的主键user_id。因为连接条件让u.user_id = o.user_id,而o.user_id又是分组列,MySQL 能推导出这些字段是确定的,于是放行。

这个方案对“明细表 + 维度表”的经典模型特别有效。你要按事实表某个维度分组,同时显示维度的描述性字段,只要维度表那个字段对应的键是主键或唯一键,就能直接写。不需要MAX,不需要ANY_VALUE,结果也完全确定。

但要注意,函数依赖推导在跨表场景下是有前提的:连接条件必须是等值连接,且参与推导的列必须是目标表的唯一键。如果连接条件里用了函数包裹(比如ON DATE(u.create_time) = o.create_date),推导大概率失败。

三种方案对比一下,给个选型参考:

判断维度重写 SQL调整 sql_mode利用函数依赖
是否根治是否是
是否改变结果语义可能需要 MAX/ANY_VALUE不改变,但结果可能随机不改变,结果确定
对性能影响需看具体写法无直接影响低,连接走索引即可
适合场景业务代码可改历史遗留 SQL 过渡期维度表关联场景

4. 本地能跑、生产报错的排查链路:版本差异和现场复现

现在回头说那天的排障过程。我当时没有直接帮他把 SQL 改掉,而是先带着他把整个排查链路走了一遍,因为这种“本地好、线上炸”的问题,以后一定会再遇到。

第一步,确认两边 MySQL 版本。本地一查:

SELECT VERSION(); -- 5.6.51-log

测试环境再查:

SELECT VERSION(); -- 5.7.30-log

到这里,事情已经清楚了八成。MySQL 5.6 默认没开ONLY_FULL_GROUP_BY,5.7 默认开了。同一个 SQL,一个睁一只眼闭一只眼,一个严格审查。

第二步,确认生效中的 sql_mode:

SELECT @@GLOBAL.sql_mode\G SELECT @@SESSION.sql_mode\G

本地输出的字符串里没有ONLY_FULL_GROUP_BY,测试环境输出的字符串里第一项就是它。这个差异是决定性的。

第三步,在本地复现问题。最简单的方式,不需要装一个 5.7 实例,直接在本地会话里临时手动打开这个模式:

SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';

然后再跑同一条 SQL,1055 立刻冒出来。这就证明问题不在数据,而是模式差异导致的行为差异。能当场复现,对后面说服开发同事非常有用,也省去了反复上线验证的折腾。

第四步,做应急处理。测试环境已经有人阻塞了,先把服务恢复才重要。因为当前连接执行SET SESSION只对当前会话有效,而中间件连接池建立的连接并不会听话,所以我最终还是建议用最稳妥的方式:允许他在测试环境的全局配置里临时去掉ONLY_FULL_GROUP_BY,重启 MySQL 恢复服务。同时明确记录时间点,约定一个 deadline,要求三天内所有问题 SQL 全部改完,第四天把配置改回来。

这中间有个很容易犯的错:有人认为直接在my.cnf里删掉这一项就完事,但其实 5.7 版本如果配置里没有显式写sql_mode,默认值反而会带上ONLY_FULL_GROUP_BY。也就是说,你以为删干净了,重启后又回来了。正确做法是像我上面那样,显式写出一整串模式,想要哪几个就只能写哪几个。

排障链路走完之后,得到的结论其实就一句话:本地与生产环境在sql_mode上出现了配置漂移。这种漂移比代码漂移更隐蔽,因为它不报错、不警告,只会让同一段代码在不同环境里跑出不同行为。版本的统一,和配置的统一,必须同时做。

5. 最容易漏的边角情况:ORDER BY、HAVING和函数包裹

1055 并不会只出现在最简单的SELECT ... GROUP BY里。实际业务 SQL 一旦复杂起来,报错的地方往往在你想不到的位置。

5.1 ORDER BY 裸字段

很多开发者以为只要 SELECT 列表合法就完事了,忘了 ORDER BY 同样受ONLY_FULL_GROUP_BY约束。比如:

SELECT user_id, COUNT(*) AS order_count FROM mall_order GROUP BY user_id ORDER BY user_name;

user_name既不在分组里,也不是聚合结果,这条 SQL 照样报 1055。解决办法要么把user_name也放进分组,要么用MIN(user_name)、MAX(user_name)排序:

ORDER BY MAX(user_name);

这种边角情况在“按维度分组然后按描述字段排序”的需求里特别常见。

5.2 HAVING 里引用未分组的列

HAVING 是对分组结果做过滤,但很多人会顺手写错:

SELECT user_id, COUNT(*) AS order_count FROM mall_order GROUP BY user_id HAVING user_name = '张三';

过滤条件user_name = '张三'里的user_name不是聚合列,也不是分组列,MySQL 立即报 1055。正确写法是把过滤条件放到 WHERE 里,如果确实要从分组结果里判断,也必须使用MAX(user_name) = '张三'这种聚合表达。

5.3 SELECT 列表里用了函数但没做聚合包裹

这个是重灾区。比如按用户分组后,想顺便展示下单日期的年份:

SELECT user_id, DATE_FORMAT(create_time, '%Y-%m-%d') AS create_date, COUNT(*) AS order_count FROM mall_order GROUP BY user_id;

DATE_FORMAT(create_time, '%Y-%m-%d')是对裸列create_time做函数操作,不是聚合操作,所以依然违规。正确做法是聚合后在子查询外再处理,或者直接用MAX(create_time)这类写法。

类似的情况还有CASE WHEN、IF、字符串拼接等一切针对裸列做处理的表达式。你只要记住:它们都没被聚合函数包住,就都算裸列。

5.4 SELECT * 与 GROUP BY 的组合

开发里还有一种很常见的写法:

SELECT * FROM mall_order GROUP BY user_id;

这条 SQL 的意图可能是“按用户去重查最近的订单”,但它把整张表的所有列都放进了 SELECT 列表,绝大多数是裸列,直接 1055。遇到这种情况,不要试图用SELECT *去绕过,先把需求拆清楚:你到底是想要每个用户的一条完整订单,还是想要用户维度的汇总?前者应该用窗口函数或者子查询,后者必须明确列出聚合列。

5.5 派生表绕行也要小心

很多时候,把问题 SQL 包一层子查询,确实能让外层查询合法。我见过不少人被 1055 逼急了,直接在原始 SQL 外面加了一层SELECT * FROM (原SQL) t,然后就不报错了。原因是子查询内部确实做了分组,外层的*引用的都是已经聚合后的结果,不再是裸列。

但这种方式容易养成坏习惯。它在语义上经常不对:如果你原来就想在子查询里返回多个“非分组列”,MySQL 会在子查询内部直接报 1055,包几层都一样。而且多套一层子查询,执行计划可能走全表扫描,性能和可读性都下降。派生表是某些特定场景的合法解法,不是通用逃生舱。

6. 根本解法:把“防1055”写进团队规范里

处理完这次发布事故,我没有止步于把 SQL 改对。因为只要团队整体意识不改变,1055 一定还会以另外的面孔回来。

那天之后,我在项目组里定了三条规则。

第一条,统一 MySQL 版本与 sql_mode 基线。开发环境、测试环境、生产环境尽量用同一个大版本。数据库初始化脚本里必须显式声明sql_mode,不依赖发行版默认值。把ONLY_FULL_GROUP_BY保留下来,让开发者在本地就能碰到严格模式,而不是等上了测试环境才发现问题。

初始化命令可以直接加进部署脚本:

mysql -uroot -p -e "SET GLOBAL sql_mode='ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';"

改配置文件更稳,反正目的就一个:让所有环境的sql_mode肉眼可见地一致。每次环境构建时,顺手跑一下SELECT @@GLOBAL.sql_mode;放进交付检查单,配置漂移问题基本就绝迹了。

第二条,把分组查询的评审点写进代码审查流程。凡是改动涉及GROUP BY的 SQL,审查人必须确认三件事:非聚合列是否真正依赖分组键;聚合函数是否选得对;查询结果是否在多次执行下保持稳定。有ANY_VALUE的地方必须额外注释,说明这里为什么不在乎具体值。别小看这一条,它可以把 1055 从“上线后故障”变成“评审时指出”的低成本问题。

第三条,对历史遗留的复杂 SQL 做渐进式改造。我检查过一些老项目的慢查询日志,发现里面有大量“裸列 + GROUP BY”的写法。这些查询在旧版本上跑了几年,换版本后全成了合规风险。逐一手工改不现实,但也不能永久禁用ONLY_FULL_GROUP_BY。我的做法是分批处理:先用EXPLAIN和日志找出高频分组查询,优先改报表和核心接口,剩下低频查询在季度维护窗口内改完。改完一批,就把sql_mode的严格程度提一档,直到所有历史 SQL 都在严格模式下能跑。

对了,还有个小技巧值得分享。排查阶段,如果不想手写一长串sql_mode,可以直接用SELECT @@GLOBAL.sql_mode拿到当前完整值,然后手工去掉ONLY_FULL_GROUP_BY之后复制回去。这样可以少打很多字,也不会意外丢其他模式。

回过头看,MySQL 1055 这个报错其实是件好事。它逼着开发者在写分组查询时想清楚每一列的语义。只要环境配置统一、开发习惯规范、审查流程严格,这个错误就基本不会再出现在你的发布日志里。最后再补一句:如果你现在正被这个问题卡着,优先改 SQL,关模式只是缓兵之计。这既是技术选择,也是团队习惯的取舍。

RELATED READING

延伸阅读

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