1. 这个报错的真实含义:先搞懂 only_full_group_by 是什么
很多人第一次见到this is incompatible with sql_mode=only_full_group_by时,第一反应是去百度关键词,复制一串SET sql_mode = ''或SET GLOBAL sql_mode = '',执行完发现当前会话好了,重启 MySQL 又原形毕露。还有一部分人直接把 MySQL 配置文件里的sql_mode删掉,结果过段时间别的同事一跑 SQL 又炸了。这个错误之所以难搞,是因为它背后涉及 MySQL 的 SQL 模式设计,以及 GROUP BY 语义的变化,而不是简单的"某个参数坏了"。
先解释only_full_group_by到底是什么。它是 MySQL 5.7 开始默认启用的一个 SQL 模式。在这个模式下,SELECT 出来的列必须满足以下条件之一:被 GROUP BY 明确包含;或者是聚合函数(SUM、COUNT、MAX、MIN 等)的参数;或者是在功能上依赖于 GROUP BY 列。听起来有点绕,我用一个生活化的例子说明。
假设你有一个订单表,想按"城市"分组统计每个城市的订单总额:
SELECT city, SUM(amount) FROM orders GROUP BY city;这个没问题,city在 GROUP BY 里,SUM(amount)是聚合函数。但你如果想顺便查一下这个城市里某个客户的手机号:
SELECT city, customer_phone, SUM(amount) FROM orders GROUP BY city;这段 SQL 在 MySQL 5.7 之前是可以执行的,结果中customer_phone是每组里"碰巧"取到的某一行的值,具体是哪一行完全由 MySQL 内部执行计划决定,不确定也不可控。在only_full_group_by模式下,MySQL 直接拒绝这种语义不确定的查询,告诉你customer_phone不在 GROUP BY 子句中,与only_full_group_by不兼容。
所以这个报错的本质其实是 MySQL 在强制执行一种更严谨的 SQL 标准,防止你拿到一个"随机"的结果自己还不知道。很多从 MySQL 5.6 或更早版本升级上来的项目,旧代码里大量使用"不完全 GROUP BY"写法,一升级就全网报错,这就是这个报错在社区里如此高频的根本原因。
这里我先把核心观点说清楚:这个报错不是 bug,是 MySQL 在帮你纠错。但现实情况是,很多业务代码的历史包袱很重,改 SQL 的成本可能很高,或者某些查询范式在业务上确实能接受"取其中一行"的语义。这时候就需要区分场景来制定解决方案,而不是一刀切关掉模式或者一股脑改代码。
接下来我按"先理解、再选型、后实操"的顺序,把这几年实际项目中处理这个报错的经验完整写出来。
2. 为什么 MySQL 5.7 之后默认开启这个模式:SQL 标准的回归
要真正理解解决思路,你得知道这个模式是怎么来的,以及它和 MySQL 的发展历史之间有什么关联。
2.1 从 MySQL 5.6 到 5.7 的语义收紧
MySQL 5.6 及更早版本中,GROUP BY的处理非常宽松。你甚至可以把所有非聚合列都塞进 SELECT 列表,MySQL 不会报错,只是行为是"随机选择"该分组中的某一行。对于排序后的分组查询,很多人还利用这个特性实现"取每组最新一条记录",比如:
SELECT * FROM ( SELECT * FROM logs ORDER BY create_time DESC ) t GROUP BY user_id;这个写法在 5.6 里能"碰巧"拿到每个用户最新的日志,实际上依赖的是临时表的输出顺序,非常脆弱。5.7 开启only_full_group_by之后,这种写法直接报错。官方给出的理由是"不符合 SQL92 / SQL99 标准",因为 SQL 标准要求 SELECT 中出现的非聚合列必须出现在 GROUP BY 中。
这背后其实是一个大的方向:MySQL 从 5.7 开始,包括后来的 8.0,一直在向标准 SQL 靠拢,同时也在收拾历史上积累的"太随意"的语法行为。only_full_group_by只是其中一项,其它还包括默认字符集从 latin1 改为 utf8mb4、密码认证插件的变化等。所以它在升级中成为高频报错,不奇怪。
2.2 这个报错影响哪些查询
不是所有 GROUP BY 查询都会触发。只有 SELECT 列表、HAVING 条件、ORDER BY 列表中出现了"既不在 GROUP BY 中、又不是聚合函数"的列时,才会报错。我整理了一份常见触发场景对比:
| 场景 | 示例 SQL | 是否触发报错 |
|---|---|---|
| 聚合统计 | SELECT city, COUNT(*) FROM t GROUP BY city | 否 |
| 多列分组,SELECT 与 GROUP BY 完全一致 | SELECT a, b, SUM(c) FROM t GROUP BY a, b | 否 |
| 非聚合列不在 GROUP BY 中 | SELECT a, b, SUM(c) FROM t GROUP BY a | 是 |
| 用了 ANY_VALUE 包装 | SELECT a, ANY_VALUE(b), SUM(c) FROM t GROUP BY a | 否 |
| 子查询先排序再分组取首行 | SELECT * FROM (SELECT * FROM t ORDER BY id DESC) x GROUP BY uid | 是 |
注意第二行的场景:SELECT a, b, SUM(c) FROM t GROUP BY a, b完全合法,因为 b 在 GROUP BY 里。所以很多时候,你不需要改逻辑,只要把"业务上本来就该一起分组"的列补充进 GROUP BY 子句即可,这是成本最低的修法。
2.3 报错信息里隐藏的排查线索
MySQL 的报错信息其实把问题列名直接抛出来了。比如:
ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'mydb.t.customer_phone' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by注意Expression #2,它告诉你 SELECT 列表中的第二个表达式有问题。你可以顺着这个编号去核对 SELECT 子句中第几个列出的字段。后面那句'mydb.t.customer_phone'直接点名了数据库、表和列名。如果是一个长 SQL,这条信息能帮你快速定位,而不是肉眼一行行找。
另外,如果你用的是老版本客户端工具,比如 Navicat 的旧版本连 MySQL 5.7,执行某个查询时报这个错,那么大概率是连接账户的sql_mode里包含only_full_group_by,但服务器端没有。同一套代码在 A 机器上正常,在 B 机器上报错,十有八九是两台服务器的sql_mode配置不一致。这类"环境差异导致的问题",后面讲排查步骤时我会专门说。
3. 四种主流解决思路的对比与适用场景
正因为这个报错的根源不同,解决方案也分成了几个流派。我在实际项目中每种都试过,下面按"侵入性从低到高"的顺序梳理一遍,并给出它们各自的适用场景。
3.1 方案一:修改 SQL 语句本身(根治且最推荐)
把 SELECT 中非聚合的非分组列,要么加入 GROUP BY,要么用聚合函数包裹,要么用ANY_VALUE()明确表达"我就要这组里随便一行"。
具体来说:
- 如果业务上确实需要按 a 分组,同时展示 b,且 b 在同一个分组内值都相同,那把 b 加进 GROUP BY 即可,语义完全一致,性能几乎没有损失。
- 如果 b 在分组内值不同,但你只需要任一值,可以用
ANY_VALUE(b)。MySQL 5.7.5 起支持,8.0 里也保留了这个函数。 - 如果 b 在分组内不同,且你需要的是某个特定规则下的值,比如"每组里 id 最大的行的 b",那就要改写为子查询或窗口函数。
举一个实际例子。有个订单明细表order_items,结构大概是order_id, user_id, product_name, amount, created_at。业务需要按user_id分组,展示每个用户最近一次下单的产品名称。旧代码可能写成:
SELECT user_id, product_name, MAX(created_at) FROM order_items GROUP BY user_id;这个在 5.6 能跑,5.7 直接报错,因为product_name不是聚合列。正确写法是:
SELECT i.user_id, i.product_name, i.created_at FROM order_items i JOIN ( SELECT user_id, MAX(created_at) AS max_created_at FROM order_items GROUP BY user_id ) recent ON i.user_id = recent.user_id AND i.created_at = recent.max_created_at;如果你的 MySQL 版本是 8.0,可以更优雅地用窗口函数:
SELECT user_id, product_name, created_at FROM ( SELECT user_id, product_name, created_at, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn FROM order_items ) ranked WHERE rn = 1;改 SQL 的缺点是可能要动很多历史查询,优点是能真正理清业务语义,后续升级到 MySQL 8.0 也不会再有兼容问题。我的建议是:如果你的报错 SQL 数量不多(比如几十条以内),优先改 SQL,这是唯一不会留技术债的方案。
3.2 方案二:修改当前会话的 sql_mode(临时应急)
如果只是临时跑一次报表、执行一次性数据修复脚本,不想动任何代码和全局配置,可以在查询前执行:
SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';原理是:SESSION级别的修改只对当前连接有效,连接断开设定的值就恢复默认。这个sql_mode字符串是把 MySQL 5.7 默认值里的ONLY_FULL_GROUP_BY去掉后的完整列表。如果你懒得记忆,也可以直接:
SET SESSION sql_mode = '';把全部模式清空,但这会连带关闭严格模式(STRICT_TRANS_TABLES),可能导致之后插入超长字符串被截断而不是报错,掩盖数据质量问题。所以我不建议直接置空,宁可多写几个模式名,保留严格模式。
会话级修改适合的场景:你正在用mysql命令行或者某个 GUI 工具连数据库,临时手动执行几条查询,不想影响其他同事。这个方案的缺点是只要断开重连就失效,而且如果你用的是连接池,每个连接都需要设置一遍,很麻烦。所以它只适合应急。
3.3 方案三:修改全局 sql_mode(影响所有新连接)
如果你确认整个项目的 SQL 风格统一采用"宽松 GROUP BY"写法,而且短期內没有精力改代码,可以修改全局配置:
SET GLOBAL sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';执行后,新建立的连接会使用新的全局sql_mode,已存在的连接不受影响——这个细节经常被忽略。如果你是用 Navicat 直接执行的,那么当前 Navicat 的这个连接改完后并没有生效,你需要重新连接,或者再执行一次会话级设置。如果你用的是项目代码里的连接池,需要重启应用或等连接池回收连接后,才能让全部请求走到新模式。
全局修改解决的是"整个实例的默认行为"问题,适合你完全掌控的测试环境或者单机部署的小项目。但在生产环境,这种修改会影响所有库的所有用户,如果别的业务线依赖严格的 GROUP BY 校验来防止错误查询,那这就是破坏行为。所以在生产环境改全局之前,一定要先检查同一 MySQL 实例上是否还有其他业务的库,确认没有影响再动。
3.4 方案四:修改配置文件并重启(持久化,最稳妥的生产方案)
上面的SET GLOBAL只对当前运行中的 MySQL 实例有效,重启 MySQL 服务后会回到配置文件里的值。要永久生效,需要编辑配置文件。
Linux 下通常是/etc/my.cnf或/etc/mysql/my.cnf,也可能在/etc/mysql/conf.d/或/etc/mysql/mysql.conf.d/下。Windows 下是my.ini,一般在 MySQL 安装目录下。在[mysqld]段下添加或修改一行:
[mysqld] sql_mode = STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION注意不能写成sql-mode,MySQL 参数名用下划线。添加完成后,重启 MySQL 服务:
systemctl restart mysqld或老版本:
service mysql restart重启后,用下面的语句验证:
SELECT @@global.sql_mode; SELECT @@session.sql_mode;两个值应该都包含你设置的完整字符串,并且不要再出现ONLY_FULL_GROUP_BY。
配置文件方案和全局设置方案的区别在于:前者是SET GLOBAL的持久化版本,重启后仍然生效;后者重启会丢。所以生产环境真正要动默认行为,必须改配置文件。
这里我想多说一句:配置文件方案虽然稳妥,但它仍然会屏蔽only_full_group_by的校验,是一种"妥协"方案。如果公司有数据库规范,最好评估一下是否允许这么做。很多时候 DBA 会拒绝改全局配置,要求开发侧改 SQL。这个博弈是行业常态,你需要提前想好说辞。
4. 按环境类型分类的操作指南:Linux / Windows / Docker / 云数据库
同一个解决方案,在不同部署方式下的操作细节差异很大。我按实际工作中最常见的四种环境分别列一下操作步骤,避免你查了一堆资料还对不上自己的环境。
4.1 Linux 下通过配置文件修改(以 CentOS / Ubuntu 为例)
先确认 MySQL 配置文件的加载顺序。mysqld读取配置时,后面的配置会覆盖前面的同名项。一般顺序是/etc/my.cnf->/etc/mysql/my.cnf->/etc/mysql/conf.d/*.cnf->/etc/mysql/mysql.conf.d/*.cnf。如果你在/etc/my.cnf里写了,又被后面的文件覆盖,就会产生"我明明改了却没用"的假象。
建议在写之前先查看当前生效值:
mysql -uroot -p -e "SELECT @@global.sql_mode;"这里能看到当前实际的sql_mode值,你复制出来,把ONLY_FULL_GROUP_BY删掉,剩下的部分作为你要写入配置的字符串。
然后编辑配置文件:
vi /etc/my.cnf在[mysqld]下加入,如果没有[mysqld]段就新建一段。保存后重启:
systemctl restart mysqld重启完成后,再执行一次验证命令,确认ONLY_FULL_GROUP_BY不在列表内。如果是在 Ubuntu 上,MySQL 配置路径通常是/etc/mysql/mysql.conf.d/mysqld.cnf,方法同理。
4.2 Windows 下修改 my.ini
Windows 安装的 MySQL,配置文件一般在安装目录下。比如C:\Program Files\MySQL\MySQL Server 8.0\my.ini,也可能是C:\ProgramData\MySQL\MySQL Server 8.0\my.ini。快速定位方法:在 MySQL 命令行里执行:
SELECT @@basedir; SELECT @@datadir;basedir是安装目录,通常my.ini就放在安装目录或其父级。用记事本打开,找到[mysqld]段,添加或修改sql_mode行。然后以管理员身份打开服务管理器,找到 MySQL 服务,右键选择重启。
Windows 下还有一个注意点:如果你的服务名是MySQL80,可以用命令行:
net stop MySQL80 net start MySQL80验证方式与 Linux 一致。
4.3 Docker 容器里的 MySQL
用 Docker 跑 MySQL 的人越来越多,容器内修改配置文件有两种思路。
思路一:进入容器改配置并重启容器。先找到容器名或 ID:
docker ps | grep mysql然后进入容器:
docker exec -it mysql-container bash容器内配置文件路径通常是/etc/mysql/my.cnf或/etc/mysql/mysql.conf.d/mysqld.cnf。使用容器内的编辑器(可能没有 vi,需要先apt-get update && apt-get install -y vim)修改,然后退出容器,重启容器:
docker restart mysql-container这种方式的缺点是容器一旦被删除重建,配置就丢了,所以我推荐另一种思路:在 Docker 启动命令里挂载配置。
启动时用-v参数把宿主机上的配置文件挂载进容器:
docker run -d \ --name mysql \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=your_password \ -v /my/custom/my.cnf:/etc/mysql/conf.d/my.cnf \ -v /my/data/mysql:/var/lib/mysql \ mysql:8.0宿主机上的/my/custom/my.cnf里写好[mysqld]段及sql_mode配置。容器启动时,conf.d下的文件会被自动读取。这样配置被纳入版本管理,容器销毁重建也不怕。
如果你用的是docker-compose,还可以在 volumes 里加一行:
volumes: - /my/custom/my.cnf:/etc/mysql/conf.d/my.cnf注意挂载到/etc/mysql/conf.d/下的文件必须属于 root 且权限不能过高,否则 MySQL 可能忽略。如果遇到挂载后配置不生效的情况,可以先docker exec进容器查看/etc/mysql/conf.d/目录权限。
4.4 云数据库(RDS / 云 MySQL)的处理方式
云数据库一般不允许你登录物理机修改配置文件,也没有systemctl restart mysql这种操作。各云厂商的控制台通常会提供"参数设置"或"参数组"功能,在里面搜索sql_mode,点击编辑,把ONLY_FULL_GROUP_BY从值中移除,保存后提交参数组,实例通常会自动重启或按维护时间生效。
我在腾讯云、阿里云、AWS RDS 上都操作过,流程大同小异。关键点有两个:
- 云数据库的参数修改一般需要"提交"或"应用"才会真正推送,只保存不提交等于白改。
- RDS 实例的参数通常区分"静态参数"和"动态参数",
sql_mode可能是动态的,修改后不需要重启,也可能需要重启。提交后观察实例状态,如果显示"修改生效中",等一段时间再重连。
另外,云数据库可能有多个实例共享同一个参数组,改参数组会影响所有关联实例,修改前要留意当前参数组绑定了哪些实例,别误伤了其他环境。
5. 最容易被忽略的坑:连接池、只读账户、字符序与隐藏的 GROUP BY
陆陆续续处理过十几个项目这个报错之后,我发现很多问题不是出在"不会改配置",而是出在对 MySQL 运行机制的细节把握不到位。下面几个坑,每一个我都见过同事踩过。
5.1 连接池里老连接带着旧 sql_mode
最常见的一种情况:你执行了SET GLOBAL sql_mode = ...,然后程序里依然报错。原因很简单:应用连接池里的数据库连接是在修改前建立的,连接建立的那一刻sql_mode已经被确定为旧值,全局修改不会自动同步到已存在的会话级变量。
解决办法有几种:
- 重启应用,让连接池重新建立连接。
- 如果你的连接池支持配置连接初始化 SQL(比如 HikariCP 的
connectionInitSql,Druid 的connectionInitSqls),可以加入这样一行:
SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'这样每次连接新建时都会自动设置会话级sql_mode,即使服务器全局配置没改,应用也能绕开。这种方式的好处是只对当前应用生效,不影响数据库全局,适合那些不能改数据库配置、只能改应用代码的场景。
5.2 只读账户没权限执行 SET GLOBAL
如果你的数据库账号不是 root,而是一个只读或普通开发账号,执行SET GLOBAL sql_mode = ...时会收到ERROR 1227 (42000): Access denied。这种时候你需要向 DBA 申请执行权限,或者使用上面说的连接池初始化 SQL 方式,只改会话级。会话级设置只需要对应的SET SESSION权限,普通账号通常拥有自己的会话权限。
5.3 修改配置文件后没有重启,以及大小写敏感问题
配置文件修改后必须重启 MySQL 服务才能生效,这点很多人知道。但还有两个细节:
sql_mode值里的模式名是大小写不敏感的,strict_trans_tables和STRICT_TRANS_TABLES都行,只是惯例上全大写。- 配置文件中一个参数名如果出现多次,后面的会覆盖前面的。检查整个配置文件,看看
sql_mode是否在多个位置被设置,如果有,必须统一。
5.4 隐式 GROUP BY:DISTINCT 与聚合混用
有些报错的 SQL 里根本没有GROUP BY,为什么也报这个错?因为DISTINCT和聚合函数混用时,MySQL 内部会生成一个分组操作。例如:
SELECT DISTINCT department_id, AVG(salary) FROM employees;这个语句虽然没有 GROUP BY,但AVG(salary)是聚合函数,department_id是非聚合列。MySQL 会把department_id作为分组依据,此时要求所有非聚合列都必须出现在 SELECT 中。如果还有一个非聚合列没被 SELECT,比如你只写SELECT DISTINCT department_id, AVG(salary),其实没毛病,但如果写成:
SELECT department_id, employee_name, AVG(salary) FROM employees;没有 GROUP BY,却有聚合和非聚合混用,这在默认sql_mode下会报错,因为它等价于把所有行当成一个分组,而department_id、employee_name都不在分组里。这类问题要按聚合查询的规则去理解,而不是单纯靠"加一个 GROUP BY"解决。
5.5 ORDER BY 与 GROUP BY 的联动陷阱
only_full_group_by不只约束 SELECT 列表,还约束 ORDER BY 列表。看这个例子:
SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id ORDER BY order_date;order_date既不在 GROUP BY 里,又不是聚合函数,MySQL 会报错。正确的语义应该是按分组后的统计结果排序,比如ORDER BY cnt DESC,或者把order_date也加入 GROUP BY(如果业务上是按user_id, order_date分组的话)。这种错误尤其在写排行榜类的 SQL 时容易犯,因为人下意识想"按最近日期排"。
5.6 字符序与 collation 对分组结果的影响
还有一个冷门相关点:MySQL 的 GROUP BY 对字符串列的比较受排序规则(collation)影响。不同 collation 下,相同字符可能被视为同一组或不同组。比如utf8mb4_general_ci下'a'和'A'视为同一组,utf8mb4_bin下视为不同组。如果你改了sql_mode后,发现某些分组数据"变多了"或"变少了",先检查列的 collation,而不是怀疑配置改错。严格模式下,这种情况不会导致报错,但会让查询结果和预期不符。所以排查报错问题的同时,如果有条件,顺便核对一下业务结果集是否仍然符合预期。
6. 完整排查链路与验证方法
前面讲了不少原理和方案,下面按实际排查的顺序给一个可直接照着做的操作流程。这个流程是我平时处理问题时的思路,适合从"收到报错"到"确认修复"全流程使用。
6.1 第一步:确认当前的 sql_mode 值
先连接数据库,执行:
SELECT @@global.sql_mode; SELECT @@session.sql_mode;对比两行输出。如果全局和会话不一致,说明你的客户端或连接池在连接初始化时做了特殊设置,或者中间有人执行过SET SESSION。如果只有会话级包含ONLY_FULL_GROUP_BY,全局没有,那问题出在连接端的初始化脚本上,修改全局配置不会有任何作用。
6.2 第二步:定位报错 SQL 的具体语句
从应用日志中找出完整 SQL,清空参数值里的特殊字符,直接在命令行中复现。建议用EXPLAIN查看执行计划前先确认语句本身能跑通。如果 SQL 很长,可以用EXPLAIN配合FORMAT=JSON输出,有时候能更清晰地看到分组和排序的层级。
复现报错后,分析 SELECT 列表中哪些列是"非聚合且非分组"的,把它们找出来。
6.3 第三步:根据业务语义决定修法
这一步是核心决策点。逐一检查每个问题列:
- 这个列在同一个分组内值是否永远相同?如果是,把它加进 GROUP BY。
- 值不同但取哪一个都无所谓?用
ANY_VALUE()包裹。 - 值不同且必须取特定规则下的值?用子查询或窗口函数改写。
- 是否可以用聚合函数表达语义?如果它只需要展示每组某个最大值/最小值对应的字段,可以改写为 JOIN 子查询。
收集完所有问题,修改 SQL,在命令行重新执行,确认不再报错,且结果集与业务预期一致。
6.4 第四步:决定是否修改全局或配置
只有当你不打算修改 SQL、且确认全局宽松模式不会影响其它业务时,才去改全局或改配置文件。任何改动前,先备份现有sql_mode值:
SELECT @@global.sql_mode;把输出保存到本地,回滚时用。修改后按前面 4.x 的方式验证增量效果,并关注是否影响其他业务。
6.5 第五步:用测试用例做回归验证
不要只验证报错语句本身。我建议把项目里的 GROUP BY 类查询做成一个规则文件,整理几条典型的 SQL:
- 按单个字段分组查多条聚合统计;
- 按多个字段分组查明细;
- 查询分组内最新一条记录(旧写法和新写法);
- DISTINCT 与聚合函数混用。
将这些 SQL 在修改前后分别执行,对比结果数量是否一致,结果内容是否合理。如果之前用的"随机行"值在新模式下变为了确定的关联查询结果,数量总数可能不变但某些字段值不一样,这时候需要业务确认。
6.6 第六步:监控与告警
修复之后,建议在日志平台加一条关键字告警,比如监控incompatible with sql_mode=only_full_group_by在应用错误日志中出现的次数。这个告警能帮助你及早发现是否还有漏网的 SQL。我在公司内部做过类似统计,很多项目第一次修完之后,过了几天又冒出来几条新的报错,都是之前没覆盖到的报表任务或后台管理页面。这些任务平时跑得少,某个月度任务触发时才被发现。所以事后监控比事前排查更重要。
7. 实际案例复盘:一个生产环境的完整处理过程
分享一下我最近处理的一个真实案例,把所有环节串起来。某公司的订单报表模块在升级 MySQL 5.7 后频繁报错,核心报表 SQL 简化为:
SELECT date(create_time) AS day, order_type, channel, COUNT(*) AS order_cnt, SUM(amount) AS total_amount, pay_time FROM orders WHERE create_time >= '2024-01-01' GROUP BY date(create_time), order_type;报错列是channel和pay_time。channel在语义上是跟随order_type的独立字段,同一种 order_type 下可能有不同 channel,所以不能直接加进 GROUP BY,否则分组粒度会变细,订单数会分裂。而pay_time明显是想取"该分组内某个代表性的支付时间",但业务方说他们只是想看最近一条支付时间,并不需要精确到每个渠道。
处理过程是这样:
- 先确认
order_type和channel的关系。业务上order_type是主分类,channel是子维度。报表里只想按order_type分组,channel展示的是分组内最大的渠道,于是用ANY_VALUE(channel)包裹。 pay_time需要的是分组内最新的支付时间,用MAX(pay_time)直接表达。- 改后的 SQL:
SELECT date(create_time) AS day, order_type, ANY_VALUE(channel) AS channel, COUNT(*) AS order_cnt, SUM(amount) AS total_amount, MAX(pay_time) AS pay_time FROM orders WHERE create_time >= '2024-01-01' GROUP BY date(create_time), order_type;- 在测试环境跑一遍,结果集行数与旧版一致,
channel值在大多数分组内与旧版"随机值"相等,极少数不一致,业务确认可以接受。 - 把项目中其它类似 SQL 一并修改,形成了一版"兼容改造清单"。
- 同时通知 DBA,拒绝修改全局
sql_mode,因为该公司还有其它金融类业务依赖严格模式。最终通过应用连接池的初始化 SQL,单独为报表模块的连接设置了宽松sql_mode,形成了一种"双轨制":常规业务走严格模式,报表服务走宽松模式。
这种做法在不少公司是可行的:数据库层面保持默认严格模式,特定应用通过连接池初始化 SQL 改变自己连接的行为。既保证了全局安全,又解决了历史负担。不过要注意,使用连接池的初始化 SQL 后,该应用连接下所有 SQL 都会受到单会话模式影响,所以只适合你确信没有风险的应用模块。
8. MySQL 8.0 以及更高版本的新变化
前面说的基本都是 MySQL 5.7 的处理方式。如果你的项目已经升级到了 MySQL 8.0,有几个新情况需要注意。
8.1 默认 sql_mode 仍然包含 ONLY_FULL_GROUP_BY
MySQL 8.0 的默认sql_mode依然包含ONLY_FULL_GROUP_BY,所以 5.7 的报错在 8.0 上会原样出现。处理方法大同小异,只是有些版本把NO_AUTO_CREATE_USER这类古老模式移除了,默认模式字符串更短了。例如 MySQL 8.0 默认值是:
ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION去掉ONLY_FULL_GROUP_BY后剩余部分就是我前面推荐你使用的值。
8.2 窗口函数让"取每组某行"的改写更简单
MySQL 8.0 开始支持窗口函数,很多原本要写 JOIN 子查询的场景可以直接用ROW_NUMBER()、FIRST_VALUE()等函数解决。例如上一节中的"每个用户最近一条订单",8.0 写法:
SELECT user_id, product_name, create_time FROM ( SELECT user_id, product_name, create_time, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY create_time DESC) AS rn FROM order_items ) t WHERE rn = 1;这种写法语义清晰、性能也可控,强烈建议新代码一律使用窗口函数替代"分组取随机行"的旧套路。
8.3 新版本下不建议直接清空 sql_mode
网上有些文章建议直接执行SET GLOBAL sql_mode = '';来解决问题。这个做法在 5.7 时代就很危险,在 8.0 里更危险,因为 8.0 的严格模式对数据类型检查更严格,清空后可能导致很多隐式转换问题被掩盖。我见过一个案例:某人把sql_mode清空后,日期字段'0000-00-00'能插入数据,但后续查询时 JDBC 驱动因为日期无法转换直接抛异常。这种隐藏问题比报错本身更难排。所以除非你就是想临时绕过某些校验且完全知道后果,否则不要清空sql_mode。
8.4 云数据库的 MySQL 8.0 参数组注意事项
云厂商的 MySQL 8.0 参数组里,sql_mode的可选值可能与开源版不同。有些云厂商额外增加了自己的模式项,比如PAD_CHAR_TO_FULL_LENGTH,或者在控制台默认值里加入了NO_ENGINE_SUBSTITUTION的云版本变体。修改前尽量从控制台当前值复制出来,只删除ONLY_FULL_GROUP_BY,其余原样保留,降低意外风险。
9. 一些长期建议:如何让这个报错从此不再出现
回到开头说的——这个报错是 MySQL 在提示你的 SQL 不符合标准语义。所以长期最优解不是出了一次问题改一次配置,而是从团队规范和代码审查层面根治。
第一,新代码的 SQL 审查加一条规则:凡出现 GROUP BY 的语句,SELECT 列表中出现的非聚合列必须全部出现在 GROUP BY 中。这条规则写进团队的数据库设计规范文档里,做代码评审时重点检查。如果确实需要"组内某个字段的特定值",明确要求写出子查询或窗口函数,禁止依赖 ANY_VALUE 隐藏语义。
第二,监控优先。所有项目在日志平台配置incompatible with sql_mode=only_full_group_by的关键字告警,一旦有报错立即推送。I 见过太多项目是报表跑完才发现数据不对,而 SQL 日志里早就躺着被拦截的报错没人看。
第三,升级评估时把sql_mode变化列为一等检查项。从 5.6 升 5.7、从 5.7 升 8.0,都要先跑一遍所有业务 SQL 的静态扫描——现在很多数据库审核工具(如 Yearning、Archery 或者开源 SQL 审核平台)都支持规则导入,你可以把only_full_group_by规则写进审核引擎,在开发阶段就拦住问题 SQL。
第四,理解成本与收益。如果团队里有新人经常写这种 SQL,与其反复说教,不如建立一个小项目,把常见的不规范写法编译为一个"正确写法对照表",放在团队 Wiki 里。例如:
| 错误写法 | 正确写法 |
|---|---|
SELECT a, b, SUM(c) FROM t GROUP BY a | SELECT a, ANY_VALUE(b), SUM(c) FROM t GROUP BY a(若 b 无特定要求) |
SELECT a, b, SUM(c) FROM t GROUP BY a | SELECT a, b, SUM(c) FROM t GROUP BY a, b(若 b 在组内确定) |
SELECT * FROM t GROUP BY uid | 改写为窗口函数 + 子查询 |
这类对照表比大段文字更能帮助团队达成一致。
另外我想强调一下,在接手老系统时,不要在没有任何评估的情况下直接改sql_mode配置。虽然这个操作能立刻消除报错,但它会让隐藏的 SQL 语义问题继续存活在代码里,未来某天数据结果错了,排查成本会更高。我的习惯是:能改 SQL 就改 SQL,改不了就谈清楚业务接受度,最后才动全局配置。如果项目真的进入了维护模式、人员流失严重、改动成本极高,那么用连接池初始化 SQL 的方式做会话级"定点宽松",是相对可控的折衷方案。
最后再分享一个小技巧:即使你决定不修改全局配置,也可以先在测试环境把sql_mode设置为完全相同的当前生产值,然后跑一遍全量回归测试,找出所有受影响的 SQL。这个"模拟生产"的做法能帮助你在开发阶段预判兼容问题。我曾经用它在一个即将上线的项目里提前抓出 23 条有问题的 SQL,全部在测试阶段改完,上线后零报错。那条规则也一直保留在我们的发布检查清单里,每次版本发布前都会重新执行一次。