把 MySQL 5.7 升到 8.0,五个报错排了三天
4 月初,我们把订单库的 MySQL 从 5.7.26 升到了 8.0.19。起因很实际:运营要做"每个用户的消费排名",5.7 里只能用会话变量写那种很难维护的 SQL,8.0 有窗口函数 ROW_NUMBER(),一行搞定。
升级过程本身(mysqldump 导出、导入、跑 mysql_upgrade)花了 4 小时,但真正耗时间的是之后的兼容性问题。五天里报了五个错,逐个记下来。
升级前的信息
源:MySQL 5.7.26,单实例,数据量 340 GB,QPS 峰值 4200
目标:MySQL 8.0.19,同样配置(16 核 64G,NVMe 1.5T)
方式:停机导出导入(业务低峰,凌晨 2 点到 6 点)
用的是逻辑导出而不是原地升级,因为 5.7 到 8.0 的系统表结构变化太大,原地升级风险高。另外老库用了很多 utf8mb3 的表,正好借这次统一成 utf8mb4。
# 导出,注意 --set-gtid-purged=OFF 避免导入时报错
$ mysqldump -uroot -p --single-transaction --master-data=2 \
--set-gtid-purged=OFF --default-character-set=utf8mb4 \
--databases shop > shop_20200403.sql
# 导入前先确认目标库的 sql_mode
$ mysql -uroot -p -e "SELECT @@sql_mode"
报错一:驱动类名变了
应用启动就报了一堆 WARN:
Loading class `com.mysql.jdbc.Driver'. This is deprecated.
The new driver class is `com.mysql.cj.jdbc.Driver'.
The driver is automatically registered via the SPI and manual loading of the driver class is generally unnecessary.
这是 WARN 不是 ERROR,服务能起来,但看着膈应。改两处:
<!-- pom.xml -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.19</version>
</dependency>
# application.yml
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://10.0.0.5:3306/shop?useUnicode=true&characterEncoding=utf8\
&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: shop_rw
password: ****
URL 里多出来的两个参数必须加,下面会说。
报错二:时区不对,时间差了 14 小时
换驱动之后启动失败了:
java.sql.SQLException: The server time zone value 'EDT' is unrecognized
or represents more than one time zone. You must configure either the server
or JDBC driver (via the serverTimezone configuration property) to use a more
specifc time zone value if you want to utilize time zone support.
Connector/J 8.0 会去读Ubuntu 服务器的时区,读到一个它不认的名字(我们服务器 OS 时区是 EDT)就拒绝连接。5.1 的驱动没这个校验,所以以前没暴露。
这个错误比看起来危险。如果你不想改 URL,有人会用另一个办法:把数据库连接的时区设成 UTC。但那样 Java 里写入的时间会按 UTC 存,读出来再转,中间容易出 8 小时或 13 小时的偏差。我当时差点就犯这个错。
正确做法是显式指定和数据库服务器一致的时区:serverTimezone=Asia/Shanghai。
顺带确认一下数据库侧:
mysql> SELECT @@global.time_zone, @@session.time_zone, NOW();
+--------------------+---------------------+---------------------+
| @@global.time_zone | @@session.time_zone | NOW() |
+--------------------+---------------------+---------------------+
| SYSTEM | SYSTEM | 2020-04-03 14:22:07 |
+--------------------+---------------------+---------------------+
报错三:认证插件连不上
Unable to load authentication plugin 'caching_sha2_password'.
MySQL 8.0 把默认认证插件从 mysql_native_password 换成了 caching_sha2_password。Connector/J 8.0.19 是支持的,但我们有些老工具(Navicat 11、Python 的 MySQLdb)不支持。
两个选择:升级所有客户端,或者让账号用回旧插件。我们选了后者,因为改账号比推动全公司升级工具容易:
-- 新建账号时指定
CREATE USER 'shop_rw'@'%' IDENTIFIED WITH mysql_native_password BY 'xxx';
-- 已有账号改
ALTER USER 'shop_rw'@'%' IDENTIFIED WITH mysql_native_password BY 'xxx';
FLUSH PRIVILEGES;
-- 确认
mysql> SELECT user, host, plugin FROM mysql.user WHERE user='shop_rw';
+---------+------+-----------------------+
| user | host | plugin |
+---------+------+-----------------------+
| shop_rw | % | mysql_native_password |
+---------+------+-----------------------+
如果确实要用新插件,URL 上要加 allowPublicKeyRetrieval=true,否则在某些场景下会报 Public Key Retrieval is not allowed。我们两个都配了。
报错四:GROUP BY 不排序了,报表顺序全乱
这个是 8.0 的一个语义变化,非常隐蔽,因为它不报错。
MySQL 5.7 里,GROUP BY 默认会按分组字段排序(依赖隐式排序)。8.0 从优化器层面去掉了这个行为,GROUP BY 的结果顺序不再有保证。
我们的月度销售报表受影响:
SELECT DATE_FORMAT(created_at, '%Y-%m') AS m,
SUM(amount) AS total
FROM t_order
WHERE created_at >= '2020-01-01'
GROUP BY m;
5.7 上返回 01、02、03 顺序,8.0 上返回 02、01、03。前端直接画了个乱序的折线图,运营以为数据错了。
修复很简单,但要改的地方不少:
SELECT DATE_FORMAT(created_at, '%Y-%m') AS m,
SUM(amount) AS total
FROM t_order
WHERE created_at >= '2020-01-01'
GROUP BY m
ORDER BY m; -- 显式写出来
我用正则扫了一遍所有 Mapper 的 XML,找出带 GROUP BY 但没有 ORDER BY 的 SQL,一共 23 处,逐个补上。建议任何要升 8.0 的团队都做这一步,这个 bug 不会在测试环境暴露(数据量小的时候恰好顺序是对的)。
另外 8.0 也去掉了 GROUP BY col DESC 这种写法,要排序就写 ORDER BY。
报错五:字段名叫 rank,成了保留字
MySQL 8.0 因为引入窗口函数,新增了一批保留字:RANK、ROW_NUMBER、DENSE_RANK、GROUPS、SYSTEM、FUNCTION、RECURSIVE。
我们有张表叫 t_member,里面有个字段就叫 rank(会员等级)。升级之后凡是查这个字段的 SQL 全挂:
You have an error in your SQL syntax; check the manual that corresponds to your
MySQL server version for the right syntax to use near 'rank from t_member where
user_id = 10037' at line 1
处理方式有三种,我们选了最彻底的一种——改字段名:
ALTER TABLE t_member CHANGE COLUMN `rank` member_level TINYINT NOT NULL DEFAULT 1;
因为要改的代码就 6 处,趁早改掉比一直带着反引号干净。如果改不动,用反引号包起来也能跑:`` SELECT `rank` FROM t_member ``。
性能上的变化
这才是升级的主要动机。sysbench 压测(oltp_read_write,64 线程,500 万行)的结果:
| 场景 | 5.7.26 | 8.0.19 | 变化 |
|---|---|---|---|
| 读写混合 QPS | 4870 | 7120 | +46% |
| 只读 QPS | 11300 | 15400 | +36% |
| 写入 QPS | 1960 | 3410 | +74% |
| P99 延迟 | 42 ms | 26 ms | -38% |
写入提升最明显,主要来自 8.0 对 redo log 的改造:5.7 里 redo log 的写入要加全局锁,8.0 改成了无锁的并发写入(把 redo 的写入拆成了"写 log buffer"和"刷盘"两个无锁阶段)。我们这种写多读少的订单库正好受益。
除了这些,8.0 还有几个我们很快就用上的能力:
- 窗口函数:排名、同比环比、累计求和,以前要写自关联或者会话变量,现在一行。
- CTE(公用表表达式):
WITH子句,递归查询组织树终于不用在应用层循环了。我们的分类树查询从 43 行 Java 代码变成 9 行 SQL。 - 降序索引:
CREATE INDEX idx ON t_order (created_at DESC),8.0 之前这个DESC会被直接忽略。我们的"最新订单"查询用上了。 - Hash Join(8.0.18 起):两个大表做等值关联时,优化器可能选 hash join 替代嵌套循环。我们有个对账查询从 87 秒降到 11 秒,
EXPLAIN里能看到Extra: Using hash join。 - 不可见索引:
ALTER TABLE t_order ALTER INDEX idx_x INVISIBLE,可以试探性地"删掉"索引看看影响,不对随时改回来。删大表索引前的必备动作。
升级后一定要检查的几项
# 1. 确认字符集,8.0 默认 utf8mb4,但要检查迁过来的老表
mysql> SELECT TABLE_NAME, TABLE_COLLATION FROM information_schema.TABLES
WHERE TABLE_SCHEMA='shop' AND TABLE_COLLATION NOT LIKE 'utf8mb4%';
# 2. 看 sql_mode,ONLY_FULL_GROUP_BY 在 5.7 和 8.0 都默认开着
mysql> SELECT @@sql_mode;
# 3. 错误日志里搜升级遗留问题
$ grep -iE "error|warning|deprecated" /var/log/mysql/error.log | tail -50
# 4. 慢查询对比,升级前后各抓一小时的
$ mysqldumpslow -s t -t 20 /var/log/mysql/slow.log
第四项我们做了,发现有一批 SQL 在 8.0 上变慢了 3 倍,原因是优化器对 IN (子查询) 的处理变了,改成 JOIN 之后恢复。不要想当然地认为新版本一定更快。
小结
- 驱动要换成
com.mysql.cj.jdbc.Driver和 8.0.x 版本,URL 必加serverTimezone。时区别图省事设成 UTC。 caching_sha2_password会让老客户端连不上,改成mysql_native_password或者升级全部客户端。- 8.0 去掉了 GROUP BY 的隐式排序。这个不报错,但会让依赖顺序的功能悄悄出错。升之前把所有带 GROUP BY 没有 ORDER BY 的 SQL 扫一遍。
rank、row_number、groups、system这些变成了保留字,当字段名的要改。- 性能上写入提升最大(我们 +74%),来自 redo log 无锁化。窗口函数、CTE、Hash Join、降序索引是几个马上能用上的新特性。
- 升级完要对比慢查询日志。我们有一条 SQL 反而慢了 3 倍。
整个升级从评估到完成用了两周,其中三天在排兼容性问题。如果重来一次,我会把"扫 SQL"这一步提前到升级前做,能省掉后面至少一天的救火时间。