说实话,让一个跑了近十年的老系统从 MySQL 5.5 升级到 MySQL 8,我一开始以为最难的会是数据迁移或者新硬件驱动不兼容。真正动手之后才发现,第一束火星是从 Hibernate 3 生成的 SQL 里冒出来的。标题里的“别名失效”这四个字,概括成一句话就是:同样的查询在 5.5 上跑得风生水起,到了 8.0 上,MySQL 要么把 Hibernate 拼出来的别名当空气,要么直接甩一句 Unknown column、Expression #N is not in GROUP BY clause 把请求打回。这不是某个字段写错了,而是老 ORM 遇上新数据库时的典型并发症。这篇文章我把从现象、根因到三层修复的完整过程写下来,正在做历史项目升级的朋友可以参考一下。
1. 升级之前,我预想的是“换个数据库而已”
1.1 这到底是什么样的项目
这个项目是我们内部跑了好多年的一个后台单体系统,技术栈属于标准的“十年前套装”:Spring 2.5 + Hibernate 3.2.5.GA,数据库是 MySQL 5.5,下面是几十张表,承载着报单、统计、审批这一堆业务。系统逻辑不复杂,但胜在“稳”,代码一动就有各种隐性问题,所以一直没人敢碰它。
这次升级的导火索很简单:MySQL 5.5 官方已经停止维护,安全补丁断供,同时机房准备换新硬件,新平台对老 MySQL 的支持越来越差。领导拍板:数据库版本必须往上升,但业务代码原则上不动,Hibernate 继续用 3.2,只把数据库物理版本、JDBC 驱动和连接配置换掉。
听着是不是觉得很轻松?我当时也是这么想的。结果从安装环境开始,坑就连着来了。
1.2 还没轮到业务,离线安装就先上了一课
生产环境在内网,没有外网 yum 源,所以要先把 MySQL 8 的 RPM 包准备好,再用离线方式装。这一步很多人会栽在依赖上,我先把操作流程和教训列出来:
- 准备阶段:在有外网的机器上,从 MySQL 官方 yum 仓库下载
mysql80-community-release和mysql-community-server/client的 RPM,注意要连同libaio、libncurses这些运行库一起带进内网。 - 安装阶段:内网机器上先
rpm -ivh mysql80-community-release*.rpm,再装mysql-community-*.rpm,顺序别反。 - 初始化阶段:MySQL 8 安装完成后不会自动初始化数据目录,必须手动跑
mysqld --initialize(随机 root 密码,记下来)或者--initialize-insecure(root 无密码,测试环境用方便)。我第一次就漏了这条,直接启动 mysqld 然后发现 datadir 是空的,服务起来又崩掉。
离线环境里还有个大坑:MySQL 8 的 RPM 安装完成后默认监听 3306,但防火墙策略还是按旧端口配的,升级完连不上,误以为服务没起来。后来排查半天,把端口放通后才恢复正常。
等环境真正跑起来了,我以为可以集中精力处理数据迁移了,结果应用一启动,Hibernate 3 给我贡献了一长串闻所未闻的报错。
2. Hibernate 3 的“别名”在 MySQL 8 眼中为什么废了
2.1 方言决定了三条 SQL 规矩
Hibernate 里的 Dialect 本质上是一套“生成 SQL 的模板协议”,它告诉 Hibernate 当前数据库支持什么语法、怎么分页、怎么拼接标识符、怎么处理数据类型。Hibernate 3.2 里的 MySQLDialect,目光所及的世界还停留在 MySQL 5.0/5.1 时代。
它不知道 MySQL 8 默认的 sql_mode 已经变成了严格模式,不知道 8.0 新增了哪些保留字,也不清楚派生表必须有显式别名这条硬性规定。于是 Hibernate 3 拼出来的 SQL 天生带着“宽松模式气质”,到了 MySQL 8 上就处处不兼容。
这种现象在升级中非常典型:老系统的 SQL 是“在旧规则下调教出来的”,换到新规则就成野孩子了。表面上看是别名失效,实际上是规则变了,而 Hibernate 3 还停在十年前的语法世界里。
2.2 严格模式生效后,ORDER BY 里的别名被拒绝
先看一个实际例子。Hibernate 3 对一条 HQL 查询生成的 SQL,大致长这样:
select this_.dept as y0_, sum(this_.amount) as y1_ from bill this_ group by this_.dept order by this_.create_time desc这条 SQL 在 MySQL 5.5 上完全没问题。但在 MySQL 8 上,只要 sql_mode 里带着ONLY_FULL_GROUP_BY,MySQL 直接报:
Expression #1 of ORDER BY clause is not in GROUP BY clause and contains nonaggregated column 'db.bill.create_time' which is not functionally dependent on columns in GROUP BY clause意思很直白:你在order by里引用的this_.create_time既不是分组字段,也不是聚合表达式,按 SQL 标准它根本不应该出现在排序里。MySQL 5.5 默认的 sql_mode 基本都是空字符串或者非常宽松,所以这种查询能混过去;MySQL 8 从 5.7 开始默认把ONLY_FULL_GROUP_BY关进笼子,Hibernate 3 这种老派写法立刻崩了。
这里有个容易被忽略的细节:表面上报错在 ORDER BY 的列名引用,并不是 Hibernate 生成的别名 y0_、y1_ 本身失效,而是“非分组列在分组查询里失去了合法性”。很多朋友看到报错里带 alias、column 这些词,就以为是别名映射配错了,其实根子在严格模式。
2.3 COUNT 子查询没有派生表别名
第二个让我印象深刻的报错是:
Every derived table must have its own alias中文翻译过来就是:每个派生表都必须有自己的别名。
MySQL 从 5.7 开始对派生表(也就是 from 后面的子查询)强制要求显式别名,8.0 继续保留这个规则。而 Hibernate 3 在处理一些带 group by 的分页 count 场景时,可能会把原先的查询包一层作为内层子查询。在我这个案例里,它拼出来的结构是没有给内层 select 加别名的:
select count(*) from ( select this_.id, this_.dept from bill this_ group by this_.dept )MySQL 8 直接拒绝执行,而 5.5 对此睁一只眼闭一只眼。于是 Hibernate 3 自认为“改名换姓”更高效的 count 子查询,到了 MySQL 8 成了非法 SQL。这个问题不是改 sql_mode 能解决的,必须从 SQL 本身动手。
2.4 一张叫 rank 的表,让整个别名体系都崩了
还有一种“别名失效”,纯粹是被 MySQL 8 的保留字列表变化波及的。MySQL 8 新增了一批保留字和关键字:rank、window、system、groups、member、values等。Hibernate 3 拼 SQL 时不会主动给表名和字段名加反引号,如果项目里恰好有以这些词命名的列,升级后就会出现各种诡异错位。
我们系统里就有一张统计表,里面恰好有个字段叫rank。MySQL 5.5 里它相安无事,到了 8.0,Hibernate 生成的查询直接变成语法级别的问题,刚开始还以为是 Hibernate 的别名前缀写错了,反复对照映射文件核对别名,最后才发现是保留字在作祟。所以排查时要先排除这种“名字本身撞车”的情况,否则你查别名配置查半天,方向从一开始就错了。
3. 排查血泪实录:从日志到复现
3.1 把 Hibernate 飞出去的话全抓回来
排查这种问题,第一步一定是把 Hibernate 真正发给 MySQL 的 SQL 完整抓出来。别用show_sql自己拼接出来的半吊子日志,它有时会省略参数占位符,导致你看到的 SQL 和实际执行的 SQL 对不上。
我通常这么做:
- 在
hibernate.cfg.xml里临时打开:
<property name="hibernate.show_sql">true</property> <property name="hibernate.format_sql">true</property> <property name="hibernate.use_sql_comments">true</property>- 同时在 MySQL 端开 general_log,抓 JDBC 实际收到的完整语句:
SET GLOBAL general_log = 'ON'; SET GLOBAL log_output = 'TABLE';后面可以直接查询mysql.general_log表,把 Hibernate 发出的每一条 SQL 连同时间都捞出来,再和 5.5 的日志对比。这一步非常关键,因为 Hibernate 3 在拼子查询、分页、count 时,生成的 SQL 和用户手写的直觉完全不同,不抓真实执行语句,你根本想不到它在背后做了什么花活。
3.2 同一条 SQL,在 5.5 与 8.0 上的两种命运
抓出 SQL 之后,我拿同样一段查询在老库和新库上各跑了一遍。老库秒出结果,新库直接报:
ERROR 1055 (42000): Expression #1 of ORDER BY clause is not in GROUP BY clause老库上查 sql_mode:
SELECT @@SESSION.sql_mode;结果是空字符串。再看 MySQL 8:
SELECT @@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看到ONLY_FULL_GROUP_BY排在最前面,基本就能锁定方向了。这里我多说一句:对比 sql_mode 时一定要同时看 global 和 session,因为很多连接池或中间件会在建立连接后设置自己的 session sql_mode,两个维度不一致时,问题表现可能时有时无,特别恶心。
3.3 用两行 SQL 验证元凶
为了快速确认是不是严格模式导致的,我在新库上临时把ONLY_FULL_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,果然通过了。这就算验证完假设:别名失效的一大元凶就是ONLY_FULL_GROUP_BY。
但注意,这只是验证手段,不等于解决方案。因为ONLY_FULL_GROUP_BY是有实际保护意义的:它能拦住一批“看起来能跑、实际结果不确定”的烂 SQL。直接把它关掉等于把 MySQL 8 降级成 5.5 的道德水准,治标不治本。而且像“派生表必须有别名”这种规则,根本不受 sql_mode 控制,所以后面还得分层处理。
4. 三层递进修复方案
4.1 第一层:数据库和驱动先做兼容适配
4.1.1 调整 sql_mode 到项目可接受的范围
这次升级最紧急的目标是让业务先跑起来,所以我做的第一步是把 sql_mode 调整到“比 5.5 严一点,但又不用立刻改代码”的程度。在my.cnf里这样写:
[mysqld] sql_mode = STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION保留STRICT_TRANS_TABLES,确保插入或更新时报错而不是截断,同时去掉ONLY_FULL_GROUP_BY,让老 SQL 先活下来。这里的取舍是:ONLY_FULL_GROUP_BY确实是好东西,但 Hibernate 3 的老查询里有一批写法天然不符合它,短期内又不可能全部改完。只能先让系统跑顺,再逐步清理烂 SQL。
注意:千万别把 sql_mode 直接设成空字符串,那样连严格模式都关闭了,数据写入时遇到超长字符串会被静默截断,等发现数据坏了再收拾就麻烦了。
4.1.2 用户认证与 JDBC 驱动
MySQL 8 默认的认证插件从mysql_native_password换成了caching_sha2_password。Hibernate 3 时代配套的 mysql-connector-java 5.x 根本不认识这个新插件,应用启动后直接报:
Unable to load authentication plugin 'caching_sha2_password'解决办法有两个方向:
- 在新库上创建老用户时,显式指定认证插件:
CREATE USER 'app'@'%' IDENTIFIED WITH mysql_native_password BY 'your_password'; GRANT ALL PRIVILEGES ON yourdb.* TO 'app'@'%'; FLUSH PRIVILEGES;- 或者把 JDBC 驱动升级到 mysql-connector-java 8.x,它是兼容
caching_sha2_password的。驱动升级后连接 URL 也要跟着改,我最终使用的是:
jdbc:mysql://dbhost:3306/yourdb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8&autoReconnect=true几个参数逐个说清楚:
useSSL=false:内网环境没有启用 SSL 证书,不关掉会因证书握手失败。allowPublicKeyRetrieval=true:配合caching_sha2_password使用,允许客户端向服务器获取公钥,否则会报 Public Key Retrieval is not allowed。serverTimezone=Asia/Shanghai:MySQL 8 和 JDBC 驱动之间的时区感知更强,不设置会报 The server time zone value is unrecognized。characterEncoding=utf8:老项目很多表还是 utf8,驱动升级后如果不显式指定,可能因为连接字符集变了导致中文乱码。
4.2 第二层:改查询,不让别名出现在不该出现的位置
4.2.1 聚合排序改为 order by 聚合表达式
改完 sql_mode 后系统暂时稳定,但我知道这只是缓兵之计。后续真正要做的是把 Hibernate 3 生成的 SQL 里那些“危险别名”清掉。最典型的场景是聚合查询排序:
改前的 HQL 写法:
String hql = "select dept, count(id) as cnt from Bill group by dept order by cnt desc";Hibernate 3 对这种order by cnt的别名处理很弱,往往生成order by this_.cnt,可表中根本没有 cnt 这个列,或者生成order by y1_,在 MySQL 8 下又可能触碰严格模式。改成这样更稳:
String hql = "select dept, count(id) as cnt from Bill group by dept order by count(id) desc";直接把聚合函数写进 order by,不依赖 select 里的别名,MySQL 8 能正确识别,Hibernate 也不会再去别名列里找答案。表面看只是把别名换成了表达式,但这是根治“ORDER BY 别名失效”最有效的一招。
4.2.2 分页 count 的替代写法
对带 group by 的分页查询,Hibernate 3 的 count 逻辑本身就是重灾区。它经常先把原查询包一层,再在外层数行数,结果碰到 MySQL 8 的派生表别名规则就炸。这种场景我一般建议做两步:
- 第一步:在映射或视图层把复杂的 group by 查询收敛成简单查询,避免 Hibernate 3 在背后生成子查询。
- 第二步:如果业务确实需要复杂聚合分页,就单独写一个原生 SQL count 方法,用固定写好的、带正确别名的 SQL 取总数,不走 Hibernate 的动态拼装。
比如:
select count(*) from ( select dept from bill group by dept ) t内层永远显式带上别名t,这就在源头上堵死了“Every derived table must have its own alias”。
4.2.3 保留字撞车怎么绕
如果你的老表里也有rank、window、system、groups这种列名,Hibernate 3 没有能力在拼 SQL 时自动加反引号。最直接的办法是给这些列改个不会撞车的名字,同时更新 Hibernate 映射文件里的 property 名和列名。如果因为历史原因不能改列名,那就建一个视图,把保留字列名映射成普通名字,Hibernate 全部指向视图。
比如:
create view v_bill_rank as select id, `rank` as rank_no, amount from bill;然后把 Hibernate 映射改成rank_no,这样 Hibernate 3 拼出来的 SQL 不会直接接触rank这个保留字,问题就从根上消失了。
4.3 第三层:给 Hibernate 3 换一个 MySQL 8 方言
4.3.1 为什么需要自定义方言
Hibernate 3.6 时代官方连 MySQL5InnoDBDialect 都算新货,更别提 MySQL8Dialect。但我们可以自己写一个子类,把 Hibernate 3 里那些过时的能力判断覆盖掉。这样 Hibernate 在生成分页、拼接标识符、处理 count 时的行为会更接近 MySQL 8 的预期。
4.3.2 一个最小可用的子类
我基于 Hibernate 3.6.10 写了一个很薄的方言子类:
import org.hibernate.dialect.MySQL5InnoDBDialect; public class MySQL8LegacyDialect extends MySQL5InnoDBDialect { @Override public boolean supportsLimit() { return true; } @Override public boolean supportsLimitOffset() { return true; } @Override public String getLimitString(String sql, boolean hasOffset) { return sql + (hasOffset ? " limit ?, ?" : " limit ?"); } }然后在hibernate.cfg.xml里指定:
<property name="hibernate.dialect">com.xxx.dialect.MySQL8LegacyDialect</property>自定义方言能解决的是“Hibernate 3 对数据库能力的误判”,比如分页语法、支持 limit 与否、是否用流式读写等。但它改变不了 Hibernate 3 对 group by、保留字、派生表别名的拼装方式,所以别指望这个类一上就把所有别名失效都解决。它只是把整个系统的 SQL 生成基线往 MySQL 8 的规则上靠了一步。
4.3.3 更彻底的一条路
如果你的项目还有余力,终极方案其实是升级 ORM 框架,从 Hibernate 3.2 至少升到 Hibernate 5.6,然后考虑 6.x。这一步会涉及 Criteria API、Interceptor、类型定义、集合映射等一堆兼容性调整,工作量不是一个升级窗口能完成的。我当时的策略是:先通过前两层修复让系统稳定运行,把升级 ORM 作为一个独立的技术债项目排期推进,不跟数据库升级混在一起,避免一次变更出两个变量。
4.4 离线环境操作备忘
4.4.1 改 my.cnf 后重启
离线环境下改 sql_mode 最稳妥的方式是直接改my.cnf:
[mysqld] sql_mode = STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION改完重启 MySQL:
systemctl restart mysqld然后验证:
SELECT @@GLOBAL.sql_mode;确认没有ONLY_FULL_GROUP_BY后才把应用重新连上。
4.4.2 验证字符集 utf8mb4
MySQL 8 默认字符集是 utf8mb4,但老项目导入的数据可能还是 utf8,为了防止 Hibernate 3 写入的中文乱码,我升级后专门确认了一遍:
SHOW VARIABLES LIKE 'character%';如果应用连接参数里已经指定了characterEncoding=utf8,同时表结构没有强制 utf8mb4,一般不会出现乱码。如果出现乱码,优先检查表字段的 collation 是否为 utf8mb4_general_ci,以及 JDBC URL 里是否少了characterEncoding参数。
5. 常见报错与排查速查表
这一轮升级里踩过的报错,我整理成了下面这张速查表,下次再遇到可以直接按图索骥:
| 报错信息 | 根因 | 处理动作 |
|---|---|---|
| Unable to load authentication plugin 'caching_sha2_password' | JDBC 驱动 5.x 不认识 MySQL 8 默认认证插件 | 升级 mysql-connector-java 8.x,或创建用户时指定mysql_native_password |
| Public Key Retrieval is not allowed | allowPublicKeyRetrieval默认关闭,无法获取 RSA 公钥 | JDBC URL 增加allowPublicKeyRetrieval=true |
| Every derived table must have its own alias | Hibernate 3 生成的内层子查询没有显式别名 | 改 HQL 或改用原生 count SQL,内层子查询手动加别名 |
| Expression #N of ORDER BY clause is not in GROUP BY clause | ONLY_FULL_GROUP_BY默认开启,排序列不满足分组规则 | 临时调整 sql_mode,或把 order by 改为聚合表达式 |
| Unknown column 'x' in 'having clause' | HAVING 中引用了 select 别名的写法在严格模式下不合法 | 把 having 条件是改为原始聚合函数表达式,不用别名 |
| The server time zone value is unrecognized | MySQL 8 与 JDBC 驱动的时区识别差异 | JDBC URL 增加serverTimezone=Asia/Shanghai |
里面有一条特别值得展开:Unknown column 'x' in 'having clause'看着特别像“HAVING 里的字段不存在”,但很多时候是因为 Hibernate 3 把 select 里临时生成的别名放进了 HAVING,而 MySQL 8 在严格模式下不允许这样引用非分组列。遇到这种报错,优先把 HAVING 里的字段改回原始表达式,比如having count(id) > 1,而不是having cnt > 1。
另外,排查时可以做一个“升级前探针”的小动作:在旧库上开启general_log,抓出一天内所有的 SELECT 语句,然后在新库上逐条 EXPLAIN 一遍,凡是报错的都记录下来。这个过程能把升级后可能爆炸的 SQL 提前抓出来,比等用户点了某个菜单才发现功能挂了要体面得多。
我个人强烈建议把这个探针清单沉淀到团队的运维文档里,以后每次数据库大版本升级,都先跑一遍 SQL 探针再动数据。历史系统的隐性 SQL 往往比你想的多得多,很多功能半年没人点,但升级后一访问就是一片红。
6. 一点个人体会
这次升级彻底改变了我的一个认知:历史系统升级时,最让人难受的不是数据量,也不是迁移脚本,而是那些“十年前能跑、今天跑不了”的 SQL 习惯。Hibernate 3 本身没有变,MySQL 8 也只是按标准执行,但它们撞在一起,就变成了别名失效、派生表报错、保留字冲突这一连串的问题。
如果你也在做类似的升级,我的建议是分三步走:第一步,用兼容性配置让系统先跑起来,保证业务不中断;第二步,用探针脚本把有问题的 SQL 全量揪出来,分批改写成符合新规则的写法;第三步,再规划 ORM 本身的升级。别试图一个晚上解决所有问题,历史项目的复杂度配得上足够的耐心。
最后再分享一个小技巧:在正式升级窗口前,把旧数据库切到只读模式,然后让应用团队把常用模块全部点一遍,同时在 MySQL 8 的实例上开着 general_log 做旁路分析,看哪些 SQL 在新库上会报错。这一步虽然费时间,但能帮你提前干掉八成以上的升级后事故。