1. 先搞清楚:你遇到的 1055 到底在说什么
如果你是从 MySQL 5.6 时代的存量项目一路维护到 8.0,或者刚把一套老系统跑在 MySQL 5.7.5+ 的默认配置上,大概率会遇到这么一条报错:
ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'mydb.sales.amount' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by看不懂没关系,拆开来看,这句话真正在说的是:你的 SELECT 列表里有某一列(这里是sales.amount),没出现在 GROUP BY 里,也没被聚合函数包住,同时 MySQL 认为它和分组字段之间不存在“函数依赖”关系。这事在ONLY_FULL_GROUP_BY模式下属于违规,所以数据库直接拒绝执行。
先看个典型场景。假设有一张销售流水表:
CREATE TABLE sales ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, amount DECIMAL(10,2) NOT NULL, pay_time DATETIME NOT NULL, INDEX idx_user_id (user_id) );业务想“按用户统计最近一次支付金额”,于是有人写出这样的查询:
SELECT user_id, amount, MAX(pay_time) FROM sales GROUP BY user_id;这条语句在 MySQL 5.6 的默认配置里大概率能跑通,还能返回结果。但在 5.7 和 8.0 的默认配置下会直接报 1055,原因就是amount字段没有参与分组,也没有套聚合函数,和user_id之间在语义上没有任何确定关系。
ONLY_FULL_GROUP_BY并不是什么新东西,它属于 SQL 标准早就规定过的行为。MySQL 从 5.7.5 开始把它默认加进sql_mode,8.0 一直延续这个配置。也就是说,这个严格化不是某次小版本临时发起的敏感模式,而是官方从 5.7 开始就试图把查询语义往标准靠拢的明确信号。
这篇文章会把“为什么 MySQL 以前不严格、现在要严格”“严格化背后的函数依赖到底是什么意思”“存量 SQL 怎么改、报错怎么查”这几件事讲透。适合正在升级数据库、排查 1055 报错,或者被老同事那句“以前 MySQL 这么写就能跑”困扰的开发、DBA 和运维朋友。
2. 为什么 MySQL 会“惯坏”一代开发者
很多人觉得ONLY_FULL_GROUP_BY是 MySQL 故意给自己找事,甚至怀疑是 “为了标准而标准”。但如果理解 MySQL 的历史定位,就会明白它当年那个宽松模式其实是无奈取舍。
2.1 SQL 标准早就规定,MySQL 却一直放宽
先看一条判断:如果对sales表按user_id分组,那么结果里每个user_id只对应一行。这时候amount该取哪一行的值?逻辑上无法确定,除非你明确告诉数据库要MAX(amount)、MIN(amount),或者让user_id唯一决定amount。
标准的做法是:分组后,SELECT 列表里要么是分组字段,要么是聚合函数计算出来的值,不允许出现“不知道取哪一行”的裸列。Oracle、PostgreSQL、SQL Server 早就是这样要求的。如果你在 PostgreSQL 里执行同样一句SELECT user_id, amount FROM sales GROUP BY user_id,它会直接告诉你:column "sales.amount" must appear in the GROUP BY clause or be used in an aggregate function。根本没有讨论余地。
但 MySQL 的出身完全不同。早期的 MySQL 定位是一个极度轻量的网站存储,追求“能跑就行”,在 4.x、5.0 那个年代,开发者基数小、查询复杂度低,官方选择的是默认不开启校验。特别是 MyISAM 那个年代,优化器怎么扫描、索引怎么走,决定了很多看似“稳定”的结果其实只是碰巧稳定。于是 MySQL 允许你写裸列,然后返回“分组后碰到的第一行”作为结果。这就是传说中的宽松 GROUP BY。
这个折中给无数项目埋了坑,也养成了大量“经验直觉”:很多人以为GROUP BY user_id之后再取amount拿到的是这一组某个规律的行,但实际上这个规律完全取决于执行计划,索引一变、统计信息一变、MySQL 版本一升级,结果就可能漂移。
2.2 宽松模式真正可怕的地方:它不是报错,而是悄悄给错数据
宽松模式最坑的地方在于:它不报错,只给你一个“看似对的结果”。如果数据库直接拒绝,你反而会谨慎处理;但它放行了,你根本不会意识到自己在写一个语义不成立的查询。
我踩过最典型的案例是这样:线上订单表,查询每个客户“最近一次下单金额”,用的就是GROUP BY customer_id然后直接取order_amount。在某个索引条件下,返回的是该组第一行(可以侥幸接近最新);后来为了性能调整了索引,结果每个客户拿到的不再是最新金额,而是历史某一条。代码没改、SQL 没改,数据开始悄无声息地错。等到对账发现问题,已经过去了整整一周。这就是宽松 GROUP BY 的代价:出错成本被无限制地推迟。
所以ONLY_FULL_GROUP_BY严格化,本质上是把“你根本不知道该取哪一行”的问题在数据库层就拦下来,逼你明确表达业务意图。
2.3 官方之所以敢在 5.7 收紧,是因为代价可以接受
MySQL 从 5.7 开始收紧,也是经过了权衡的。存量代码确实会有一批查询开始在升级后报错,这让很多 DBA 头疼;但反过来看,如果继续放任宽松模式,等于让所有新项目从第一天起就继承一个“可能产生错误结果”的默认行为。官方选择把正确性放在第一位,兼容性放在第二位。
因此不要一看到 1055 就想尽办法关掉 ONLY_FULL_GROUP_BY。我见过一些团队为了老代码省事,直接把sql_mode里的ONLY_FULL_GROUP_BY删掉,结果新同事继续写模糊查询,继续埋雷。正确的做法是把报错当成一次“债务清零”的机会,一次性梳理清楚存量 SQL。
3. 严格化背后的“函数依赖”到底是什么
报错信息里有一句很让人摸不着头脑的话:not functionally dependent on columns in GROUP BY clause。翻译成人话就是:MySQL 认为你选的那些列,不是由分组字段唯一确定的。
3.1 函数依赖的最简单理解
假设有张员工表:
CREATE TABLE emp ( id INT PRIMARY KEY, name VARCHAR(50), dept_id INT );如果按id分组,同一组里只有一行,那么name、dept_id其实都被id唯一决定了。这种“只要知道了 id,其他列的值就跟着确定了”的关系,就叫函数依赖。此时 MySQL 允许你写:
SELECT id, name, dept_id FROM emp GROUP BY id;因为id是主键,而name、dept_id在这个表里依赖id,所以虽然它们没有出现在 GROUP BY 中,也没有被聚合函数包裹,MySQL 依然判定合法。这个规则是 MySQL 5.7 引入的“部分函数依赖识别”能力。
但如果改成按dept_id分组,同一个部门里有多个员工,每个员工的name都是不同的。此时name不受dept_id控制:
SELECT dept_id, name FROM emp GROUP BY dept_id;这条就会被拒。因为这一组里有十个员工的姓名,数据库不知道你具体想要哪一个。
3.2 跨表 join 时的函数依赖陷阱
最容易踩坑的是 JOIN 场景。假设员工表关联部门表:
SELECT e.id, e.name, d.dept_name FROM emp e JOIN dept d ON e.dept_id = d.id GROUP BY e.id;从业务直觉上,e.id作为主键,应该能确定e.name,也能通过e.dept_id关联到唯一的d.dept_name。但 MySQL 的函数依赖识别在跨表场景下非常保守,它不会去分析 JOIN 条件所形成的推导关系。实际测试中,d.dept_name很容易被标记为“没有函数依赖”,然后抛 1055。
解决办法也简单,把外键关联涉及的非分组字段也并列进 GROUP BY:
SELECT e.id, e.name, d.dept_name FROM emp e JOIN dept d ON e.dept_id = d.id GROUP BY e.id, e.name, d.dept_name;或者用聚合函数包一层,比如MAX(d.dept_name)——因为一个员工对应的部门只有一个,所以 MAX 取出来依然准确。最稳妥的还是子查询或窗口函数改写,后面会详细展开。
3.3 聚合函数为什么能成为“保命符”
这就是为什么报错解决方案里最常见的就是“加一层 MAX/MIN/ANY_VALUE”。聚合函数的作用不是“挑一个值”,而是把一组值收敛成一个确定结果。
比如:
SELECT user_id, MAX(amount), MAX(pay_time) FROM sales GROUP BY user_id;每组的MAX(amount)语义明确,数据库知道怎么算。另外还有个特殊的ANY_VALUE(),它的语义是“你随便给我一个值,我不在意是哪一行”。这个函数专门为“字段确实和分组无关,但业务不需要精确取值”的场景设计。注意,ANY_VALUE()不是说数据库随机给值,而是告诉 MySQL“由执行计划决定即可,我不会依赖它做业务判断”,所以它能绕过 1055。
不过我要提醒一句:能用MAX/MIN表达清楚意图就别用ANY_VALUE糊弄。看到 ANY_VALUE 的地方,通常意味着查询本身有语义歧义。
4. 被 ONLY_FULL_GROUP_BY 拒绝的查询,到底怎么改
报错出现后最紧急的问题是“怎么改”。这里给出几条从简到繁的处理路径,建议按顺序尝试。
4.1 先判断业务到底想要什么
别一上来就改 SQL。先问一句:这个分组查询,最终想拿什么?
- 想拿每个分组的最新/最大/最小记录?改子查询或窗口函数。
- 想拿每个分组某个字段的聚合值?直接用聚合函数。
- 想拿“分组后任意一行”就行?用 ANY_VALUE。
- 想的是“查出同一组里所有行”,但错误套了 GROUP BY?那就把表 JOIN 回聚合结果,别滥用分组。
判断清楚意图,改写方向自然就有了。
4.2 最常用的改写方案
第一个方案是聚合收敛法。比如按用户统计“总金额”和“最大一笔金额”:
SELECT user_id, SUM(amount) AS total_amount, MAX(amount) AS max_amount FROM sales GROUP BY user_id;如果 SELECT 列表里每一个非分组字段都被聚合函数包住,这条语句百分百过检。
第二个方案是“先取目标 ID,再反过来 JOIN”。经典案例:每个用户最近一笔支付记录。错误写法是:
SELECT user_id, amount, pay_time FROM sales GROUP BY user_id HAVING pay_time = MAX(pay_time);这个写法在宽松模式也不严谨,严格模式下直接挂。正确写法是:
SELECT s.user_id, s.amount, s.pay_time FROM sales s JOIN ( SELECT user_id, MAX(pay_time) AS max_time FROM sales GROUP BY user_id ) t ON s.user_id = t.user_id AND s.pay_time = t.max_time;子查询先精确算出每个用户的max_time,再拿这个时间点去原表匹配整行记录。这样逻辑完全明确,任何版本的 MySQL 都能稳定执行。
第三个方案是窗口函数。如果你已经在 MySQL 8.0,窗口函数表达“每组取一条”更直观:
SELECT user_id, amount, pay_time FROM ( SELECT user_id, amount, pay_time, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY pay_time DESC ) AS rn FROM sales ) ranked WHERE rn = 1;注意:窗口函数不能直接用在 WHERE 里,所以通常包一层子查询再过滤rn = 1。这比子查询 + JOIN 更简洁,但也要求数据库必须是 8.0。
第四个方案是GROUP CONCAT+ 子查询。如果只是想展示“分组内的所有手机号、所有订单号”,就别用裸列,可以直接:
SELECT user_id, GROUP_CONCAT(amount) AS all_amounts FROM sales GROUP BY user_id;这是合规的,因为GROUP_CONCAT也是聚合函数。
4.3 升级存量项目时,建议这么挨个排查
存量项目报 1055 之后,最怕的是一个一个查。实际操作时,我习惯分成四步:
第一步,先看当前sql_mode到底开了什么:
SELECT @@GLOBAL.sql_mode; SELECT @@SESSION.sql_mode;第二步,去性能表里捞所有曾经报过 ONLY_FULL_GROUP_BY 的语句。MySQL 8.0 可以在performance_schema.events_statements_history_long里按错误信息过滤:
SELECT SQL_TEXT, MESSAGE_TEXT FROM performance_schema.events_statements_history_long WHERE MESSAGE_TEXT LIKE '%ONLY_FULL_GROUP_BY%' LIMIT 50;如果历史记录已经被清空,也可以开慢查询日志,把long_query_time临时设成 0,集中跑一天业务,再找出包含GROUP BY的语句人工判断:
SET GLOBAL long_query_time = 0; SET GLOBAL slow_query_log = ON;第三步,按“业务意图”批量导出候选 SQL,逐个套用上面四种改写方案。建议先用聚合函数快速解决“能明确聚合”的 70%,剩下的“每组取一条”再走子查询或窗口函数。
第四步,回归验证。分组查询改写后,对比改写前后的结果集规模和关键业务字段,确认无误再上线。这一步不能省,尤其涉及金额、状态这类敏感数据。
我也理解有些场景短期内确实没法彻底改完,比如第三方插件产生的 SQL 不在自己代码库里。这时候可以暂时把ONLY_FULL_GROUP_BY从sql_mode去掉作为过渡,但必须控制范围:只在本次会话里调整,而不是全局永久关闭。
SET SESSION sql_mode = (SELECT REPLACE(@@sql_mode, 'ONLY_FULL_GROUP_BY', ''));如果要写进配置文件,my.cnf的[mysqld]段里设置:
[mysqld] sql_mode = "STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION"设定一个整改排期,排期结束前必须把这条配置恢复原状。我见过有人图省事把ONLY_FULL_GROUP_BY永久移除,结果半年后新来的实习生理所当然地写模糊分组,线上出现金额错位问题,最后查根因查到配置上,白白背了一口锅。
4.4 关于“设置持久化”的正确姿势
如果你确实因为迁移窗口期需要临时放宽,MySQL 8.0 提供了SET PERSIST,它会把配置写入mysqld-auto.cnf,重启后依然生效。但注意,持久化开启容易、关闭容易忘记。我通常建议只在会话级放宽,配合明确恢复时间的监控任务,给自己上一道保险。
5. 常见 1055 报错场景和快速处理速查
不同写法导致的 1055 缓解方式略有差别,我把工作中最常见的几类整理成一张速查表,可以直接对照处理。
| 典型写法 | 报错原因 | 推荐处理 |
|---|---|---|
SELECT user_id, amount, MAX(pay_time) ... GROUP BY user_id | amount未分组、未聚合,且不存在函数依赖 | 明确意图后改为MAX(amount),或子查询取最新一条 |
SELECT e.*, d.dept_name ... JOIN ... GROUP BY e.id | 跨表选择了关联表的列,优化器不识别跨表函数依赖 | 把d.dept_name也加入 GROUP BY,或改窗口函数/子查询 |
SELECT col1, col2 FROM t GROUP BY col1 | SELECT 里有裸列col2 | 若只是随便展示,用ANY_VALUE(col2);若想要准确值,改成子查询 |
SELECT * FROM t GROUP BY id(id 非主键) | *展开后包含大量非函数依赖列 | 显式列出字段;按主键分组时仅当所有列由主键决定才合规 |
SELECT id, name FROM t GROUP BY id(id 为主键) | 理论上合规,但如果name可重复且含可变内容可能仍报错 | 给id加主键/唯一约束;确保函数依赖关系被 MySQL 识别 |
HAVING里引用裸列 | HAVING 过滤条件里的列不满足分组语义 | 改用聚合表达式,或先子查询再 WHERE |
这里多说一句,SELECT *和 GROUP BY 的组合是最容易出问题的。*会把整行所有列展开,其中只要有一列不满足函数依赖就会报错。即使按主键分组,如果表结构中有被 JOIN 进来的虚拟列或表达式列,照样可能触发 1055。所以涉及 GROUP BY 的语句,从一开始就不要用*。
6. 还有一个容易忽略的细节:ORDER BY 里的裸列
很多人只会盯 SELECT 列表,忽略 ORDER BY。ONLY_FULL_GROUP_BY不仅约束 SELECT 列表,对 ORDER BY 里的非分组列同样敏感。例如:
SELECT user_id, COUNT(*) FROM sales GROUP BY user_id ORDER BY amount DESC;这个amount在 ORDER BY 里没有包聚合函数,也没有出现在 GROUP BY 中,严格模式下同样报 1055。道理和 SELECT 列表一模一样:分组结果里一个用户对应多笔金额,你到底按哪个金额排序?
解决办法要么改成ORDER BY MAX(amount) DESC,要么把排序字段和排序逻辑想清楚后再改写。
这类问题之所以隐蔽,是因为很多人在开发环境里sql_mode被改过、或者 MySQL 版本较旧,执行正常,一上生产就炸。所以我一直强调:开发环境和生产环境的sql_mode一定要保持一致。哪怕你自己习惯宽松模式,也要让 CI、测试、预发布都跑同样的配置,不然这种“环境差异导致的语义错位”会非常难查。
7. 我在实际处理这类问题时的最后两点个人经验
这个内容做下来,给我最深的感受是:ONLY_FULL_GROUP_BY严格化不是 MySQL 在“找茬”,它是在逼你说清楚“你到底想要什么”。过去五年里我处理过的生产事故里,因为宽松分组导致的“假正确”数据问题,比因为死锁、慢查询导致的问题更隐蔽、更难复盘。慢查询至少能看到耗时,错误数据往往要业务方先发现异常,再反推 SQL 逻辑,成本高得多。
第二点建议你从今天起就做:无论当前数据库是否开启了 ONLY_FULL_GROUP_BY,写新的 GROUP BY 查询时都按严格模式的标准来写。这个习惯成本极低,收益却很大。等到哪一天你被迫升级 MySQL,或者把代码迁移到另一个数据库,你会感谢自己当初没有依赖“碰巧返回第一行”的行为。
最后送一个小技巧:排查完一批 1055 报错后,把改写前后的语句收进一个CHANGELOG.sql,标注“为什么改写”。以后团队里再有人写出同样的问题查询,直接把这份文档丢给他。数据库配置和版本会一直变,但“分组查询里禁止出现语义不明确的裸列”这条原则不会变。