数据库基础体系 · 第 93/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
MySQL 升级与迁移:兼容检查、DDL、字符集、灰度和回滚
MySQL 升级和迁移经常被误解为“把数据复制到新服务器,再把应用指向新地址”。实际上,它至少同时改变了四类状态:
- 服务器软件状态:版本、默认参数、SQL mode、认证插件和系统表结构。
- 数据字典与表结构状态:表、索引、约束、存储过程、触发器和事件。
- 数据表示状态:字符集、排序规则、时间类型、隐式类型转换和索引字节数。
- 业务运行状态:应用读写协议、部署批次、复制链路、连接池和回滚路径。
因此,一个迁移方案必须回答四个问题:
- 新版本能否正确解释旧版本的对象和数据?
- 表结构变更会不会阻塞线上事务,或者耗尽磁盘?
- 新旧应用能否在一段时间内同时运行?
- 失败后,能否恢复到一个已知且可验证的状态?
本文以 MySQL 8.4 的公开语义为主,示例主要针对 InnoDB、事务表、单主复制或独立实例。具体升级路径仍必须以目标版本官方升级文档、发行版说明和实际测试结果为准。
一、先区分四种动作:升级、迁移、变更和回滚
1. 版本升级
版本升级是让同一份数据由不同版本的 MySQL Server 继续管理。例如:
旧 MySQL 实例
└── 停机或受控切换
↓
新版本 MySQL 实例读取原有数据
升级需要检查:
- 数据目录是否能被目标版本识别;
- 系统表和数据字典是否需要转换;
- SQL 语义、默认值和字符集是否变化;
- 认证插件、用户权限和客户端协议是否兼容;
- 存储引擎、插件和备份工具是否支持目标版本。
升级并不等价于“二进制文件替换”。数据目录是由特定版本解释的持久化状态,不能仅凭文件路径相同就认为可以跨版本启动。
2. 数据迁移
迁移是把数据从源实例复制到目标实例,通常目标可以是:
- 不同机器;
- 不同磁盘或云服务;
- 不同拓扑;
- 不同版本;
- 不同字符集或表结构。
迁移可以使用逻辑备份、物理备份、复制、存储引擎级传输或专用迁移工具。不同方法的兼容边界不同:
| 方法 | 复制的对象 | 主要优点 | 主要边界 |
|---|---|---|---|
| 逻辑备份 | SQL、表数据、对象定义 | 跨平台、跨版本能力较强 | 慢,恢复期间需要重建索引 |
| 物理备份 | 数据文件、表空间、日志 | 快,适合大数据量 | 对版本、平台、配置和存储引擎要求高 |
| 基于 Binlog 的复制 | 数据变更事件 | 可缩短停机时间 | 需要兼容复制协议、对象和语句语义 |
| 备份加 Binlog PITR | 某个基线加之后的变更 | 可恢复到时间点 | 必须保存完整、连续且可验证的日志 |
3. DDL 变更
DDL 是 CREATE、ALTER、DROP、RENAME 等改变数据库对象定义的操作。DDL 不只是“修改元数据”:
- 可能获取元数据锁;
- 可能重建整张表;
- 可能扫描和回写所有行;
- 可能使用大量临时磁盘;
- 通常会隐式提交事务;
- 即使底层具有原子 DDL,也不代表业务可以对它执行普通事务回滚。
4. 回滚
回滚有三个不同含义:
- 语句回滚:某条 DML 因错误被事务回滚。
- 部署回滚:应用版本退回旧版本。
- 数据库状态回滚:把数据和结构恢复到迁移前或某个时间点。
前两者通常可以独立完成,第三者最困难。特别是当新版本已经写入旧版本无法理解的数据或结构时,“把应用镜像切回去”并不能恢复数据库兼容性。
二、升级前的兼容性模型
一次升级真正需要验证的不是“版本号是否更大”,而是下面这个条件:
其中:
- :对象兼容,表、索引、视图、触发器、存储过程和事件可以被目标版本解析;
- :数据兼容,现有数据能被目标版本按预期读取和比较;
- :语义兼容,SQL mode、排序规则、隐式转换等行为满足应用假设;
- :应用兼容,客户端、驱动、认证方式和连接参数可用;
- :恢复兼容,备份、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,可能需要先经过官方支持的中间版本。正确流程是:
- 查阅目标版本的升级路径和限制;
- 在与生产相同的备份副本上演练;
- 验证启动、数据字典检查和应用读写;
- 验证失败后的恢复;
- 再安排生产切换。
“先启动看看”不是升级演练,因为启动成功只覆盖了极少数兼容条件。
三、备份不是回滚:先建立可恢复基线
升级和迁移前应形成一个可验证的恢复基线:
全量备份 B0
+ 连续 Binlog L(t)
→ 可恢复到 B0 之后的任意时间点
假设全量备份完成时间为 ,需要恢复到 ,则必须满足:
也就是说,从备份一致性点开始,到目标恢复时间之间的 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;
其推导过程是:
CONVERT(nickname USING latin1):模拟目标编码;- 再转回
utf8mb4:得到目标编码能够保留的结果; - 与原值按二进制语义比较;
- 不相等的行是潜在丢失或替换候选。
这不是所有非法字节和排序差异的万能检测器。生产迁移还应针对真实数据抽样,检查:
- emoji 和四字节字符;
- 组合字符;
- 中日韩字符;
- 特殊符号;
- 无效字节;
- 业务中用于唯一标识的字符串。
3. 排序规则会改变唯一性和查询结果
假设表上有唯一索引:
CREATE TABLE account (
login VARCHAR(100)
CHARACTER SET utf8mb4
COLLATE utf8mb4_0900_ai_ci
NOT NULL,
UNIQUE KEY uk_login (login)
);
在不区分大小写的排序规则下,Alice 和 alice 可能被视为相等,因此第二次插入可能失败:
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. 字符集迁移的安全步骤
一个可验证的流程是:
- 盘点库、表、列、连接和应用协议的字符集;
- 找出非目标字符集的列;
- 检测无法表示、会碰撞或会改变排序的值;
- 检查索引字节长度;
- 在副本上执行转换;
- 对比转换前后的样本和摘要;
- 再处理应用连接字符集;
- 最后统一数据库和表的默认值。
应用连接也必须明确设置。服务器默认字符集改变,并不能修复一个仍使用错误连接字符集的客户端。
五、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,应改为明确评估 INPLACE 或 COPY 的资源和锁风险,而不是删除 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:
- 停止旧应用写入旧列;
- 确认没有旧版本实例;
- 移除双写;
- 再考虑删除旧列。
删除旧列通常不是可逆的轻量动作:
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. 结构校验
检查:
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 一直执行不结束
新的查询也开始排队
诊断顺序:
SHOW PROCESSLIST查看等待状态;- 查询
performance_schema.metadata_locks; - 定位持有 MDL 的连接;
- 查看该连接是否存在长事务;
- 判断是否可以提交、终止请求或安排停写;
- 检查 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 的锁行为、字符集的可表示范围、排序规则的相等语义、复制的位点连续性,以及数据库回滚的不可逆边界,决定了方案是否真正安全。只有当每个阶段都有明确的数据状态、验证条件和恢复路径,升级与迁移才不是一次冒险的切换,而是可重复演练的运维过程。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:MySQL 分片与 Vitess:路由、VSchema、重分片和跨分片事务
- 下一篇:PostgreSQL 高级 SQL:LATERAL、递归 CTE、窗口、数组和范围
- 延伸:MySQL 备份恢复:逻辑备份、物理备份、Binlog 与 PITR 演练
- 延伸:数据库迁移与在线变更:Expand-Contract、锁风险和可回滚发布
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论