第08篇:MySQL 5.7 → 8.0 升级实战全指南:新特性 · 破坏性变更 · 零停机迁移手册

系列 :MySQL 从基础到精深------Java SaaS 实战系列 · 第 08 篇(★ 核心专题)

难度 :⭐⭐⭐⭐⭐ 适合正在主导或计划升级的工程师

预计阅读时间 :55 分钟

关键词:MySQL 5.7 升级 8.0、mysql_native_password、窗口函数 ROW_NUMBER RANK、CTE WITH 子句、utf8mb4_0900_ai_ci、不停机升级 MySQL、Java JDBC 兼容性、Spring Boot MySQL 8.0 迁移


速览摘要

这是本系列含金量最高的一篇。第一部分解释为什么升级不是可选项而是必选项。第二部分系统讲解 MySQL 8.0 的五大新特性(窗口函数、CTE、函数索引、JSON 增强、隐式列自动更新),每个特性都给出与 5.7 的对比和 Java 侧的改写示例。第三部分是本篇重心------逐一拆解十二个破坏性变更,包括认证插件变更这个最高发的坑。第四部分提供零停机升级的分阶段操作手册(升级前预检 → 从库滚动升级 → 主库切换 → 验证回滚)。第五部分给出 Java 代码全链路适配清单。


一、升级是必选项,不是可选项

在说技术细节之前,先把这个问题说清楚:为什么 2025 年还在讨论 5.7 → 8.0 的升级,而不是"我们的系统跑得好好的,为什么要动它"。

原因有三层,从轻到重依次是。

第一层:安全漏洞无人修复。 MySQL 5.7 的官方支持生命周期已于 2023 年 10 月结束(End of Life)。这意味着从那时起,任何新发现的安全漏洞,Oracle 不再为 5.7 提供补丁。SaaS 系统存储着租户的核心业务数据,运行着一个不再接受安全修复的数据库,在合规审查(ISO 27001、SOC 2、等保三级)中会直接被标记为高风险项,有时甚至影响大客户的采购决策。

第二层:新特性带来的开发效率提升被永久错过。 8.0 的窗口函数和 CTE 不是锦上添花,是让某些复杂查询从"在 Java 层多次查询 + 手工拼接"变成"一条 SQL 干净完成"的质变。越晚升级,积累的技术债越多,历史代码里那些因为没有窗口函数而写得扭曲的查询,每次维护都是多余的成本。

第三层:升级越晚越难。 这是最容易被低估的成本。每多运行一年 5.7,就多积累一年的历史数据、历史 SQL 代码、历史配置依赖。三年后再升级,要处理的 SQL 兼容性问题、数据量迁移成本,会比今天大得多。升级这件事,越早做成本越低,越拖成本越高。

理解了这三层原因,接下来的所有技术细节就有了明确的工程背景。


二、MySQL 8.0 的五大新特性精讲

2.1 窗口函数:彻底终结"在 Java 层做排名计算"

在 MySQL 5.7 时代,有一类查询需求极其常见却极其难写:在同一个分组内给行排名、计算累计值、做环比对比。没有窗口函数时,唯一的纯 SQL 解法是相关子查询(O(N²) 复杂度),或者把数据捞回 Java 层再计算。前者性能差,后者带来了额外的网络传输和内存占用,而且计算逻辑散落在 Java 代码里,不在 SQL 里,日后维护时两处都要改。

窗口函数把这类计算还给了 SQL 层,性能好(单次扫描完成),语义清晰(读 SQL 就能看懂逻辑),是 8.0 升级后第一个应该去"翻新"的场景。

理解窗口函数有一个关键概念要先建立清楚:窗口函数和 GROUP BY 的本质区别。GROUP BY 会把多行"压缩"成一行(聚合),窗口函数在每行上单独计算,原始行完整保留,只是在结果集里多了一列计算值。这意味着你可以在一个结果集里同时看到"每笔订单的明细"和"该订单在当前租户内的排名",而 GROUP BY 做不到这一点。

sql 复制代码
-- 场景一:每个租户内,按金额倒序给订单排名,取前 3 名
-- 这个需求在 SaaS 系统里非常常见(如"本月成交额 TOP3"的看板)

-- MySQL 5.7 的写法(相关子查询,O(N²),订单多时极慢)
SELECT o1.tenant_id, o1.order_no, o1.total_amount
FROM orders o1
WHERE (
    SELECT COUNT(*)
    FROM orders o2
    WHERE o2.tenant_id = o1.tenant_id
      AND o2.total_amount > o1.total_amount
      AND o2.status = 1
) < 3
  AND o1.status = 1;

上面的 5.7 写法有一个深层问题:如果两笔订单金额相同,它们的排名可能都是第 2,也可能都是第 3,结果集行数不确定。这种不稳定性很难向产品解释清楚,往往需要在 Java 层再做一次过滤。

sql 复制代码
-- MySQL 8.0 的写法(窗口函数,单次扫描,结果确定)
WITH ranked_orders AS (
    SELECT
        tenant_id,
        order_no,
        total_amount,
        ROW_NUMBER() OVER (
            PARTITION BY tenant_id      -- 按租户分窗口
            ORDER BY total_amount DESC  -- 窗口内按金额排序
        ) AS rn                         -- rn 是在当前租户内的排名
    FROM orders
    WHERE status = 1
)
SELECT tenant_id, order_no, total_amount, rn
FROM ranked_orders
WHERE rn <= 3
ORDER BY tenant_id, rn;

这里的 ROW_NUMBER() 在金额相同时会给出不同的排名(区分同名次)。如果你的业务需要"金额相同则同名次",应该用 RANK()(同名次后跳号,如 1,2,2,4)或 DENSE_RANK()(同名次不跳号,如 1,2,2,3)。三个函数名字相近,选错会导致产品需求对不上,在审阅 LLM 生成的 SQL 时要特别注意这一点。

下面是 SaaS 系统中另一个高频场景------环比计算,5.7 时代要用自关联 JOIN 实现,8.0 用窗口函数的 LAG() 一行解决:

sql 复制代码
-- 场景二:计算各租户每月收入及环比增长率

-- 5.7 写法(自关联,结构复杂,容易写错边界条件)
SELECT
    cur.tenant_id,
    cur.month,
    cur.revenue,
    prev.revenue AS prev_revenue,
    ROUND((cur.revenue - prev.revenue) / prev.revenue * 100, 2) AS growth_pct
FROM monthly_revenue cur
LEFT JOIN monthly_revenue prev
    ON prev.tenant_id = cur.tenant_id
    AND prev.month = DATE_FORMAT(DATE_SUB(STR_TO_DATE(CONCAT(cur.month, '-01'), '%Y-%m-%d'),
                      INTERVAL 1 MONTH), '%Y-%m')
ORDER BY cur.tenant_id, cur.month;

这段 5.7 的 SQL 里藏着一个日期处理逻辑------先把月份字符串转成日期,减一个月,再格式化回字符串。任何一个函数参数写错,结果就悄悄地错了,还不报错。这类 SQL 一旦写完,三个月后连作者自己都不敢随便动它。

sql 复制代码
-- 8.0 写法(LAG 函数直接取上一行的值,逻辑一目了然)
SELECT
    tenant_id,
    month,
    revenue,
    LAG(revenue, 1) OVER (
        PARTITION BY tenant_id
        ORDER BY month
    ) AS prev_revenue,
    ROUND(
        (revenue - LAG(revenue, 1) OVER (PARTITION BY tenant_id ORDER BY month))
        / NULLIF(LAG(revenue, 1) OVER (PARTITION BY tenant_id ORDER BY month), 0)
        * 100,
        2
    ) AS growth_pct
FROM monthly_revenue
ORDER BY tenant_id, month;

注意这里用了 NULLIF(..., 0) 包裹除数,这是防止除以零报错的标准写法。上个月收入为零(新租户第一个月没有"上月"数据时 LAG 返回 NULL,NULL 参与运算结果也是 NULL,不会除零),但如果上月收入恰好是 0,NULLIF(0, 0) 会返回 NULL,避免除以零错误。这个细节容易忘,在审查 LLM 生成的窗口函数 SQL 时要主动检查。

2.2 CTE(公共表表达式):让复杂 SQL 有了"变量名"

CTE 解决的是复杂 SQL 可读性和可维护性的问题。在 5.7 时代,复杂的多步骤查询只能嵌套子查询,或者用临时表分步骤。嵌套子查询的问题是层级深了之后没有名字、没有注释,读代码时要从最内层往外剥,维护极其痛苦。临时表的问题是需要额外的 CREATE / DROP 语句,无法在一个语句里完成,事务处理也更复杂。

CTE 给了每一步中间结果一个名字,让多步骤查询可以像写代码一样从上往下读,每步做什么一目了然:

sql 复制代码
-- 场景:统计"本月有效订单金额超过上月平均金额的租户",需要三步计算

-- 5.7 写法(三层嵌套子查询,从外往里读,越来越晕)
SELECT tenant_id, current_revenue
FROM (
    SELECT tenant_id, SUM(total_amount) AS current_revenue
    FROM orders
    WHERE status = 1
      AND created_at >= '2024-06-01'
      AND created_at < '2024-07-01'
    GROUP BY tenant_id
) cur
WHERE cur.current_revenue > (
    SELECT AVG(monthly_revenue)
    FROM (
        SELECT tenant_id, SUM(total_amount) AS monthly_revenue
        FROM orders
        WHERE status = 1
          AND created_at >= '2024-05-01'
          AND created_at < '2024-06-01'
        GROUP BY tenant_id
    ) prev
);
sql 复制代码
-- 8.0 CTE 写法(从上往下读,每步有名字)
WITH
-- 第一步:计算上月各租户总收入
last_month AS (
    SELECT tenant_id, SUM(total_amount) AS monthly_revenue
    FROM orders
    WHERE status = 1
      AND created_at >= '2024-05-01'
      AND created_at < '2024-06-01'
    GROUP BY tenant_id
),
-- 第二步:计算上月平均收入(跨租户的均值)
avg_last_month AS (
    SELECT AVG(monthly_revenue) AS avg_revenue
    FROM last_month
),
-- 第三步:计算本月各租户总收入
this_month AS (
    SELECT tenant_id, SUM(total_amount) AS current_revenue
    FROM orders
    WHERE status = 1
      AND created_at >= '2024-06-01'
      AND created_at < '2024-07-01'
    GROUP BY tenant_id
)
-- 最终结果:本月收入超过上月平均的租户
SELECT t.tenant_id, t.current_revenue, a.avg_revenue
FROM this_month t
CROSS JOIN avg_last_month a
WHERE t.current_revenue > a.avg_revenue
ORDER BY t.current_revenue DESC;

两段 SQL 的逻辑完全相同,但可读性天差地别。CTE 版本的每一个 WITH 块都有语义明确的名字,读一遍就能理解计算流程;子查询版本需要把结构图在脑子里逆向还原,耗费大量认知资源。

CTE 还支持递归,这是它最强大也最容易被忽视的能力。递归 CTE 可以用纯 SQL 处理树形结构(菜单、组织架构、分类层级),不再需要在 Java 层做递归查询:

sql 复制代码
-- 递归 CTE:获取某个菜单节点的所有后代节点
-- 这个需求在 5.7 时代通常需要存储过程或在 Java 层多次查询拼接

WITH RECURSIVE menu_tree AS (
    -- 锚点查询:起始节点(根节点 ID = 1)
    SELECT id, parent_id, name, menu_level, sort_order,
           CAST(name AS CHAR(500)) AS full_path   -- 初始化路径
    FROM menu
    WHERE id = 1

    UNION ALL

    -- 递归查询:每次找当前集合中所有节点的直接子节点
    SELECT m.id, m.parent_id, m.name, m.menu_level, m.sort_order,
           CONCAT(mt.full_path, ' > ', m.name)    -- 拼接路径
    FROM menu m
    INNER JOIN menu_tree mt ON m.parent_id = mt.id
    WHERE mt.menu_level < 10   -- 防止循环数据导致无限递归,设置深度上限
)
SELECT * FROM menu_tree
ORDER BY menu_level, sort_order;

递归 CTE 有一个必须养成的习惯:永远加深度限制条件(这里是 menu_level < 10)。如果菜单数据中有环形引用(A 是 B 的子节点,B 又是 A 的子节点),没有深度限制的递归 CTE 会无限循环直到 MySQL 报错或超时。深度限制是防御性编程,不是可有可无的。

2.3 函数索引:为表达式建立索引,消除大量 filesort 和全表扫描

在 5.7 时代,如果你的查询里有 WHERE YEAR(created_at) = 2024 或者 WHERE LOWER(email) = 'user@example.com',索引就失效了,因为 B+树是按原始列值排序的,对列应用函数后,排序关系就不再成立。要解决这个问题,唯一的办法是改写 SQL(如把 YEAR(created_at) = 2024 改成范围条件)。

但有些情况下,SQL 是第三方调用的(你改不了),或者改写后可读性会变差,或者函数逻辑本身是业务必须的。MySQL 8.0 的函数索引(Expression Index)解决了这个问题:对一个表达式的计算结果建立索引,查询中只要使用了相同的表达式,就能命中这个索引。

sql 复制代码
-- 场景一:大小写不敏感的邮箱唯一索引
-- SaaS 系统中,注册邮箱通常要做大小写不敏感的唯一性校验
-- 'User@Example.com' 和 'user@example.com' 应该被视为同一个邮箱

-- 5.7 没有函数索引,只能在应用层把邮箱统一转小写后存储
-- 如果有历史数据大小写混乱,还需要数据清洗

-- 8.0 函数索引:对 LOWER(email) 建唯一索引
ALTER TABLE user
    ADD UNIQUE INDEX uk_lower_email ((LOWER(email)));

-- 查询时,只要使用相同的表达式,就能命中索引
SELECT * FROM user WHERE LOWER(email) = LOWER('User@Example.com');

-- 场景二:按月份统计时,对 YEAR_MONTH 表达式建索引,消除 filesort
-- 这在报表查询里非常常见
ALTER TABLE orders
    ADD INDEX idx_tenant_yearmonth (tenant_id, (DATE_FORMAT(created_at, '%Y-%m')));

-- 这个查询现在可以走索引
SELECT DATE_FORMAT(created_at, '%Y-%m') AS month, COUNT(*), SUM(total_amount)
FROM orders
WHERE tenant_id = 1001
  AND DATE_FORMAT(created_at, '%Y-%m') >= '2024-01'
GROUP BY DATE_FORMAT(created_at, '%Y-%m');

函数索引有一个容易踩的坑:表达式必须和索引定义完全一致才能命中 。如果索引是 (LOWER(email)),查询里用 LOWER(TRIM(email)) 或者 LOWER(email) COLLATE utf8mb4_bin 都不会命中,MySQL 无法识别"语义相同但写法不同"的表达式。建索引和写查询时要确保表达式字符串完全一样,包括函数名的大小写(MySQL 函数名不区分大小写,但仍建议统一风格)。

2.4 JSON 类型增强与 JSON_TABLE:半结构化数据的终极武器

5.7 引入了 JSON 类型,8.0 大幅增强了 JSON 的查询和操作能力,其中最值得关注的是 JSON_TABLE() 函数------它可以把 JSON 数组"展开"成关系型的行,与其他表进行 JOIN,让 JSON 数据和结构化数据在同一个查询里协同工作。

这个能力在 SaaS 系统中的典型场景是:订单明细、权限配置、动态表单数据这类结构不固定的内容,往往以 JSON 形式存储,但有时需要按明细行做聚合统计。5.7 时代只能把 JSON 捞到应用层解析,8.0 可以直接在 SQL 里展开:

sql 复制代码
-- 场景:订单的商品明细存储为 JSON 数组,需要按商品 ID 统计销量

-- orders.items_json 示例值:
-- [{"productId":101,"name":"商品A","qty":2,"price":99.00},
--  {"productId":102,"name":"商品B","qty":1,"price":199.00}]

SELECT
    o.tenant_id,
    jt.product_id,
    jt.product_name,
    SUM(jt.qty)                              AS total_qty,
    SUM(jt.qty * jt.unit_price)              AS total_revenue
FROM orders o,
JSON_TABLE(
    o.items_json,        -- JSON 列
    '$[*]'               -- 遍历数组的每个元素
    COLUMNS (
        product_id   BIGINT        PATH '$.productId',
        product_name VARCHAR(128)  PATH '$.name',
        qty          INT           PATH '$.qty',
        unit_price   DECIMAL(10,2) PATH '$.price'
    )
) AS jt                  -- 展开后作为虚拟表,别名 jt
WHERE o.tenant_id = 1001
  AND o.status = 1
  AND o.created_at >= '2024-01-01'
GROUP BY o.tenant_id, jt.product_id, jt.product_name
ORDER BY total_revenue DESC;

JSON_TABLE 的语法初看有点陌生,但逻辑很清晰:第一个参数是 JSON 列,第二个参数是 JSONPath($[*] 表示展开数组的所有元素),COLUMNS 定义每个 JSON 字段如何映射到关系型列。理解了这个结构,应对各种变体就游刃有余了。

需要提醒的是,JSON_TABLE 展开 JSON 数组时,如果 JSON 列有 NULL 值或者 JSON 格式不合法,行为取决于 ON ERRORON EMPTY 子句的配置(默认是 NULL)。生产环境使用前,建议先用小批量数据验证 NULL 和异常数据的处理方式,避免展开结果与预期不符。

2.5 不可见索引与降序索引:运维操作的安全网

不可见索引(Invisible Index)是 8.0 给 DBA 的安全缓冲机制。在 5.7 时代,删除一个索引是不可逆操作------删掉之后发现某个查询变慢了,只能重新建索引(这在大表上可能需要几十分钟)。8.0 的不可见索引允许先把索引标记为"不可见",优化器不再使用它,但索引本身继续维护,随时可以改回可见。这就给了"删除索引前先观察一段时间"的安全操作空间:

sql 复制代码
-- 不确定某个索引是否还在被使用?先设为不可见,观察 1-2 周
ALTER TABLE orders ALTER INDEX idx_old_column INVISIBLE;

-- 观察期间,通过 sys.schema_unused_indexes 确认没有查询依赖它
SELECT * FROM sys.schema_unused_indexes WHERE object_schema = 'saas_demo';

-- 观察期结束,确认无影响后再正式删除
ALTER TABLE orders DROP INDEX idx_old_column;

-- 如果期间发现有查询变慢了(不可见导致的),立刻改回可见
ALTER TABLE orders ALTER INDEX idx_old_column VISIBLE;

降序索引 解决了 ORDER BY 混合升降序(如 ORDER BY create_time DESC, id ASC)无法消除 filesort 的问题。建索引时为每列单独指定升降序,查询的排序方向和索引方向完全匹配时就能直接利用索引有序性:

sql 复制代码
-- 需求:按创建时间倒序、同时按 id 正序排列(用于稳定排序的分页)
CREATE INDEX idx_created_desc_id_asc ON orders (created_at DESC, id ASC);

-- 这个查询现在可以避免 filesort
SELECT * FROM orders
WHERE tenant_id = 1001
ORDER BY created_at DESC, id ASC
LIMIT 20;

三、破坏性变更:升级前必须逐一核查的十二个问题

这一节是本篇最需要认真阅读的部分。新特性是升级的收益,破坏性变更是升级的风险------每一条在下面被列出,都曾经在真实的升级项目中导致过生产事故或紧急回滚。有些问题很明显,有些极其隐蔽,需要主动扫描才能发现。

变更一:认证插件从 mysql_native_password 改为 caching_sha2_password(最高发的坑)

这是 5.7 → 8.0 升级中导致应用连接失败最常见的原因,没有之一。理解它需要先知道背景:MySQL 用户密码的存储和验证方式(即"认证插件")在 8.0 中换了新的默认实现,从 mysql_native_password 改为 caching_sha2_password,后者安全性更高(基于 SHA-256,而不是 SHA-1 的变种)。

问题在于:很多 JDBC 驱动的旧版本(MySQL Connector/J 5.x,以及某些公司内部维护的老版本驱动)不支持 caching_sha2_password,连接时会报 Public Key Retrieval is not allowedUnable to load authentication plugin 'caching_sha2_password' 错误。应用代码本身一行都不用改,但连接就是建不起来。

解法有两个方向,需要根据实际情况选择其中一个或两者结合:

sql 复制代码
-- 方向一:升级 JDBC 驱动到 MySQL Connector/J 8.x(推荐,一劳永逸)
-- 在 pom.xml 中:
-- <dependency>
--     <groupId>com.mysql</groupId>
--     <artifactId>mysql-connector-j</artifactId>
--     <version>8.0.33</version>
-- </dependency>
--
-- 注意:驱动包名从 mysql:mysql-connector-java 变为 com.mysql:mysql-connector-j
-- Driver 类名从 com.mysql.jdbc.Driver 变为 com.mysql.cj.jdbc.Driver
-- 两处都要改,只改 pom.xml 而忘记改 driver-class-name 会导致 ClassNotFoundException

-- 方向二:将目标用户的认证插件改回旧版本(过渡期临时措施)
-- 适用于无法快速升级所有驱动的情况(如有第三方系统连接 MySQL)
ALTER USER 'app_user'@'%'
    IDENTIFIED WITH mysql_native_password BY 'your_password';

-- 或者在 my.cnf 中设置全局默认(新建用户默认使用旧插件)
-- [mysqld]
-- default_authentication_plugin = mysql_native_password
-- 注意:这个参数在 MySQL 8.0.34 之后被废弃,8.4+ 中已完全移除

升级驱动是正确方向,降级认证插件是临时过渡。如果因为某些原因(如老系统无法升级驱动)必须用 mysql_native_password,至少要在升级计划里明确写清楚"何时迁移到 caching_sha2_password",不要让临时方案变成永久配置。

JDBC URL 里还需要加 allowPublicKeyRetrieval=true,这是 caching_sha2_password 在 SSL 未完整配置时的必要参数------它允许客户端从服务器获取 RSA 公钥来完成认证握手。不加这个参数,即使驱动版本正确,也可能在某些网络环境下连接失败:

yaml 复制代码
# application.yml 中的 JDBC URL,8.0 的推荐写法
spring:
  datasource:
    url: jdbc:mysql://your-host:3306/saas_demo
         ?useUnicode=true
         &characterEncoding=utf8mb4
         &serverTimezone=Asia/Shanghai
         &allowPublicKeyRetrieval=true
         &useSSL=false
    driver-class-name: com.mysql.cj.jdbc.Driver

变更二:utf8mb4 默认排序规则从 general_ci 改为 0900_ai_ci

在 5.7 中,utf8mb4 的默认排序规则是 utf8mb4_general_ci。在 8.0 中,默认改为 utf8mb4_0900_ai_ci(基于 Unicode 9.0,ai 表示口音不敏感,ci 表示大小写不敏感)。

这个变化本身不是问题,0900_ai_ci 的排序准确性和性能都优于 general_ci。问题在于:如果 5.7 的数据库使用的是 general_ci,升级到 8.0 后,任何涉及两个不同排序规则的列的 JOIN 或比较,都会报 Illegal mix of collations 错误。这个错误在单表查询时不会出现,只在跨表 JOIN 或比较时才露面,而且测试时经常被遗漏(测试用的是相同环境,排序规则一致)。

解决策略是升级时保持排序规则一致,不要在同一次升级里同时改 MySQL 版本和排序规则:

sql 复制代码
-- 升级到 8.0 时,在 my.cnf 中显式指定保留旧排序规则
-- 这样新建的表和列会沿用与 5.7 相同的排序规则,不会出现混合冲突
-- [mysqld]
-- character-set-server = utf8mb4
-- collation-server = utf8mb4_unicode_ci   ← 明确指定,不依赖 8.0 的新默认值

-- 查看当前数据库的排序规则
SELECT schema_name, default_character_set_name, default_collation_name
FROM information_schema.schemata
WHERE schema_name = 'saas_demo';

-- 查看哪些表的排序规则与数据库级别不一致(潜在的 JOIN 冲突风险)
SELECT table_name, table_collation
FROM information_schema.tables
WHERE table_schema = 'saas_demo'
  AND table_collation != 'utf8mb4_unicode_ci';  -- 替换为你的目标排序规则

排序规则的统一迁移(从 general_ci 迁到 0900_ai_ci)可以在 8.0 稳定运行一段时间后单独做,不要和版本升级同时进行。两件事叠加在一起,出了问题不知道是哪个原因,排查难度倍增。

变更三:GROUP BY 不再隐式排序

在 MySQL 5.7 及更早版本,GROUP BY col 有一个隐式的副作用:结果集会按 col 升序排列,即使你没有写 ORDER BY。很多开发者依赖这个行为,写出了没有显式 ORDER BY 但期待有序结果的 SQL。

MySQL 8.0 移除了这个隐式排序行为。GROUP BY 现在就是纯粹的分组,不保证任何顺序。依赖隐式排序的 SQL 升级后结果集的行顺序是不确定的,如果业务逻辑依赖这个顺序,就会产生难以复现的 Bug:

sql 复制代码
-- 5.7 的危险写法(依赖 GROUP BY 的隐式排序)
SELECT tenant_id, COUNT(*) AS order_count
FROM orders
GROUP BY tenant_id;
-- 5.7 中结果按 tenant_id 升序排列(隐式行为)
-- 8.0 中结果顺序不确定

-- 正确写法:显式声明 ORDER BY
SELECT tenant_id, COUNT(*) AS order_count
FROM orders
GROUP BY tenant_id
ORDER BY tenant_id;  -- 明确表达意图,在任何版本都行为一致

这个问题的扫描方式:搜索代码库中所有带 GROUP BY 但不带 ORDER BY 的查询,逐一检查调用方是否依赖结果的行顺序。在 Java 层,如果对查询结果做了 list.get(0) 或按下标取值,就要格外注意。

变更四:SQL 严格模式收紧------ONLY_FULL_GROUP_BY 默认开启

这和上一条是不同的问题。ONLY_FULL_GROUP_BY 是 SQL 的标准合规校验:SELECT 中的非聚合列,如果不在 GROUP BY 里,就报错。5.7 在某些安装下这个模式是关闭的(或被 DBA 手动关闭过),允许查询结果中非聚合列取值不确定。8.0 默认开启,于是原来能跑的 SQL 开始报错。

这类 SQL 通常是这样的:

sql 复制代码
-- 在 ONLY_FULL_GROUP_BY 关闭时能跑,开启时报错
SELECT tenant_id, name, COUNT(*) AS cnt   -- name 不在 GROUP BY 里
FROM orders
GROUP BY tenant_id;
-- ERROR 1055: 'orders.name' isn't in GROUP BY

修复方式有三种,选哪种取决于业务语义。如果 name 对于同一个 tenant_id 确实是唯一的(多余字段只是方便展示),用 MIN(name)MAX(name) 包一层;如果需要全部字段,把它加进 GROUP BY;如果统计逻辑需要分离,改用 JOIN:

sql 复制代码
-- 修复方案:用 MIN/MAX 聚合,或加入 GROUP BY,或改用子查询 + JOIN
SELECT o.tenant_id, t.name, o.order_count
FROM (
    SELECT tenant_id, COUNT(*) AS order_count
    FROM orders GROUP BY tenant_id
) o
JOIN tenant t ON t.id = o.tenant_id;

变更五:查询缓存彻底移除

第 01 篇已经提过这个问题,这里强调升级操作层面的处理:5.7 配置文件中所有 query_cache_* 参数在 8.0 中都变成了无法识别的参数,如果不清除,MySQL 8.0 启动时会因为 unknown variable 而拒绝启动。升级前必须检查 my.cnf 并移除这些参数:

bash 复制代码
# 搜索配置文件中所有 query_cache 相关参数
grep -i "query_cache" /etc/mysql/my.cnf /etc/mysql/mysql.conf.d/*.cnf

# 通常需要移除的参数包括:
# query_cache_type
# query_cache_size
# query_cache_limit
# query_cache_min_res_unit
# query_cache_wlock_invalidate

变更六:utf8 字符集警告与未来移除

MySQL 的 utf8 字符集实际上是 utf8mb3(最多 3 字节),无法存储 4 字节字符(emoji、某些生僻汉字)。8.0 中 utf8 仍然是 utf8mb3 的别名,但官方已经明确计划在未来版本中改变这一行为(让 utf8 成为真正的 4 字节 utf8mb4 的别名)。现在使用 utf8 字符集的表,在 8.0 的错误日志中会有警告,而且未来升级时可能有兼容性问题。

建议在升级 8.0 之后,逐步把所有表的字符集从 utf8 迁移到 utf8mb4。在线迁移可以用 pt-online-schema-change 工具,对大表无需停服:

sql 复制代码
-- 检查哪些表还在用 utf8 字符集
SELECT table_name, table_collation
FROM information_schema.tables
WHERE table_schema = 'saas_demo'
  AND (table_collation LIKE 'utf8\_%' AND table_collation NOT LIKE 'utf8mb4%');

-- 修改表的字符集(小表可以直接 ALTER,大表用 pt-osc)
ALTER TABLE old_table
    CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

变更七:整型显示宽度语法废弃

INT(11)BIGINT(20) 中的括号数字在 8.0.17 之后被官方标记为废弃(仍然支持,但会在未来移除)。这不是破坏性变更(现有 SQL 仍然能运行),但在工具链(如某些 ORM 框架的 DDL 生成、DBeaver 的逆向工程)上可能触发警告或异常行为。

处理方式是在代码生成器、数据库迁移脚本(Flyway/Liquibase)里把显示宽度去掉,新建的表不再使用这个语法,存量的表在下次有 DDL 变更时顺手去掉:

sql 复制代码
-- 旧写法(5.7 时代遗留)
id    INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
count BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,

-- 新写法(8.0 推荐)
id    INT UNSIGNED NOT NULL AUTO_INCREMENT,
count BIGINT UNSIGNED NOT NULL DEFAULT 0,

变更八:YEAR(2) 类型完全移除

YEAR(2) 是一个存储两位年份的数据类型(如 24 代表 2024),已在 5.7.5 废弃,8.0 完全移除。如果你的历史数据库里还有 YEAR(2) 类型的列,升级前必须先迁移:

sql 复制代码
-- 查找所有 YEAR(2) 类型的列
SELECT table_name, column_name, column_type
FROM information_schema.columns
WHERE table_schema = 'saas_demo'
  AND column_type = 'year(2)';

-- 迁移:改为 YEAR 类型(四位年份)
ALTER TABLE your_table
    MODIFY COLUMN year_col YEAR NOT NULL COMMENT '改为四位年份';

变更九:SETENUM 类型中的 PAD_CHAR_TO_FULL_LENGTH 行为变化

这个变更比较冷门,但如果你的系统有用 SETENUM 类型做状态标记的历史遗留设计,需要验证一遍查询和比较的行为是否与 5.7 一致。

变更十:INFORMATION_SCHEMA 视图性能提升,但某些字段名变化

8.0 的 INFORMATION_SCHEMA 做了重构,某些视图的字段名称发生了变化(如 INNODB_LOCKS 改为 PERFORMANCE_SCHEMA.DATA_LOCKS)。如果你有监控脚本、运维工具、或者 Java 代码直接查询 INFORMATION_SCHEMA,需要逐一验证:

sql 复制代码
-- 5.7 的死锁和锁等待查询方式(8.0 中仍然兼容,但推荐用新方式)
-- 旧:SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS;
-- 旧:SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCK_WAITS;

-- 8.0 推荐的新方式
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;

变更十一:DEFAULT 值的严格校验

8.0 对某些列的默认值做了更严格的校验。最常见的问题是:NOT NULLBLOBTEXT 类型列在 5.7 中可以没有 DEFAULT 值,8.0 中会在某些操作下报错。还有 DATETIME 类型在某些配置下的 '0000-00-00 00:00:00' 默认值在严格模式下不再允许:

sql 复制代码
-- 检查哪些 NOT NULL 列没有 DEFAULT 值(潜在兼容问题)
SELECT table_name, column_name, column_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'saas_demo'
  AND is_nullable = 'NO'
  AND column_default IS NULL
  AND column_type IN ('text', 'mediumtext', 'longtext', 'blob', 'mediumblob', 'longblob');

变更十二:PASSWORD() 函数移除

PASSWORD() 函数在 5.7 被标记为废弃,8.0 完全移除。如果代码里有用 SET PASSWORD = PASSWORD('xxx') 的旧式密码修改方式,需要改为标准语法:

sql 复制代码
-- 旧(5.7 废弃,8.0 报错)
SET PASSWORD FOR 'user'@'%' = PASSWORD('newpassword');

-- 新(8.0 标准写法)
ALTER USER 'user'@'%' IDENTIFIED BY 'newpassword';

四、零停机升级操作手册

理论看完了,进入最有工程价值的部分:如何在不停服的情况下把生产环境的 MySQL 从 5.7 升级到 8.0。

这套方案的核心逻辑是:先在不影响服务的从库上做升级,充分验证后,在一个短暂的维护窗口内完成主库切换。整个过程中,主库一直在提供服务,只有最后的主库切换时有几秒到几十秒的写中断。

4.1 升级前:预检工具使用

做任何操作之前,先让官方工具替你扫描兼容性问题,比人工逐条检查更可靠也更高效:

bash 复制代码
# 方法一:MySQL Shell 的升级检查器(最推荐,覆盖面最广)
# 先安装 MySQL Shell(版本要和目标 8.0 版本匹配)

mysqlsh -- util checkForServerUpgrade \
    root@localhost:3306 \
    --outputFormat=JSON \
    --targetVersion=8.0.36 \
    2>&1 | tee upgrade_check_$(date +%Y%m%d).json

# 检查报告中重点关注以下级别:
# "level": "Error"   → 必须在升级前修复,否则升级后应用或 MySQL 会出错
# "level": "Warning" → 不影响升级,但升级后行为可能变化,需评估
# "level": "Notice"  → 建议优化项,可以后续处理

# 方法二:mysqlcheck 升级前检查(较旧,但广泛支持)
mysqlcheck --all-databases --check-upgrade -u root -p

预检工具跑完后,通常会发现十几到几十个 Warning,不要被吓到。逐一看清楚每条的含义,区分"真正需要修复的"和"只是建议优化的"。把需要修复的问题整理成清单,按优先级排序,在升级操作开始前全部处理完。

4.2 升级前:备份(无论多熟练都不能省略)

备份不是因为你不相信自己的操作,而是因为升级过程中任何一个不可预见的因素(磁盘故障、网络中断、版本 Bug)都可能让你需要从备份恢复。没有备份就开始升级,是对整个系统的不负责任:

bash 复制代码
# 使用 Percona XtraBackup 做全量物理备份(比 mysqldump 快得多,适合大库)
xtrabackup \
    --backup \
    --target-dir=/backup/pre-upgrade-$(date +%Y%m%d_%H%M) \
    --user=backup_user \
    --password="${BACKUP_PASSWORD}" \
    --compress \
    --compress-threads=4 \
    --parallel=4

# 备份完成后,验证备份是否完整(这一步经常被跳过,强烈建议不要省略)
xtrabackup --prepare --target-dir=/backup/pre-upgrade-$(date +%Y%m%d_%H%M)
# prepare 成功意味着备份是可以恢复的,否则备份本身就是坏的

# 同时做一份 mysqldump(逻辑备份,体积大但可以精确恢复单张表)
mysqldump \
    --all-databases \
    --single-transaction \
    --master-data=2 \
    --flush-logs \
    -u root -p \
    | gzip > /backup/full-dump-$(date +%Y%m%d_%H%M).sql.gz

4.3 Phase 1:从库滚动升级

这个阶段的目标是把所有从库升级到 8.0,同时验证应用在 8.0 上的行为正常。主库在这整个过程中保持 5.7,继续提供服务。

bash 复制代码
# ── 在目标从库上执行 ──────────────────────────────────

# Step 1:停止复制,断开与主库的连接
mysql -uroot -p -e "STOP SLAVE;"   # 8.0 中改为 STOP REPLICA

# Step 2:停止 MySQL 服务
systemctl stop mysqld

# Step 3:替换 MySQL 二进制文件(具体方式取决于安装方式)
# 方式 A:使用包管理器(yum/apt)
yum install -y mysql-community-server-8.0.36

# 方式 B:使用预编译包
# 替换 /usr/local/mysql 下的二进制文件

# Step 4:启动 MySQL 8.0
systemctl start mysqld

# Step 5:执行升级(MySQL 8.0.16+ 在启动时自动完成,无需手动执行)
# 如果是 8.0.16 之前的版本,需要手动执行:
# mysql_upgrade -u root -p

# Step 6:重启使升级生效(8.0.16+ 自动升级后建议重启一次)
systemctl restart mysqld

# Step 7:恢复复制
mysql -uroot -p -e "START REPLICA;"

# Step 8:验证复制状态
mysql -uroot -p -e "SHOW REPLICA STATUS\G"
# 检查:Replica_IO_Running = Yes,Replica_SQL_Running = Yes
#        Seconds_Behind_Source 趋近于 0(复制追上了主库)

从库升级完成后,不要急着做主库切换。把升级后的从库接入负载均衡,让真实的读流量打进来,观察至少 24 小时(甚至一周):查询结果是否正确、性能是否正常、错误日志里有没有异常。这段观察期是发现"隐藏兼容性问题"的关键窗口------很多问题在功能测试时发现不了,只有真实流量才能触发。

4.4 Phase 2:Java 应用适配与验证

从库升级完成后,趁着流量在 8.0 从库上跑,同步做 Java 应用的适配和验证:

xml 复制代码
<!-- pom.xml:更新 JDBC 驱动 -->
<!-- 移除旧依赖 -->
<!-- <dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency> -->

<!-- 添加新依赖 -->
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>8.0.33</version>
</dependency>

驱动升级后,应用的行为可能有细微变化,需要逐一验证。比如 com.mysql.cj.jdbc.Driver 默认会在连接串没有 serverTimezone 时抛出异常(旧驱动不要求这个参数),JDBC URL 需要补全所有必要参数。还有 useSSL=false 在新驱动下如果没有明确设置,会产生 SSL 相关的 WARNING 日志(不影响运行,但日志噪音很大),建议在 URL 里明确声明。

4.5 Phase 3:主库切换(需要一个短暂的维护窗口)

这是整个升级过程中唯一有写中断风险的步骤。策略是:选择业务最低峰的时间段(通常凌晨 2:00 ~ 4:00),把写流量临时切到只读模式,等复制追平后完成主库切换:

bash 复制代码
# ── 在主库(仍然是 5.7)执行 ────────────────────────────

# Step 1:通知业务方,发布维护公告,关闭或限流写入请求
# (在应用层配置:拒绝写操作请求,返回"系统维护中"提示)

# Step 2:确认所有从库的复制延迟降为 0
mysql -uroot -p -e "SHOW REPLICA STATUS\G"  # 在每台从库上查

# Step 3:在主库上刷新并锁定(防止新的写入)
mysql -uroot -p -e "FLUSH TABLES WITH READ LOCK;"

# 记录此刻的 Binlog 位置(后续从库升级需要用到)
mysql -uroot -p -e "SHOW MASTER STATUS\G"

# Step 4:再次确认所有从库已追平
# Seconds_Behind_Source = 0 且 Exec_Source_Log_Pos 等于上面记录的位置

# Step 5:在选定的从库(即将成为新主库)上停止复制,并重置为主库
mysql -uroot -p -e "STOP REPLICA;"
mysql -uroot -p -e "RESET MASTER;"

# Step 6:修改应用的数据库连接配置,指向新主库
# (通常是修改配置中心/ConfigMap 里的 DB URL,触发应用重新加载连接池)

# Step 7:在原主库上解除锁定(它将成为新主库的从库)
mysql -uroot -p -e "UNLOCK TABLES;"

# Step 8:把原主库配置为跟随新主库的从库
# 升级原主库到 8.0(按 Phase 1 的步骤),然后:
mysql -uroot -p -e "
CHANGE REPLICATION SOURCE TO
    SOURCE_HOST='new-primary-host',
    SOURCE_USER='repl_user',
    SOURCE_PASSWORD='repl_password',
    SOURCE_AUTO_POSITION=1;   -- GTID 模式下使用
START REPLICA;
"

整个切换过程中,写中断时间取决于第三步到第六步之间的操作速度,通常在 30 秒到 2 分钟之间。在维护窗口内这是可以接受的,关键是提前通知到所有相关方,并在切换后立刻验证应用的写操作是否恢复正常。

4.6 升级后验证清单

主库切换完成后,不要急着宣布升级成功。按以下清单逐项验证,确认一切正常:

bash 复制代码
# 1. 检查所有节点的 MySQL 版本
mysql -uroot -p -e "SELECT VERSION();"
# 期望:所有节点都是 8.0.x

# 2. 检查主从复制状态
mysql -uroot -p -e "SHOW REPLICA STATUS\G"
# 期望:Replica_IO_Running=Yes, Replica_SQL_Running=Yes, Seconds_Behind_Source≈0

# 3. 检查错误日志(升级后 24 小时内持续关注)
tail -f /var/log/mysql/error.log
# 关注任何 ERROR 或 WARNING 级别的日志

# 4. 运行核心业务的端到端测试用例
# 至少覆盖:用户登录、核心 CRUD、分页查询、报表统计

# 5. 检查慢查询日志(升级后某些查询计划可能变化)
tail -f /var/log/mysql/slow.log
# 观察是否有升级前没有出现的新慢查询

五、Java 代码全链路适配清单

升级 MySQL 8.0 之后,Java 侧需要适配的点不只是驱动版本。以下清单覆盖了所有需要检查的地方:

java 复制代码
// ── 检查点一:JDBC URL 参数完整性 ─────────────────────────────
// 旧(5.7 时代的典型 URL):
// jdbc:mysql://host:3306/db?useSSL=false&characterEncoding=utf8
//
// 新(8.0 推荐的完整 URL):
// jdbc:mysql://host:3306/db
//   ?useUnicode=true
//   &characterEncoding=utf8mb4      ← 从 utf8 改为 utf8mb4
//   &serverTimezone=Asia/Shanghai   ← 8.x 驱动强制要求显式指定时区
//   &allowPublicKeyRetrieval=true   ← caching_sha2_password 认证需要
//   &useSSL=false                   ← 内网环境关闭 SSL,生产建议开启
//   &rewriteBatchedStatements=true  ← 批量写入性能优化(一直应该加)

// ── 检查点二:Driver 类名变更 ─────────────────────────────────
// 旧:com.mysql.jdbc.Driver(5.x 驱动)
// 新:com.mysql.cj.jdbc.Driver(8.x 驱动,注意中间多了 .cj)
// 如果用 spring.datasource.driver-class-name,必须更新

// ── 检查点三:Hibernate Dialect ──────────────────────────────
// 旧(Spring Boot 2.x + Hibernate 5):
// spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
// 或 MySQL5InnoDBDialect
//
// 新:
// spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
// Spring Boot 3.x 通常能自动检测,但明确指定更稳妥

适配工作中有一个容易被忽视的细节:HibernateDialect 影响的不只是 DDL 生成,还影响某些函数的翻译方式(比如 CONCAT 函数在不同 Dialect 下的行为),以及自动建表时的存储引擎和字符集选择。升级后建议在开发环境开启 spring.jpa.show-sql=true,观察 Hibernate 生成的 SQL 是否符合预期,再上生产。


六、用 AI 辅助批量扫描兼容性问题

前面提到的每一个破坏性变更,都需要扫描代码库里所有的 SQL。对于有数百个 Mapper XML 文件的大型 SaaS 项目,人工逐一检查不现实。这是 LLM 能真正发挥价值的场景------批量处理重复性的模式识别工作:

java 复制代码
@Service
@Slf4j
public class UpgradeCompatibilityScanner {

    @Autowired
    private ChatClient chatClient;

    /**
     * 批量扫描 SQL 片段,检测 MySQL 8.0 兼容性问题。
     *
     * 使用方式:从 Mapper XML 文件中提取所有 SQL 语句,
     * 分批(每批 10-20 条)传入此方法,汇总所有结果。
     *
     * 为什么要分批?
     * LLM 的上下文窗口有限,一次传入太多 SQL 会导致分析质量下降,
     * 而且单次请求时间过长。每批 10-20 条是实测效果较好的批量大小。
     */
    public List<CompatibilityIssue> scanBatch(List<String> sqlSnippets) {
        if (sqlSnippets == null || sqlSnippets.isEmpty()) {
            return Collections.emptyList();
        }

        // 把多条 SQL 编号后合并成一个请求,减少 API 调用次数
        StringBuilder sqlList = new StringBuilder();
        for (int i = 0; i < sqlSnippets.size(); i++) {
            sqlList.append("[SQL-").append(i + 1).append("]\n");
            sqlList.append(sqlSnippets.get(i).trim()).append("\n\n");
        }

        String prompt = """
            你是 MySQL 5.7 → 8.0 升级专家。请逐一检查以下编号 SQL,
            识别每条 SQL 中存在的 MySQL 8.0 兼容性问题。
            
            重点检查以下类型(其他类型也要指出):
            1. GROUP BY 非聚合列(8.0 ONLY_FULL_GROUP_BY 严格模式)
            2. 依赖 GROUP BY 隐式排序(缺少 ORDER BY)
            3. 使用了 PASSWORD() 函数
            4. 使用了 utf8 字符集(而非 utf8mb4)
            5. 使用了整型显示宽度(INT(11) 等)
            6. 含有 8.0 新增保留字(如 rank、groups、system)作为列名或别名
            7. 使用了 NOW() 等非确定性函数但格式为 STATEMENT binlog(只能提示,无法确定)
            8. 子查询写法可用 8.0 窗口函数大幅优化(标记为优化建议,不是错误)
            
            SQL 列表:
            %s
            
            以 JSON 数组格式返回,每个元素对应一条 SQL:
            [
              {
                "sqlId": "SQL-1",
                "hasIssue": true,
                "severity": "ERROR|WARNING|INFO",
                "issues": ["问题描述1", "问题描述2"],
                "fixSuggestion": "修复建议"
              },
              ...
            ]
            
            没有问题的 SQL 仍然要返回条目,设 hasIssue=false。
            只返回 JSON 数组,不要加任何额外说明或 markdown 代码块标记。
            """.formatted(sqlList.toString());

        try {
            String response = chatClient.prompt(prompt).call().content().trim();
            // 如果 LLM 加了 markdown 代码块,去掉它
            if (response.startsWith("```")) {
                response = response.replaceAll("```json\\n?", "").replaceAll("```\\n?", "").trim();
            }

            ObjectMapper mapper = new ObjectMapper();
            List<Map<String, Object>> results = mapper.readValue(response,
                new TypeReference<>() {});

            return results.stream()
                .filter(r -> Boolean.TRUE.equals(r.get("hasIssue")))
                .map(r -> new CompatibilityIssue(
                    (String) r.get("sqlId"),
                    (String) r.get("severity"),
                    (List<String>) r.get("issues"),
                    (String) r.get("fixSuggestion")
                ))
                .collect(Collectors.toList());

        } catch (Exception e) {
            log.error("SQL 兼容性扫描解析失败,原始响应:{}", e.getMessage());
            return Collections.emptyList();
        }
    }
}

这个扫描工具的实际使用方式是:写一个 main 方法,扫描项目的 resources/mapper 目录下所有 XML 文件,用正则提取 <select><update> 等标签里的 SQL 内容,分批调用上面的 scanBatch 方法,把所有 severity=ERROR 的结果写入报告文件。一个中型 SaaS 项目(100 个 Mapper,500 条 SQL),跑完整个扫描通常在 5 分钟以内,能覆盖到人工 Review 很难发现的细节问题。


七、本篇小结与下篇预告

这是本系列篇幅最长、信息密度最高的一篇。核心内容可以用三句话概括:升级是必须做的事,越晚越难;8.0 的新特性(窗口函数、CTE、函数索引)是把某类"只能在应用层做"的逻辑还给数据库层的质变;破坏性变更(尤其是认证插件和排序规则)是升级失败的高发区,必须用工具扫描和逐一验证,不能靠感觉。

如果你现在已经决定要做升级,建议把本篇第三节的"十二个破坏性变更"打印出来贴在白板上,作为升级前的检查清单,逐条打勾确认。每一条背后都是真实的踩坑案例,不要等到生产告警才发现。

第 09 篇预告 :MySQL 性能全链路调优------参数 · Buffer Pool · Profiling。我们会讲清楚 innodb_buffer_pool_size 的科学设置方法(不是"越大越好"那么简单)、Redo Log 大小对写入性能的影响、SHOW PROFILE 和 Performance Schema 的对比使用、连接数参数的合理范围,以及 SaaS 系统在 K8s 容器环境下调优的特殊注意点(cgroups 内存感知问题)。


FAQ

Q:MySQL 8.0 和 8.4 的差距有多大,直接升 8.4 好不好?

A:MySQL 8.4 是 2024 年 4 月发布的 LTS(长期支持)版本,相比 8.0 做了一些清理(移除了更多废弃特性,比如 mysql_native_password 插件默认被禁用)。如果你现在从 5.7 升级,目标选 8.0(如 8.0.36,稳定且生态支持最广)或 8.4 LTS 都可以。选 8.0 的理由是生产验证时间更长、第三方工具兼容性更好;选 8.4 的理由是获得更长的官方支持周期。两者在功能上大同小异,对于 Java SaaS 的日常开发需求,8.0.36 完全足够。

Q:升级失败了怎么回滚?

A:这正是"先升从库,保留原主库"策略的价值所在。如果升级后发现严重问题,只需要把应用的数据库连接切回原主库(5.7),原主库从头到尾都没有被升级,数据也是完整的。需要注意的是:MySQL 不支持降级(从 8.0 降回 5.7),一旦 8.0 的数据文件被写入,这些数据就无法再被 5.7 读取。所以回滚只能切回原主库,不能"把 8.0 的从库降级为 5.7"。这再次强调了:在切主库之前,原主库要完整保留。

Q:升级后某些查询变慢了,第一步应该怎么排查?

A:首先执行 ANALYZE TABLE 更新统计信息------8.0 的优化器对统计信息的依赖比 5.7 更强,旧统计信息在新版本下可能导致次优的执行计划。其次,对比升级前后该查询的 EXPLAIN 输出,看执行计划是否发生变化(用了不同的索引,或者 JOIN 顺序变了)。如果确认是优化器选错了执行计划,用第 07 篇介绍的 FORCE INDEX 或 Optimizer Hint 临时修复,同时分析根本原因(是否需要更新索引设计或收集更精确的统计信息)。


这篇文章信息量很大,建议收藏后在实际升级项目中对照使用。有任何实际升级中遇到的问题,欢迎在评论区留言,我会逐一回复。