ARTICLE DETAIL

资讯详情

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

MySQL 8.0升级:ONLY_FULL_GROUP_BY规则与SQL改写实战

MySQL 8.0升级:ONLY_FULL_GROUP_BY规则与SQL改写实战 最近在帮一个项目做 MySQL 5.7 到 8.0 的升级测试环境一跑好几个老报表接口直接报 ERROR 1055开发同学第一反应都是同一个问题能不能把sql_mode里的ONLY_FULL_GROUP_BY去掉我的回答是先别急着关模式先去读一下你的 SQL。因为 MySQL 默认在 5.7 之后把ONLY_FULL_GROUP_BY打开并不是为了刁难谁而是 SQL 标准本来就要求GROUP BY查询按照“分组语义”来写。以前 MySQL 在这块太宽松升级后不过是把欠下的规则课补上了。这篇文章就把ONLY_FULL_GROUP_BY严格化这件事讲透它到底拦截了什么、为什么这么设计、升级之后你该怎么改写自己的 SQL以及哪些情况下允许临时关闭。1. ERROR 1055 到底在拦什么1.1 一条曾经“能跑”的 SQL为什么升级后不能跑先看一个最典型的报错场景。假设有一张员工表结构大概是这样的CREATE TABLE employee ( id INT PRIMARY KEY, name VARCHAR(50), dept_id INT, age INT, salary DECIMAL(10,2) );你以前可能在 MySQL 5.6 或更早版本写过这种查询SELECT name, dept_id, MAX(salary) FROM employee GROUP BY dept_id;在旧版本里这条 SQL 能正常执行返回每个部门工资最高那条记录的name看起来也没出过错。但到了 MySQL 5.7 及以上这行 SQL 会直接报 1055ERROR 1055 (42000): Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column employee.name which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_modeonly_full_group_by翻译成人话你按dept_id分组分完组之后每个组里通常会有多个不同的name。那这些name到底输出哪一个数据库没法替你决定于是直接拒绝执行。这也正是ONLY_FULL_GROUP_BY的核心语义分组查询中出现在 SELECT 列表里的列要么是分组列本身要么是聚合函数包裹的列要么在功能上完全依赖于分组列。超出这个范围的属于语义不明确的查询MySQL 直接判违法。1.2 用生活场景理解分组语义把这个问题放到生活里就特别容易理解。你按“班级”把人分组然后问“这个班的平均身高是多少”这个没问题平均身高在组内是唯一的、可确定的。但如果你问“这个班的人叫什么名字”这就没法回答了——一个班有几十个名字到底输出谁的这时候数据库不能瞎猜只能告诉你你这个问法不合法。所以ONLY_FULL_GROUP_BY拦截的不是“复杂查询”而是“语义不明确的查询”。你以为数据库会“智能地”帮你挑一条记录实际上它并不保证挑哪条不同版本、不同索引、不同数据分布得到的结果都可能不一样。关掉模式只是让旧写法不报错并不是让查询语义变正确。2. ONLY_FULL_GROUP_BY 的五条执行规则ONLY_FULL_GROUP_BY并不是一个简单规则MySQL 在执行时会按几个层次检查查询。我把实际会被拦截的点拆开对应到你能直接写出来的 SQL 形态。2.1 SELECT 列表中的非聚合列必须出现在 GROUP BY 中这是最基本的规则。如果 SELECT 里的某个列既没在 GROUP BY 里也没被聚合函数包住就会被拦截。-- 违法name 不在 GROUP BY 中也不是聚合列 SELECT dept_id, name, MAX(salary) FROM employee GROUP BY dept_id; -- 合法两个列都在 GROUP BY 中 SELECT dept_id, name, MAX(salary) FROM employee GROUP BY dept_id, name;这里容易踩的坑是“我明明把name加进 GROUP BY 了为什么结果变多了”因为GROUP BY dept_id, name的分组粒度已经变成“同一部门的同一姓名”为一组返回行数会变多和你原本想拿“每个部门一条记录”的意图完全不一样。所以单纯往 GROUP BY 后面追加列很多时候并不能解决问题反而会把结果集改得面目全非。2.2 聚合函数里的列不受限制SUM(salary)、MAX(age)、AVG(salary)、COUNT(*)这些聚合运算在分组之后能计算出一个确定的值所以聚合参数里的列不需要出现在 GROUP BY 里这是合法的。SELECT dept_id, AVG(salary), COUNT(*) FROM employee GROUP BY dept_id;注意一个细节聚合函数的参数本身可以很复杂甚至可以是表达式。比如SELECT dept_id, MAX(salary * 12 bonus) FROM employee GROUP BY dept_id;只要整个表达式处于聚合函数内部就不受ONLY_FULL_GROUP_BY约束。2.3 功能依赖列可以在 SELECT 中直接出现如果GROUP BY里包含某张表的主键那么这张表的其他列会被认为是“功能依赖于主键”的可以直接出现在 SELECT 列表中。这是 MySQL 5.7.5 之后逐步增强的一个规则到了 8.0.13 之后函数依赖检测能力已经比较完善。举个例子SELECT e.id, e.name, MAX(o.amount) FROM employee e JOIN order o ON o.emp_id e.id GROUP BY e.id;哪怕e.name没有出现在 GROUP BY 里也不会报错。因为e.id是主键主键一旦确定这一行的e.name就是唯一确定的顶多若干行数据重复但name的表现是一致的。这个特性在日常开发里非常实用。以前一个常见的写法是“把没用到的列也加到 GROUP BY 里”现在不用了只要你的分组列是主键同表其他列可以自由 SELECT。2.4 确定性表达式可以出现在 SELECT 列表MySQL 允许 SELECT 列表中出现“由分组列计算得到的确定性表达式”。SELECT dept_id, dept_id 100 AS dept_code, MAX(salary) FROM employee GROUP BY dept_id;这里dept_id 100的结果对于每个组来说是固定的不会引入歧义因此不会触发 1055。再比如字符串拼接SELECT dept_id, CONCAT(部门-, dept_id), MAX(salary) FROM employee GROUP BY dept_id;也是合法的。判断标准很简单这个表达式是不是仅由分组列、常量、以及不会改变行数语义的运算组成。2.5 HAVING 和 ORDER BY 也受同套规则约束很多人只检查 SELECT 列表忘了 HAVING 和 ORDER BY 里的“裸列”结果同样被拦截。-- 违法HAVING 中 name 非分组列也非聚合列 SELECT dept_id, MAX(salary) FROM employee GROUP BY dept_id HAVING name 张三;这种写法在旧版本里能被执行但语义很迷HAVING name 张三到底想表达“组内的张三”还是“存在一个叫张三的记录”严格模式下直接被拒。还有 ORDER BY-- MySQL 8.0 中违法 SELECT dept_id, MAX(salary) FROM employee GROUP BY dept_id ORDER BY name;这条在 MySQL 5.7 里可能不报错因为 5.7 对 ORDER BY 的检查相对宽松但 8.0 已补齐这块默认也按严格语义拦截。升级 8.0 之后由排序字段引起的 1055 会很明显排查时要多看一眼。3. MySQL 为什么要把这个模式“严格化”3.1 不是新规则是对 SQL 标准的归位要理解 MySQL 为什么会选择严格化得先把这个功能的背景捋清楚。ONLY_FULL_GROUP_BY本身不是 MySQL 的发明它出自 SQL 标准中关于GROUP BY的规范。标准要求分组之后SELECT 列表中所有非聚合列必须出现在 GROUP BY 子句中否则查询不合法。早期 MySQL 为了降低使用门槛、兼容部分开发者的写法默认没启用这条规则。也就是说你随手写的“分组查非聚合列”MySQL 会挑一行数据返回但不保证是哪一行。这种“宽松”和标准的差距积累了大量实际业务里的隐患同一套 SQL 在测试库和线上库返回不同数据MySQL 版本升级后结果变化依赖索引顺序或物理存储顺序的隐式行为随着执行计划的改变而改变。所以 MySQL 5.7 开始将ONLY_FULL_GROUP_BY纳入默认sql_mode本质上是把“宽松模式”关闭回归到标准语义。3.2 宽松模式下到底埋了什么雷我举个例子。假设有张订单表某次查询是SELECT customer_id, order_id, SUM(amount) FROM orders GROUP BY customer_id;旧版本会返回每个客户某个“任意订单号”这个订单号也许恰好是最新的也许是第一条取决于索引扫描顺序。看起来没毛病实际上有几个隐患第一不可复现。同一个查询今天返回order_id 100明天可能返回order_id 105因为执行计划变了。第二错误的决策依赖。有团队可能做着做着发现“哦返回的是最新订单”然后写进业务逻辑里一旦升级或换库订单号的选择逻辑失效业务直接出问题。第三测试失效。测试环境数据量小可能每次都能稳定返回同一条到了生产环境就翻车。我在实际项目中见过一个案例报表系统用这种模糊写法取“每个用户最近的一笔订单 ID”开发环境一直正常上线后有一批用户的数据对不上排查两天才定位到是ONLY_FULL_GROUP_BY关闭状态下“碰巧”选错了行。这类问题严格化之后从根上就被挡掉了。3.3 严格化之后查询语义更可控启用ONLY_FULL_GROUP_BY后数据库保证每条查询的语义是确定的。你能确定的只有分组列的值聚合函数的结果功能依赖列的值。也就是说哪怕数据库将来换优化器、换索引、数据量增长查询结果都不会因为这些变化而出现“随机”差异。对于数据分析、对账、报表这类对一致性要求极高的场景这一点非常重要。不少开发抱怨严格化“挡业务”但仔细看拦截出来的 SQL绝大多数确实写得不严谨。MySQL 帮你把这种潜在风险提前暴露在开发阶段而不是等上线后变成线上事故代价已经很小了。4. 升级 MySQL 8.0 后的 SQL 兼容方案如果你的项目已经从 5.7 升到 8.0或者正准备升级面对 1055 报错正确的处理方式是先分类再对症处理。下面这几条路径我按推荐程度排列。4.1 优先改写 SQL子查询拿到精确值大部分 1055 报错都可以用子查询改写让“选哪一条记录”的逻辑变得明确。比如“每个部门里年龄最大的人”-- 旧写法仅 5.6 及以下能跑 SELECT name, dept_id, MAX(age) FROM employee GROUP BY dept_id;改成SELECT e.name, e.dept_id, e.age FROM employee e JOIN ( SELECT dept_id, MAX(age) AS max_age FROM employee GROUP BY dept_id ) t ON e.dept_id t.dept_id AND e.age t.max_age;这种写法的好处是查询意图非常明确先拿每个部门最大年龄再回表取完整行。代价是子查询可能要临时运算但语义可靠。另一种写法是使用窗口函数这个在 8.0 里非常顺手SELECT name, dept_id, age FROM ( SELECT name, dept_id, age, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY age DESC) AS rn FROM employee ) t WHERE rn 1;窗口函数方案在可读性上比子查询更好性能上一般也不差建议优先考虑。4.2 对“取任意值”场景使用 ANY_VALUE有些业务确实只是想在分组结果里“拿一个代表值”比如按部门分组取部门下任意一个员工的手机号用于联系。这个需求严格模式下可以通过ANY_VALUE()实现SELECT dept_id, ANY_VALUE(name), MAX(salary) FROM employee GROUP BY dept_id;ANY_VALUE()的含义就是“随便选一个我不关心具体是哪条”。它让数据库不报错但你需要清楚这个值的选取不保证确定性同一查询不同执行计划可能返回不同结果。我建议ANY_VALUE()只用于两类场景一是结果列本身不会被业务逻辑消费二是不在意具体值比如只是拼个逗号分隔的字符串。如果业务依赖这个值判断逻辑就不要用ANY_VALUE()。4.3 利用函数依赖减少不必要的 GROUP BY 列如果你在查询中 GROUP BY 的是主键那么同表的其他字段可以直接放 SELECT 里不需要全部追加到 GROUP BY。这能大幅简化 SQL也规避“分组粒度变细”的问题。SELECT e.id, e.name, e.email, SUM(o.amount) FROM employee e JOIN order o ON o.emp_id e.id GROUP BY e.id;这里e.name、e.email依赖主键e.id不会触发 1055。MySQL 8.0.13 之后的函数依赖检测已经能识别这种关系如果你的版本低于 8.0.13可能仍要手动把这两个列加进 GROUP BY。还有一个小技巧功能依赖的识别不仅适用于单表主键连表时如果 JOIN 条件建立在外键/唯一键上也能被识别。但如果 JOIN 的是重复数据MySQL 不会冒险推断宁可报错。4.4 临时关闭模式的正确姿势如果历史 SQL 实在太多短期无法全部改写可以临时放宽模式。但要注意直接执行这一句是最常见的错误SET SESSION sql_mode ;这会把当前会话的sql_mode清空其他默认模式比如STRICT_TRANS_TABLES、NO_ZERO_DATE也一并被清掉可能引入新的数据写入异常。正确做法是先查当前值在保留其他模式的前提下只去掉ONLY_FULL_GROUP_BYmysql SELECT sql_mode;拿到结果后用“当前值去掉 ONLY_FULL_GROUP_BY”的形式设置。比如当前值是ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION那么执行SET SESSION sql_mode STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION;这只对当前会话生效不影响线上其他连接。如果确认要全局临时调整可以修改 MySQL 配置文件my.cnf或my.ini在[mysqld]下把sql_mode写全然后重启 MySQL。MySQL 8.0 还提供了持久化运行参数的能力不用改配置文件SET PERSIST sql_mode STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION;注意SET PERSIST会写入磁盘上的 mysqld-auto.cnf重启后仍生效。想恢复默认值可以用SET PERSIST sql_mode DEFAULT;我的建议是可以用临时关闭作为“让系统先跑起来”的过渡手段但一定要在迭代计划里给改写 SQL 留出时间窗口。长期关闭ONLY_FULL_GROUP_BY等于把以前 MySQL 5.6 时代的坑原封不动地带到 8.0 上风险会持续累积。5. 升级迁移中的实际问题排查5.1 用日志系统化定位 1055几百条历史查询里找 1055 报错时不要一条条看代码直接在 MySQL 错误日志里 grep 错误号或者让应用在捕获 1055 时打印完整的 SQL 语句。大部分情况下报错信息里的Expression #N会明确告诉你第几个字段有问题。拿到完整 SQL 后用EXPLAIN看分组列和执行计划基本就能确认是哪个查询需要改。这里有个小经验升级项目里报 1055 最多的是老报表接口、导出功能、还有后台管理列表的统计查询。这类 SQL 大多是多年前写好后一直没动过的优先覆盖。5.2 注意 GROUP BY 中出现隐式类型转换另一种隐蔽的 1055 不是字段选择问题而是类型转换导致的“依赖失效”。比如表里dept_id是字符串类型你在分组时写GROUP BY dept_id但在 SELECT 里写dept_id 100涉及类型转换时MySQL 的依赖推断可能不那么智能宁可报错也要让你明确改法。这种情况下先看字段本身的类型然后把条件里的常量转换成同类型或者直接使用CASTSELECT dept_id, MAX(salary) FROM employee GROUP BY dept_id;如果业务上需要按数值类型过滤就改成SELECT dept_id, MAX(salary) FROM employee WHERE dept_id 100 GROUP BY dept_id;这里的关键不是语法对错而是让 MySQL 的依赖检测“一眼看明白”你在按什么列分组。类型一旦有隐式转换检测路径可能走不通报错也会让人摸不着头脑。5.3 DISTINCT 与 GROUP BY 混用时的注意事项有些开发喜欢在分组查询外面再套一层DISTINCT比如SELECT DISTINCT dept_id, MAX(salary) FROM employee GROUP BY dept_id;这条 SQL 本身不报错但外层的DISTINCT基本是多余的。因为按部门分组后dept_id已经是唯一值再 DISTINCT 一遍既不改变结果也会让执行计划多做一轮去重运算。更需要注意的写法是SELECT dept_id, (SELECT name FROM employee e2 WHERE e2.dept_id employee.dept_id LIMIT 1) FROM employee GROUP BY dept_id;子查询里带了 LIMIT 1勉强能跑但性能堪忧而且每次返回哪一条也没有保证。如果确实要取具体一条老老实实按 4.1 的窗口函数或 JOIN 写法来。5.4 ORM 框架自动生成的 SQL 也要排查升级之后如果使用了 JPA、MyBatis 等 ORM 框架有些批量统计查询是框架自动生成的报 1055 时你往往看不到原始 SQL。快捷定位方式是在开发环境打开 SQL 日志把运行时生成的 SQL 抓出来后再套用前面几节的判断标准检查。尤其是 MyBatis 的foreach拼 IN 条件、JPA 的 Specification 动态查询很容易形成“SELECT 表和排序字段不一致”的情况。这类 SQL 在旧 MySQL 上可能一直没报错升级后集中爆发需要提前跑一遍回归。5.5 快速判断一条 SQL 会不会触犯 ONLY_FULL_GROUP_BY我把判断步骤写成一个清单排查时照着过一遍即可找出 SELECT 列表中的所有列和表达式。对每一列判断是否在 GROUP BY 子句中是否被聚合函数包裹是否功能依赖于 GROUP BY 中的列如果以上三者都不满足这条 SQL 在严格模式下必然报错。再检查 HAVING 和 ORDER BY同样的逻辑再走一遍。如果 SQL 里报了 1055先把第 2 步达到的结论写出来再改。这个方法 10 分钟内基本能定位一条 SQL 的问题哪怕字段再多也就是机械判断。6. 一些个人的实践心得做 MySQL 迁移这几年来我越来越觉得ONLY_FULL_GROUP_BY严格化是一件“当时难受、后面香”的事。刚碰到 1055 的时候很多团队会本能地想关模式我也干过这事。但后来复盘发现真正因为业务需求复杂到“必须关闭严格模式”才能实现的 SQL几乎没有绝大多数 1055 都是因为开发阶段没在意分组语义急匆匆地写出来了。其中有几次印象很深的教训一次是报表系统某个“用户最后登录城市”的统计旧 SQL 依赖数据库默认返回的第一条记录。运行一年后被新版本打断换了一版子查询改写后结果和业务方手工记录的数据对不上最后发现旧结果本身就是“碰运气”不是我们想要的正确结果。改用明确语义的 SQL 之后业务方反而确认数据更准了。还有一次是排障时发现ORDER BY里带了个非分组字段5.7 下没报错升级 8.0 后直接 1055。开发同事很困惑“我只是按订单金额排个序跟分组有什么关系”实际上分组查询的结果已经按组聚合排序字段必须要么是分组列要么是聚合结果否则这个“排序”的语义根本没法定义。所以如果你现在也被 1055 卡住我给的建议是先不改模式先把报错 SQL 一条条抓出来按本文第 2 节的规则梳理一遍再用第 4 节的方法改写。你会发现绝大部分 SQL 改完之后可读性反而变好了后面的同事维护起来也轻松。至于那些确实需要“取任意值”的地方用ANY_VALUE()显式声明意图比依赖数据库“随便选”要专业得多。最后分享一个迁移小技巧升级前可以在测试环境直接开启ONLY_FULL_GROUP_BY跑一遍全量自动化测试把 1055 收集成清单按模块分给对应负责人。先改 SQL再考虑模式能不改配置就不改配置。这样升级到 8.0 的过程会平滑很多线上也不会被一群“历史遗留”查询打得措手不及。
返回列表