数据库基础体系 · 第 93/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

MySQL 升级与迁移:兼容检查、DDL、字符集、灰度和回滚

MySQL 升级和迁移经常被误解为“把数据复制到新服务器,再把应用指向新地址”。实际上,它至少同时改变了四类状态:

  1. 服务器软件状态:版本、默认参数、SQL mode、认证插件和系统表结构。
  2. 数据字典与表结构状态:表、索引、约束、存储过程、触发器和事件。
  3. 数据表示状态:字符集、排序规则、时间类型、隐式类型转换和索引字节数。
  4. 业务运行状态:应用读写协议、部署批次、复制链路、连接池和回滚路径。

因此,一个迁移方案必须回答四个问题:

  • 新版本能否正确解释旧版本的对象和数据?
  • 表结构变更会不会阻塞线上事务,或者耗尽磁盘?
  • 新旧应用能否在一段时间内同时运行?
  • 失败后,能否恢复到一个已知且可验证的状态?

本文以 MySQL 8.4 的公开语义为主,示例主要针对 InnoDB、事务表、单主复制或独立实例。具体升级路径仍必须以目标版本官方升级文档、发行版说明和实际测试结果为准。


一、先区分四种动作:升级、迁移、变更和回滚

1. 版本升级

版本升级是让同一份数据由不同版本的 MySQL Server 继续管理。例如:

旧 MySQL 实例
    └── 停机或受控切换
            ↓
新版本 MySQL 实例读取原有数据

升级需要检查:

  • 数据目录是否能被目标版本识别;
  • 系统表和数据字典是否需要转换;
  • SQL 语义、默认值和字符集是否变化;
  • 认证插件、用户权限和客户端协议是否兼容;
  • 存储引擎、插件和备份工具是否支持目标版本。

升级并不等价于“二进制文件替换”。数据目录是由特定版本解释的持久化状态,不能仅凭文件路径相同就认为可以跨版本启动。

2. 数据迁移

迁移是把数据从源实例复制到目标实例,通常目标可以是:

  • 不同机器;
  • 不同磁盘或云服务;
  • 不同拓扑;
  • 不同版本;
  • 不同字符集或表结构。

迁移可以使用逻辑备份、物理备份、复制、存储引擎级传输或专用迁移工具。不同方法的兼容边界不同:

方法 复制的对象 主要优点 主要边界
逻辑备份 SQL、表数据、对象定义 跨平台、跨版本能力较强 慢,恢复期间需要重建索引
物理备份 数据文件、表空间、日志 快,适合大数据量 对版本、平台、配置和存储引擎要求高
基于 Binlog 的复制 数据变更事件 可缩短停机时间 需要兼容复制协议、对象和语句语义
备份加 Binlog PITR 某个基线加之后的变更 可恢复到时间点 必须保存完整、连续且可验证的日志

3. DDL 变更

DDL 是 CREATEALTERDROPRENAME 等改变数据库对象定义的操作。DDL 不只是“修改元数据”:

  • 可能获取元数据锁;
  • 可能重建整张表;
  • 可能扫描和回写所有行;
  • 可能使用大量临时磁盘;
  • 通常会隐式提交事务;
  • 即使底层具有原子 DDL,也不代表业务可以对它执行普通事务回滚。

4. 回滚

回滚有三个不同含义:

  1. 语句回滚:某条 DML 因错误被事务回滚。
  2. 部署回滚:应用版本退回旧版本。
  3. 数据库状态回滚:把数据和结构恢复到迁移前或某个时间点。

前两者通常可以独立完成,第三者最困难。特别是当新版本已经写入旧版本无法理解的数据或结构时,“把应用镜像切回去”并不能恢复数据库兼容性。


二、升级前的兼容性模型

一次升级真正需要验证的不是“版本号是否更大”,而是下面这个条件:

C=ODSARC = O \land D \land S \land A \land R

其中:

  • OO:对象兼容,表、索引、视图、触发器、存储过程和事件可以被目标版本解析;
  • DD:数据兼容,现有数据能被目标版本按预期读取和比较;
  • SS:语义兼容,SQL mode、排序规则、隐式转换等行为满足应用假设;
  • AA:应用兼容,客户端、驱动、认证方式和连接参数可用;
  • RR:恢复兼容,备份、Binlog 和恢复流程在目标环境中可执行。

其中任一条件不成立,升级都可能“启动成功但业务错误”。

1. 对象兼容

需要检查:

  • 使用了目标版本保留字作为表名、列名或别名;
  • 旧版本允许而新版本拒绝的语法;
  • 已移除或默认禁用的系统变量;
  • 存储过程、函数、触发器和事件中的 SQL;
  • 视图的定义与依赖表;
  • 生成列、函数索引、表达式默认值;
  • 外键、全文索引和空间索引;
  • 非 InnoDB 表,例如 MyISAM。

可以先获取对象清单:

SELECT TABLE_SCHEMA, TABLE_NAME, ENGINE, TABLE_ROWS
FROM information_schema.TABLES
WHERE TABLE_SCHEMA NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys')
ORDER BY TABLE_SCHEMA, TABLE_NAME;

预期结果是得到业务表及其存储引擎。这里的 TABLE_ROWS 对 InnoDB 通常是估算值,不能用它作为精确行数证明。

检查非 InnoDB 表:

SELECT TABLE_SCHEMA, TABLE_NAME, ENGINE
FROM information_schema.TABLES
WHERE ENGINE IS NOT NULL
  AND ENGINE <> 'InnoDB'
  AND TABLE_SCHEMA NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys');

如果结果非空,不能直接套用“事务表在线迁移”的判断。比如:

  • mysqldump --single-transaction 主要保证 InnoDB 一致性;
  • MyISAM 表不受 InnoDB MVCC 保护;
  • 非事务表上的写入可能需要锁表或停写窗口;
  • 复制和恢复时的原子性也不同。

MySQL Shell 提供升级检查工具时,应针对实际目标版本运行升级检查,而不是仅检查客户端能否连接。例如在 MySQL Shell 中,形式类似:

util.checkForServerUpgrade(
  "user@host:3306",
  {targetVersion: "8.4.0"}
)

实际运行前需要根据环境提供认证信息、TLS 参数和权限。检查器报告的是已知规则,不是对业务正确性的形式化证明;应用 SQL、数据分布、性能和回滚仍需单独验证。

2. SQL mode 和隐式语义

同一条 SQL 在不同 SQL mode 下可能得到不同结果。例如:

  • 非法日期可能被拒绝,也可能被转换;
  • 超长字符串可能报错,也可能被截断并产生 warning;
  • 除零、无效数字和类型转换可能改变行为;
  • GROUP BY、保留字和默认值的限制可能变化。

因此不能只比较:

SELECT VERSION();

还应记录关键运行变量:

SELECT
    @@version,
    @@version_comment,
    @@sql_mode,
    @@character_set_server,
    @@collation_server,
    @@time_zone,
    @@lower_case_table_names;

lower_case_table_names 尤其不能在已有数据目录上随意改变。它涉及表名大小写的存储和比较方式,且通常与操作系统文件系统行为相关。Linux 与 Windows 之间直接搬运文件时,表名大小写是高风险边界。

3. 客户端和认证兼容

服务器升级后,应用可能不是因为 SQL 失败,而是因为认证失败:

  • 账号使用的认证插件发生变化;
  • 旧客户端或驱动不支持目标认证方式;
  • TLS 配置、证书校验或默认加密要求不同;
  • 连接池在握手阶段失败。

应在测试环境使用生产同版本驱动和连接参数验证:

mysql \
  --host=db-new.example.com \
  --port=3306 \
  --user=app \
  --password \
  --ssl-mode=VERIFY_IDENTITY \
  --ssl-ca=/path/to/ca.pem \
  -e 'SELECT 1, CURRENT_USER(), @@version;'

SELECT 1 只说明连接成功,不说明业务 SQL 兼容。至少还要执行:

  • 事务开始、提交和回滚;
  • 预编译语句;
  • 关键查询;
  • 批量写入;
  • 字符集输入输出;
  • 连接池重连。

4. 升级路径不是任意跳跃

不能因为目标版本能够读取某些旧数据,就推断所有旧版本都能直接升级。官方支持的升级路径、数据字典转换和中间版本要求可能不同。

例如从较旧的大版本升级到 MySQL 8.4,可能需要先经过官方支持的中间版本。正确流程是:

  1. 查阅目标版本的升级路径和限制;
  2. 在与生产相同的备份副本上演练;
  3. 验证启动、数据字典检查和应用读写;
  4. 验证失败后的恢复;
  5. 再安排生产切换。

“先启动看看”不是升级演练,因为启动成功只覆盖了极少数兼容条件。


三、备份不是回滚:先建立可恢复基线

升级和迁移前应形成一个可验证的恢复基线:

全量备份 B0
    + 连续 Binlog L(t)
    → 可恢复到 B0 之后的任意时间点

假设全量备份完成时间为 t0t_0,需要恢复到 t1t_1,则必须满足:

Binlog coverage[t0,t1]\text{Binlog coverage} \supseteq [t_0, t_1]

也就是说,从备份一致性点开始,到目标恢复时间之间的 Binlog 必须连续可用。

1. InnoDB 逻辑备份

典型逻辑备份命令:

mysqldump \
  --host=source \
  --user=backup \
  --password \
  --single-transaction \
  --routines \
  --events \
  --triggers \
  --hex-blob \
  --all-databases \
  --source-data=2 \
  > full.sql

关键选项的含义:

  • --single-transaction:对事务表建立一致性读,通常不需要长时间锁住数据;
  • --routines:导出存储过程和函数;
  • --events:导出事件;
  • --triggers:导出触发器;
  • --hex-blob:以十六进制形式处理二进制列;
  • --source-data=2:把源 Binlog 位点信息写入注释,便于建立复制或恢复定位。

边界包括:

  • --single-transaction 不会让非事务表获得一致性快照;
  • 导出期间执行 DDL 可能导致元数据锁等待或对象定义与数据处理时序复杂;
  • 逻辑恢复会重新执行建表、插入和建索引,耗时与临时空间不可忽略;
  • 只导出表数据而漏掉触发器、事件、权限或存储过程,会得到“数据看似完整、行为不完整”的数据库。

2. 物理备份

物理备份复制的是 InnoDB 数据文件、重做日志等底层状态,恢复速度通常更好,但兼容条件更严格。不能把任意数据目录压缩后复制到另一台机器就称为物理迁移。

需要确认:

  • 源和目标的 MySQL 版本、补丁级别和升级路径;
  • CPU 架构、操作系统和文件系统;
  • InnoDB 配置和表空间布局;
  • 加密密钥是否一并保存;
  • 备份工具是否支持目标版本;
  • 恢复后是否执行过一致性检查和业务校验。

3. 恢复演练的验收标准

一次备份只有在恢复成功后才是可用备份。演练至少要记录:

  • 恢复开始和结束时间;
  • 恢复到哪个 Binlog 位点或时间点;
  • 表数量、关键表行数或校验摘要;
  • 关键查询结果;
  • 用户、权限、事件和触发器;
  • 应用连接和写入结果;
  • 恢复所需的磁盘、内存和临时空间。

四、字符集迁移:编码、排序和索引是三个问题

字符集决定“字符如何编码”,排序规则决定“字符如何比较、排序和判断相等”。二者不能混为一谈。

例如:

CREATE TABLE user_name (
    name VARCHAR(100)
        CHARACTER SET utf8mb4
        COLLATE utf8mb4_0900_ai_ci
);

这里:

  • utf8mb4 允许使用完整 Unicode 范围,包括辅助平面字符;
  • utf8mb4_0900_ai_ci 是一种不区分重音、通常也不区分大小写的排序规则;
  • VARCHAR(100) 的 100 是字符数,不是字节数;
  • 唯一索引的相等判断受排序规则影响。

1. 为什么 utf8 不一定是完整 Unicode

在 MySQL 语境中,历史上的 utf8 通常对应最多三个字节的 UTF-8 编码,即 utf8mb3 语义;它不能表示所有 Unicode 字符。要存储完整 Unicode,应明确使用:

CHARACTER SET utf8mb4

不要只根据列名或应用代码中的“UTF-8”判断数据库实际编码。应查询:

SELECT
    TABLE_SCHEMA,
    TABLE_NAME,
    COLUMN_NAME,
    CHARACTER_SET_NAME,
    COLLATION_NAME,
    DATA_TYPE,
    CHARACTER_MAXIMUM_LENGTH,
    COLUMN_TYPE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'app'
  AND DATA_TYPE IN ('char', 'varchar', 'text', 'tinytext', 'mediumtext', 'longtext');

还要查看表级和数据库级默认值:

SELECT
    SCHEMA_NAME,
    DEFAULT_CHARACTER_SET_NAME,
    DEFAULT_COLLATION_NAME
FROM information_schema.SCHEMATA
WHERE SCHEMA_NAME = 'app';

SHOW CREATE TABLE app.user_name;

2. 转换是否安全取决于方向

utf8mb3 转为 utf8mb4,通常是扩展可表示范围,不会因为目标编码更窄而丢失字符。

反过来,从 utf8mb4 转为 latin1,可能丢失字符:

"café"       → 可能可表示
"你好"       → 无法由 latin1 表示
"😀"         → 无法由 latin1 表示

在严格模式下,转换可能报错;在非严格模式或某些写入路径下,可能产生警告或替换字符。不能只检查 DDL 是否成功,还要检查数据是否保持。

可以先建立候选检测逻辑。下面示例针对 app.customer.nickname,目标假设为 latin1,使用二进制比较减少排序规则干扰:

SELECT id, nickname
FROM app.customer
WHERE nickname IS NOT NULL
  AND CONVERT(
        CONVERT(nickname USING latin1)
        USING utf8mb4
      ) COLLATE utf8mb4_bin
      <> nickname COLLATE utf8mb4_bin
LIMIT 100;

其推导过程是:

  1. CONVERT(nickname USING latin1):模拟目标编码;
  2. 再转回 utf8mb4:得到目标编码能够保留的结果;
  3. 与原值按二进制语义比较;
  4. 不相等的行是潜在丢失或替换候选。

这不是所有非法字节和排序差异的万能检测器。生产迁移还应针对真实数据抽样,检查:

  • emoji 和四字节字符;
  • 组合字符;
  • 中日韩字符;
  • 特殊符号;
  • 无效字节;
  • 业务中用于唯一标识的字符串。

3. 排序规则会改变唯一性和查询结果

假设表上有唯一索引:

CREATE TABLE account (
    login VARCHAR(100)
        CHARACTER SET utf8mb4
        COLLATE utf8mb4_0900_ai_ci
        NOT NULL,
    UNIQUE KEY uk_login (login)
);

在不区分大小写的排序规则下,Alicealice 可能被视为相等,因此第二次插入可能失败:

INSERT INTO account(login) VALUES ('Alice');
INSERT INTO account(login) VALUES ('alice');

如果业务要求大小写敏感,应该明确选择二进制或大小写敏感排序规则,而不是依赖默认值:

CREATE TABLE api_key (
    value VARCHAR(128)
        CHARACTER SET utf8mb4
        COLLATE utf8mb4_bin
        NOT NULL,
    UNIQUE KEY uk_value (value)
);

迁移时改变排序规则,可能导致:

  • 原本可同时存在的两行变成重复键;
  • 原本相等的查询变成不相等;
  • ORDER BY 顺序变化;
  • 索引范围扫描结果和分页边界变化;
  • 唯一索引重建失败。

4. 字符集会影响索引字节长度

索引限制按字节计算,而不是按字符数计算。对于 VARCHAR(255)

latin1:    最多约 255 字节
utf8mb4:   最多约 255 × 4 = 1020 字节

因此,扩大字符集可能使原有复合索引超过索引键长度限制。例如:

CREATE TABLE document (
    tenant_id BIGINT NOT NULL,
    title VARCHAR(255) NOT NULL,
    UNIQUE KEY uk_tenant_title (tenant_id, title)
) ENGINE=InnoDB;

utf8mb4 下,复合索引最大长度还要加上 tenant_id 的字节数、长度字段和索引内部开销。不能用“255 个字符小于 3072”简单替代实际检查。

可选处理方式包括:

  • 缩短列的最大长度;
  • 使用前缀索引,但要接受前缀索引不能完整保证长字符串唯一;
  • 把业务唯一性转化为哈希列加原值校验;
  • 重新设计索引;
  • 拆分查询条件和排序条件。

例如前缀索引:

CREATE INDEX idx_title_prefix
ON document (title(100));

它能帮助部分前缀查询,但不等价于完整 title 索引。若用于唯一约束,两个前缀相同但后缀不同的值也会冲突。

5. 字符集迁移的安全步骤

一个可验证的流程是:

  1. 盘点库、表、列、连接和应用协议的字符集;
  2. 找出非目标字符集的列;
  3. 检测无法表示、会碰撞或会改变排序的值;
  4. 检查索引字节长度;
  5. 在副本上执行转换;
  6. 对比转换前后的样本和摘要;
  7. 再处理应用连接字符集;
  8. 最后统一数据库和表的默认值。

应用连接也必须明确设置。服务器默认字符集改变,并不能修复一个仍使用错误连接字符集的客户端。


五、DDL 的真实行为:算法、锁和事务边界

对 InnoDB 表执行 ALTER TABLE 时,最重要的不是 SQL 看起来短不短,而是服务器选择了什么执行算法。

1. COPY、INPLACE 和 INSTANT

可以把三种算法理解为不同的数据流:

COPY

旧表 → 创建临时表 → 复制数据和索引 → 替换旧表

它通常需要扫描整张表,消耗磁盘和 I/O,并可能长时间影响写入。

INPLACE

INPLACE 不一定意味着“不复制数据”。它表示不通过服务器层的完整临时表拷贝完成,但具体操作仍可能重建索引、重排记录或扫描表。

因此:

INPLACE ≠ 无锁
INPLACE ≠ 无 I/O
INPLACE ≠ 一定瞬时完成

INSTANT

INSTANT 主要通过修改元数据完成支持的变更,不重建整张表。它速度快、数据搬运少,但只适用于特定 DDL。若操作不被支持,明确要求 ALGORITHM=INSTANT 时应失败,而不是悄悄退化为更重的算法。

推荐在风险敏感场景显式指定:

ALTER TABLE app.orders
    ADD COLUMN source VARCHAR(32) NULL,
    ALGORITHM=INSTANT;

如果目标版本和当前表结构不支持该操作,预期结果是语句报错,表不会因为算法自动降级而产生意外长任务。

2. LOCK=NONE 不是无锁

可以指定:

ALTER TABLE app.orders
    ADD COLUMN source VARCHAR(32) NULL,
    ALGORITHM=INSTANT,
    LOCK=NONE;

LOCK=NONE 主要限制表级并发访问方式,不会消除元数据锁(Metadata Lock,MDL)。

DDL 执行前需要获取目标表的 MDL。一个长期未提交的事务,即使没有更新该表,也可能持有阻止 DDL 获取排他元数据锁的状态:

事务 T1:BEGIN;执行 SELECT;长时间不 COMMIT
事务 T2:ALTER TABLE ...
事务 T2:等待 MDL
新的事务:可能在 DDL 队列后继续等待

因此,一个“瞬时”的 INSTANT DDL 也可能因为 MDL 阻塞几十分钟。

排查阻塞时可先看:

SHOW PROCESSLIST;

再查看 Performance Schema 中的元数据锁:

SELECT
    OBJECT_SCHEMA,
    OBJECT_NAME,
    LOCK_TYPE,
    LOCK_DURATION,
    LOCK_STATUS,
    OWNER_THREAD_ID
FROM performance_schema.metadata_locks
WHERE OBJECT_SCHEMA = 'app'
  AND OBJECT_NAME = 'orders';

结果中的关键关系是:

  • PENDING:线程正在等待;
  • GRANTED:线程已经持有锁;
  • 持有锁的线程不一定正在执行明显的 SQL,可能只是事务未提交。

还应结合事务信息、连接来源和应用请求定位真正的长事务,而不是直接杀掉排队的 DDL。杀掉 DDL 可能只是释放等待者,根因仍然存在。

3. DDL 和事务不是普通的可回滚关系

下面的直觉是错误的:

START TRANSACTION;
ALTER TABLE app.orders ADD COLUMN source VARCHAR(32);
ROLLBACK;

不能据此认为列一定会被回滚。MySQL 中许多 DDL 会隐式提交事务,DDL 的原子性是服务器保证崩溃恢复时对象状态不出现半成品,不等于用户可以用 ROLLBACK 撤销结构变更。

因此:

  • DML 可以使用事务回滚;
  • DDL 的失败通常由语句自身处理;
  • DDL 成功后应使用反向 DDL、备份恢复或重新发布来撤销;
  • 不能把 ALTER TABLE 和业务更新放在一个事务里设计成“要么全成、要么全回滚”。

4. 先验证算法,再执行生产 DDL

可以先在同版本、相似表结构的测试副本上执行:

ALTER TABLE app.orders
    ADD COLUMN source VARCHAR(32) NULL,
    ALGORITHM=INSTANT,
    LOCK=NONE;

验证:

SHOW CREATE TABLE app.orders;
SHOW WARNINGS;

如果使用的变更不支持 INSTANT,应改为明确评估 INPLACECOPY 的资源和锁风险,而不是删除 ALGORITHM 让服务器自行选择。省略算法意味着把选择权交给服务器,版本、表结构和具体 DDL 可能导致不同结果。


六、Expand-Contract:让结构变更与应用发布解耦

线上无法停机时,最重要的原则是:

新旧应用在迁移窗口内都必须能访问当前数据库状态。

这要求结构变更采用 Expand-Contract,而不是一次性把旧结构改成新结构。

1. 反例:直接重命名列

原表:

CREATE TABLE app.user_profile (
    id BIGINT PRIMARY KEY,
    nickname VARCHAR(100) NOT NULL
) ENGINE=InnoDB;

旧应用执行:

SELECT nickname FROM app.user_profile WHERE id = ?;

如果先执行:

ALTER TABLE app.user_profile
    RENAME COLUMN nickname TO display_name;

旧应用会立即收到:

ERROR 1054 (42S22): Unknown column 'nickname' in 'field list'

这不是灰度,而是制造了一个必然的兼容窗口。

2. 正确的 Expand 阶段

先增加新列,保留旧列:

ALTER TABLE app.user_profile
    ADD COLUMN display_name VARCHAR(100) NULL,
    ALGORITHM=INSTANT;

此时状态为:

旧应用:读写 nickname
新应用:可以读写 display_name,但不能假设它已经有值
数据库:同时存在 nickname 和 display_name

新应用需要采用兼容逻辑,例如:

SELECT
    id,
    COALESCE(display_name, nickname) AS display_name
FROM app.user_profile
WHERE id = ?;

写入时可以由应用双写:

UPDATE app.user_profile
SET nickname = ?,
    display_name = ?
WHERE id = ?;

这条语句在新旧应用并存时通常更安全,因为旧应用仍能读到 nickname,新应用能读到 display_name

3. 回填不是简单的一个大事务

数据回填可以分批执行:

UPDATE app.user_profile
SET display_name = nickname
WHERE display_name IS NULL
ORDER BY id
LIMIT 1000;

但这条 SQL 需要在循环中反复执行,并根据受影响行数停止。生产代码还应:

  • 使用主键或稳定索引分页;
  • 限制每批行数;
  • 每批独立提交;
  • 观察锁等待、复制延迟和 redo 生成;
  • 支持中断后继续;
  • 避免使用无索引条件导致全表扫描。

更稳妥的范围式回填示意:

UPDATE app.user_profile
SET display_name = nickname
WHERE id > ? 
  AND id <= ?
  AND display_name IS NULL;

其中 ? 是已规划的主键区间。范围式回填的好处是处理进度可记录、可重试,也更容易估计剩余工作。

回填完成后不要只看影响行数,还要验证:

SELECT COUNT(*) AS remaining
FROM app.user_profile
WHERE display_name IS NULL;

如果旧列允许 NULL,还要区分“未回填”和“旧值本来就是 NULL”。

4. 切换、收缩和删除

当满足以下条件后,才可切换读取逻辑:

  • 所有新应用都能处理新列;
  • 旧列与新列的写入规则稳定;
  • 回填完成;
  • 灰度流量读取结果一致;
  • 复制和备份链路包含新列;
  • 已保留足够观察时间。

之后才进入 Contract:

  1. 停止旧应用写入旧列;
  2. 确认没有旧版本实例;
  3. 移除双写;
  4. 再考虑删除旧列。

删除旧列通常不是可逆的轻量动作:

ALTER TABLE app.user_profile
    DROP COLUMN nickname;

一旦执行,旧应用即使重新部署也无法恢复。删除前必须确认有:

  • 可恢复备份;
  • 反向数据或重建脚本;
  • 足够长的观察窗口;
  • 旧版本确实不会重新启动。

七、灰度发布的状态机和数据流

灰度不是简单地“先让 1% 流量访问新库”。必须明确每个阶段的数据库状态。

阶段 A:兼容结构

数据库:旧列 + 新列
应用:旧版本
写入:旧列
读取:旧列

执行 Expand DDL,不改变旧应用行为。

阶段 B:兼容应用

数据库:旧列 + 新列
应用:旧版本 + 新版本
写入:双写或由数据库触发器同步
读取:优先新列,缺失时回退旧列

此阶段的关键不是流量比例,而是新版本是否能在部分数据尚未回填、双写暂时失败、旧实例仍在运行时保持正确。

阶段 C:逐步切换读取

数据库:旧列 + 新列
应用:主要为新版本
写入:新列为主,必要时继续双写
读取:新列

可以按实例、租户、地域或请求标记灰度,但必须避免同一业务对象被不同规则反复覆盖。例如:

请求 R1 由新应用写入 display_name
请求 R2 由旧应用写入 nickname

如果没有双向同步或版本号控制,两个列可能产生分歧。

阶段 D:收缩

数据库:只保留新列
应用:只使用新列

这一步才执行删除旧列或移除兼容代码。

双写的一致性边界

双写不是天然原子操作。应用分别执行两条 SQL 时可能发生:

1. nickname 写成功
2. display_name 写失败
3. 事务提交或连接断开

如果两列必须同事务更新,应放入同一个数据库事务:

START TRANSACTION;

UPDATE app.user_profile
SET nickname = ?,
    display_name = ?
WHERE id = ?;

COMMIT;

但这只能保证同一个数据库事务中的两列一起成功,不能保证跨数据库、消息队列或缓存也一起成功。跨系统同步需要幂等事件、重试、对账和修复机制。


八、复制迁移与切换

如果使用源库向目标库复制,目标库通常经历以下状态:

初始快照
    ↓
应用源库 Binlog
    ↓
目标库追平
    ↓
短暂停写
    ↓
确认目标位点追平
    ↓
切换连接

1. 目标库追平不等于数据可用

需要同时验证:

  • 复制线程正常;
  • SQL 线程没有错误;
  • 延迟在可接受范围;
  • 关键表和关键行存在;
  • 目标版本执行查询计划符合预期;
  • 目标库权限、事件和触发器正确;
  • 应用使用的字符集和时区正确。

监控复制状态时,可使用:

SHOW REPLICA STATUS\G

不同版本或工具可能使用不同术语和展示方式,但应重点关注:

  • IO 线程是否运行;
  • SQL 线程是否运行;
  • 最近错误;
  • 已接收和已执行的位置;
  • 延迟指标;
  • GTID 集合是否符合预期。

不要仅凭 Seconds_Behind_Source = 0 判断完全追平。它是基于复制时间的估算,在网络、空闲源库或时间配置异常时不能代表所有事务都已完成。切换时应使用明确的 Binlog 位点或 GTID 集合,并执行数据校验。

2. 基于语句和基于行的差异

语句复制记录“执行了什么 SQL”,行复制记录“哪些行变成了什么值”。迁移和灰度通常更关注行级确定性,因为:

  • 非确定函数可能在不同时间产生不同结果;
  • 不同排序规则可能影响语句结果;
  • LIMIT 不带稳定 ORDER BY 的更新具有风险;
  • 触发器、自动更新时间和默认值可能产生差异。

这不意味着行复制自动解决所有兼容问题。目标仍必须能够理解事件中的表结构、列类型和元数据。

3. DDL 与复制的关系

在复制环境中执行 DDL,要考虑:

  • DDL 是否会阻塞源库业务;
  • DDL 是否复制到目标;
  • 目标表结构是否已经预先兼容;
  • 新旧应用分别连接哪一侧;
  • 失败后两个实例的结构是否分叉。

常见的安全顺序是:

  1. 先在所有目标节点建立兼容的新结构;
  2. 保证旧应用仍可运行;
  3. 再灰度应用;
  4. 观察复制和业务;
  5. 最后收缩旧结构。

九、切换前的验证:从“能启动”到“业务等价”

迁移验证应分成四层。

1. 结构校验

检查:

SHOW CREATE TABLE app.user_profile;
SHOW CREATE TABLE app.orders;

比较:

  • 列名、类型、是否允许 NULL;
  • 默认值和生成表达式;
  • 主键、唯一键和普通索引;
  • 外键;
  • 字符集和排序规则;
  • 触发器、视图和事件。

2. 数据校验

精确行数可以通过:

SELECT COUNT(*) FROM app.orders;

但大表上全表 COUNT(*) 可能成本较高。可以按主键区间或业务分片校验:

SELECT
    MIN(id) AS min_id,
    MAX(id) AS max_id,
    COUNT(*) AS row_count,
    SUM(amount) AS amount_sum
FROM app.orders
WHERE id >= ? AND id < ?;

这类摘要可以发现大量缺行或数值异常,但不能证明每一行完全相同。关键表还应抽样比较主键、状态、金额、更新时间和业务唯一键。

3. 行为校验

验证真实 SQL:

  • 查询结果;
  • 唯一键冲突;
  • 分页顺序;
  • ORDER BY
  • 时间范围查询;
  • 事务隔离;
  • 锁等待;
  • 插入、更新和删除;
  • 触发器产生的副作用。

特别要验证排序规则变化后的分页。基于 OFFSET 的分页在排序规则变化后可能出现重复或遗漏;基于稳定唯一键的游标分页通常更容易校验。

4. 性能校验

迁移后的数据相同,不代表性能相同。需要比较:

EXPLAIN ANALYZE
SELECT ...

关注:

  • 是否使用预期索引;
  • 估算行数与实际行数差异;
  • 排序和临时表;
  • 回表次数;
  • buffer pool 命中和磁盘读;
  • DDL、回填、复制对线上延迟的影响。

十、回滚设计:哪些能退,哪些不能退

1. 应用回滚通常容易

如果数据库保持向后兼容,应用可以:

新应用 → 旧应用

例如新增可空列、保留旧列、采用双读双写时,应用回滚通常只需要切换部署版本。

2. DDL 回滚必须是反向变更

新增列的反向操作是删除列:

ALTER TABLE app.orders
    DROP COLUMN source;

但它不是事务回滚,并且可能无法恢复已经写入该列的数据。若新列的数据没有同步到旧列,删除后信息就丢失了。

对重命名、类型缩窄、字符集转换尤其如此:

扩大类型:VARCHAR(100) → VARCHAR(255)
缩小类型:VARCHAR(255) → VARCHAR(100)

缩小类型可能失败、截断或丢失数据,不能把它当作扩大类型的简单逆操作。

3. 数据库版本回滚通常不是“降级启动”

升级后直接用旧版本启动新数据目录,可能不受支持。原因包括:

  • 数据字典格式改变;
  • 系统表结构改变;
  • redo/undo 或持久化元数据格式改变;
  • 新版本写入旧版本不认识的对象属性;
  • 认证和权限系统发生变化。

可靠的数据库回滚通常是:

停止或隔离新实例
    ↓
恢复升级前的备份 B0
    ↓
应用 B0 之后的 Binlog
    ↓
恢复到指定时间点
    ↓
切回旧版本应用

这要求升级前备份和 Binlog 已经验证可用。

4. 切换后的反向复制不是自动成立

若已经从源库切换到目标库,想让目标库的新增写入回到源库,需要解决:

  • 源库是否理解目标库的新结构;
  • 是否会产生自增主键冲突;
  • Binlog 位点和 GTID 是否连续;
  • 双向写入是否造成循环复制;
  • DDL 是否在两侧一致;
  • 冲突如何裁决。

因此,“保留旧库”不等于“随时可以切回旧库”。旧库如果没有接收到新写入,它只是旧快照;如果接收了新写入,又会引入双向复制和冲突控制问题。


十一、典型故障路径与诊断

故障一:DDL 长时间卡住

现象:

ALTER TABLE 一直执行不结束
新的查询也开始排队

诊断顺序:

  1. SHOW PROCESSLIST 查看等待状态;
  2. 查询 performance_schema.metadata_locks
  3. 定位持有 MDL 的连接;
  4. 查看该连接是否存在长事务;
  5. 判断是否可以提交、终止请求或安排停写;
  6. 检查 DDL 是否已经进入复制队列。

错误做法是不断重试同一个 DDL。每次重试都可能增加等待线程,放大连接堆积。

故障二:字符集转换报错

现象可能是:

Incorrect string value
Data too long for column
Duplicate entry

分别可能对应:

  • 目标字符集无法表示原值;
  • 转换后字节数或列限制不够;
  • 新排序规则使两条原本不同的值相等。

诊断应查看具体行、列定义、排序规则和 SQL mode,而不是直接关闭严格模式。关闭严格模式可能把“明确失败”变成“静默损坏”。

故障三:新应用灰度后出现数据回退

常见时序:

新应用写 display_name = "新值"
旧应用随后把 nickname = "旧值"
新应用双读时发现两个列不一致

如果没有版本号或写入时间控制,双写只能降低风险,不能解决并发覆盖。可以在记录中引入版本或更新时间,并规定哪个写入具有更高版本;也可以在灰度期间禁止旧应用修改相关字段。

故障四:复制停止但源库仍正常

应先查看复制错误,而不是直接跳过事务。跳过事务可能造成静默数据不一致。需要判断:

  • 是目标缺少表或列;
  • 是字符集或排序规则不兼容;
  • 是唯一键冲突;
  • 是权限、触发器或 SQL 语义问题;
  • 是 DDL 顺序错误。

修复后应重新校验受影响表,而不是仅看到复制线程恢复就宣布完成。


十二、一个可执行的迁移演练顺序

以下流程适合 InnoDB 独立实例或单主复制环境:

第一步:建立基线

记录:

SELECT
    @@version,
    @@version_comment,
    @@sql_mode,
    @@character_set_server,
    @@collation_server,
    @@time_zone,
    @@gtid_mode,
    @@log_bin;

同时保存:

  • 对象定义;
  • 账号和权限;
  • 备份;
  • Binlog 保留策略;
  • 关键业务数据摘要;
  • 当前流量、错误率和延迟基线。

第二步:在副本上进行目标版本演练

使用生产备份恢复到隔离环境,执行:

  • 目标版本启动;
  • 升级检查;
  • 全量校验;
  • 应用连接;
  • 关键查询和写入;
  • 备份恢复;
  • 失败恢复。

第三步:先做兼容性 DDL

例如:

ALTER TABLE app.orders
    ADD COLUMN source VARCHAR(32) NULL,
    ALGORITHM=INSTANT;

确认:

SHOW CREATE TABLE app.orders;
SELECT COUNT(*) FROM app.orders WHERE source IS NOT NULL;

第四步:部署兼容应用

应用必须满足:

旧应用可运行
新应用可运行
新列为空时新应用有正确行为
新旧写入不会互相破坏

第五步:回填并对账

使用小批量、可重试的范围更新,观察:

  • 锁等待;
  • 事务耗时;
  • redo 和磁盘;
  • 复制延迟;
  • 业务错误率。

第六步:灰度切换

按照实例、租户或流量比例切换读取和写入,比较:

  • 新旧查询结果;
  • 错误码;
  • 延迟;
  • 数据对账;
  • 复制状态。

第七步:冻结回滚窗口

在观察窗口内保留:

  • 旧应用镜像;
  • 旧列和兼容代码;
  • 可恢复备份;
  • 连续 Binlog;
  • 明确的切回命令和负责人。

第八步:最后收缩

只有在确认不再需要旧应用和旧数据路径后,才删除旧列、旧索引或旧表。收缩操作本身也应单独评估 DDL 算法、MDL 和恢复方案。


MySQL 升级与迁移的核心不是寻找一条“不会停机”的命令,而是把变化拆成可验证的状态转换:

兼容检查
  → 可恢复备份
  → 兼容性结构
  → 新旧应用共存
  → 数据回填与校验
  → 灰度切换
  → 观察
  → 收缩

其中,DDL 的锁行为、字符集的可表示范围、排序规则的相等语义、复制的位点连续性,以及数据库回滚的不可逆边界,决定了方案是否真正安全。只有当每个阶段都有明确的数据状态、验证条件和恢复路径,升级与迁移才不是一次冒险的切换,而是可重复演练的运维过程。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。