800GB 云数据库迁移:mysqldump 跑了 9 小时,InnoDB Clone 只用了 22 分钟——页级 chunk 并行、redo 归档溢出与 LSN 断点续传的底层拆解

先把一个反直觉的事实摆在桌上

同一份 800GB 的 MySQL 8.0 数据,同一个源库,同一个目标端,带宽都是 10Gbps 内网:

  • 方案 A:mysqldump --single-transaction --set-gtid-purged=OFF,管道进 mysql,全流程 9 小时 12 分。
  • 方案 B:CLONE INSTANCE FROM ...,全流程 22 分 40 秒。

很多人第一反应是"物理备份不用走 SQL 解析所以快"。这句话对,但只对了一半,而且是最不重要的那一半。真正的差距在 IO 模式和可断点性上。搞不清这两点,你调一辈子 net_write_timeout 和 max_allowed_packet 也压不下迁移窗口。

逻辑迁移慢在哪:B+ 树的逻辑序 ≠ 物理序

mysqldump 在 RR 隔离级别下开的是一致性快照,读的是逻辑行。它按主键顺序把每一行取出来,序列化成 INSERT 语句或 tab 分隔文本。这里有两个不可回避的代价:

代价一:随机 IO 被打包成了"看起来顺序"

InnoDB 的聚簇索引,PK 顺序在逻辑上是递增的,但在物理页上完全不是。一棵经历过大量 UPDATE、DELETE、页分裂的 B+ 树,PK=1000 的行可能落在 space_id=42 的第 9001 页,PK=1001 落在第 12 页。你按 PK 扫一遍,等价于把整个表空间的页随机访问一遍。

而物理迁移按 (space_id, page_no) 顺序读——这是真正的顺序 IO。在 NVMe 上,顺序读和随机读的差距是 4~8 倍,在机械盘上差 50 倍以上。

代价二:二级索引必须从零重建

逻辑导入时,每一条 INSERT 都要走完整的 B+ 树插入路径,包括:

-- 逻辑导入时每行都要付的成本
1. 聚簇索引定位插入页(可能触发页分裂)
2. 每个二级索引做一次 B+ 树查找 + 插入
3. undo 写入
4. redo 写入(如果没关 doublewrite / 没调 innodb_flush_log_at_trx_commit)
5. change buffer 维护

一个 10 个二级索引的表,逻辑导入的写放大至少是 11 倍。物理迁移是把页面直接落到磁盘,索引结构原样搬过去,写放大约等于 1。

物理页级迁移的核心:页是自描述的、可寻址的、可并行的

InnoDB 表空间本质上就是一个页数组。page_no 是绝对偏移,页头自带 FIL_PAGE_LSN、FIL_PAGE_PREV/NEXT、校验和。这意味着:

  • 可寻址:给 (space_id, page_no) 就能直接定位,不依赖任何 SQL 语义。
  • 可并行:页之间在传输层面无依赖,可以随便切块并行拉取。
  • 可断点:断线后只要知道哪些页已落盘、哪些没有,接着传就行,不需要重放事务。

MySQL 8.0.17 引入的 Clone Plugin 就是把这套东西工程化了。它不用 binlog,不用 SQL 层,走的是自己的协议。

Clone 的完整工作流:三个 LSN 决定一切

阶段一:donor 获取快照点并启动 redo 归档

donor 拿到备份锁(BACKUP_ADMIN 权限对应的 MDL),记录当前 LSN 作为 CLONE_START_LSN。从这个 LSN 开始,所有新产生的 redo 会被额外归档到 donor 数据目录下的 #clone 目录。注意,这一步不阻塞普通 DML,但会阻塞 DDL。

阶段二:并行 chunk 传输

表空间被切成固定大小的 chunk,默认 1MB。多线程并行拉取,落到 recipient 的 DATA DIRECTORY。

阶段三:apply redo 到一致点

页全部落地后,recipient 把归档的 redo 应用一遍,把数据推到与 donor 一致的 LSN。然后 restart。

操作命令(关键部分)

-- ============ donor 端 ============
INSTALL PLUGIN clone SONAME 'mysql_clone.so';   -- 8.0.17+ 默认已装

CREATE USER 'cloner'@'10.0.1.%' IDENTIFIED BY 'StrongPass!';
GRANT BACKUP_ADMIN ON *.* TO 'cloner'@'10.0.1.%';

-- 查看 donor 的 redo 容量,这是踩坑重灾区
SHOW VARIABLES LIKE 'innodb_redo_log_capacity';   -- 8.0.30+
SHOW VARIABLES LIKE 'innodb_log_file_size';       -- 8.0.29 及以前

-- ============ recipient 端 ============
INSTALL PLUGIN clone SONAME 'mysql_clone.so';
SET GLOBAL clone_valid_donor_list = '10.0.1.10:3306';

CLONE INSTANCE FROM 'cloner'@'10.0.1.10':3306
  IDENTIFIED BY 'StrongPass!'
  DATA DIRECTORY = '/data/mysql_8032';

调优的第一性原理:chunk 大小 × 并发 / RTT

Clone 的传输是"发一个 chunk,等对方 ACK/落盘,再发下一个"的流式模型。在单条流上,理论吞吐近似:

有效吞吐 ≈ clone_chunk_size × clone_max_concurrency / RTT

代入一组真实数字。跨可用区 RTT = 2ms,默认 chunk 1MB,并发 8:

1MB × 8 / 0.002s = 4000 MB/s  ← 理论值,会被带宽和磁盘先卡住

换成跨地域 RTT = 46ms:
1MB × 8 / 0.046s = 174 MB/s  ← 这时候 RTT 就是瓶颈,不是带宽

所以在长肥管道上,第一件事是把 chunk 拉大:

SET GLOBAL clone_chunk_size = 16777216;      -- 16MB,最大 1GB
SET GLOBAL clone_max_concurrency = 16;       -- 默认 0 = 1.5×CPU核数,上限 32

注意 chunk 不是越大越好。chunk 越大,单个 chunk 的传输时间越长,失败重传的粒度就越粗;而且 recipient 需要持有更大的内存缓冲。跨洋场景 8~32MB 是甜点区。

如果迁移的两端是同一区域内的 NVMe 实例,RTT 通常在 0.2~0.5ms,chunk 甚至不需要调——默认 1MB 就能打满带宽。我们在轻云互联同机房的两台 NVMe 数据库实例之间做 clone,RTT 稳定在 0.3ms 上下,16 并发直接跑满 25Gbps 内网,chunk 大小保持默认也没成为瓶颈。反而是在这种低 RTT 场景下,磁盘的 fsync 能力才是真正的天花板。

最容易翻车的地方:redo 归档溢出

这是 Clone 迁移失败率最高的一类原因,而且报错信息很短,容易被忽略:

ERROR 1086 (HY000): Donor's redo log overflow, cannot complete clone operation

原理很直白:donor 在 clone 期间要持续归档从 CLONE_START_LSN 开始的全部 redo,而归档能用的空间是有上限的——就是 redo 容量本身。算一笔账:

数据量            800 GB
全量传输耗时      22 分钟
donor 写入速率    60 MB/s(约 5000 TPS 的 OLTP)

归档 redo 总量 = 60 MB/s × 1320 s ≈ 79 GB

donor redo 容量:
  MySQL 8.0.29 默认 innodb_log_file_size=48M × 2 = 96 MB   ← 差 800 倍,必炸
  MySQL 8.0.30 默认 innodb_redo_log_capacity=100M          ← 同样必炸

结论:默认 redo 容量下,clone 只适合几十 GB 以内、且写入量很低的数据。超过这个量级必须提前动手:

# my.cnf,8.0.30+
innodb_redo_log_capacity = 16G

# 8.0.29 及以前
innodb_log_file_size = 4G
innodb_log_files_in_group = 4

调整 redo 容量本身需要重启。这是迁移前必须做的前置检查,不是迁移时再想办法的事。

三条降低 redo 归档压力的实操手段

  • 压低 donor 写入速率:迁移窗口内把批量任务、报表导出、定时 job 全部停掉。写入速率从 60MB/s 降到 5MB/s,归档量直接降到 6.6GB。
  • 分批迁移:按库或按大表拆成多轮 clone,每轮只搬一部分。注意 clone 是实例级的,拆表要靠 innodb_directories + 传输表空间(FLUSH TABLES ... FOR EXPORT)另想办法,clone 本身不支持选表。
  • 分离 redo 盘:redo 和 datadir 放同一块盘时,归档写入会和页传输抢 IO。redo 单独挂 NVMe 能显著降低 donor 侧的整流压力。

监控:迁移不是黑盒,四张表看穿

-- recipient 端执行,实时进度
SELECT
  stage, state,
  CAST(estimate AS UNSIGNED) AS estimate_bytes,
  CAST(data AS UNSIGNED)     AS transferred_bytes,
  TIMESTAMPDIFF(SECOND, begin_time, NOW()) AS elapsed_s
FROM performance_schema.clone_progress;

stage 的取值按顺序是:DROP DATA → FILE COPY → PAGE_COPY → REDO_COPY → FILE_SYNC → RESTART。

经验值是:PAGE_COPY 应该占总耗时的 85% 以上。如果 REDO_COPY 占了很久,说明你在迁移期间 donor 还在猛写,归档 redo 太多,apply 阶段吃掉了大量时间——这时候该反思的不是 clone,是你的迁移窗口编排。

-- 查看最终结果和 GTID 位点,这个字段后面会用到
SELECT state, begin_time, end_time,
       CAST(data_size AS UNSIGNED)/1024/1024/1024 AS data_gb,
       binlog_file, binlog_position, gtid_executed
FROM performance_schema.clone_status\G

断点续传:LSN 才是那个断点

Clone 天然支持中断恢复,因为它记录的是 CLONE_START_LSN 和一个页位图。网络抖动、recipient 重启,只要在 clone_donor_timeout_after_network_failure(默认 5 分钟)内重来,就能从已落盘的页继续,而不是从零开始。

-- 8.0.20+ 支持自动 resume,配置好后不需要人工干预
SHOW VARIABLES LIKE 'clone_donor_timeout_after_network_failure';  -- 默认 300

-- 手工强制重启(换过 donor / 超过超时窗口后)
CLONE INSTANCE FROM 'cloner'@'10.0.1.10':3306
  IDENTIFIED BY 'StrongPass!'
  DATA DIRECTORY = '/data/mysql_8032'
  RESTART;

但要注意:断点续传续的是页,不是 redo 归档。如果 donor 在中途重启过,原来的归档 redo 会失效,整个 clone 必须从头来。所以迁移窗口内别动 donor 的 innodb_redo_log_capacity,也别重启。

最后一公里:clone 完成后你还差一步

Clone 是实例级的物理复制,不走 SQL 层,所以 recipient 上的 gtid_executed 是空的。如果你直接建复制链路,会立刻撞上:

ERROR 1236 (HY000): The slave is connecting using CHANGE MASTER TO
MASTER_AUTO_POSITION = 1, but the master has purged binary logs
containing the GTIDs required for this slave.

正确姿势是从 clone_status.gtid_executed 里取位点,手工补齐:

-- 1. 从 clone_status 拿到 gtid_executed,假设是
--    3f2a...:1-99887732

STOP REPLICA;
RESET MASTER;   -- 清掉 recipient 上 clone 后自己产生的那点 GTID

SET GLOBAL gtid_purged = '3f2a9b1c-6f7d-11ee-9c8a-005056b1f2a3:1-99887732';

CHANGE REPLICATION SOURCE TO
  SOURCE_HOST          = '10.0.1.10',
  SOURCE_PORT          = 3306,
  SOURCE_USER          = 'repl',
  SOURCE_PASSWORD      = '...',
  SOURCE_AUTO_POSITION = 1,
  SOURCE_SSL           = 1,
  SOURCE_CONNECT_RETRY = 10;

START REPLICA;
SHOW REPLICA STATUS\G

补齐 gtid_purged 之后,增量追平通常只需要几分钟。真正切换窗口就是从 START REPLICA 到 Seconds_Behind_Source = 0 的那一小段,而不是全量那 22 分钟。这才是 clone 相比逻辑迁移最大的价值:把停机窗口和全量窗口解耦。

三个写进 checklist 的硬性前置条件

  • 只搬 InnoDB:MyISAM、MEMORY、CSV 等非 InnoDB 表不会进 clone。迁移前必须先 SELECT table_schema, table_name, engine FROM information_schema.tables WHERE engine <> 'InnoDB'; 扫一遍,有的话提前转引擎或单独走逻辑迁移。
  • 版本必须 recipient ≥ donor:8.0.17 起支持跨小版本,但方向只能是从低到高。反过来直接拒绝。
  • donor 上不能有长事务和 DDL:clone 需要拿到 MDL,一个跑了 40 分钟的 ALTER TABLE 会让 clone 卡在 FILE COPY 阶段干等,直到 lock_wait_timeout 超时。迁移前先 SELECT * FROM information_schema.innodb_trx ORDER BY trx_started LIMIT 5; 清场。

一句话总结

逻辑迁移是在"用 SQL 重新描述数据",物理迁移是在"搬运自描述的页"。前者慢在随机 IO 和索引重建,后者慢在 redo 归档容量这个隐藏上限上。把 innodb_redo_log_capacity 提前拉到 16G,把 clone_chunk_size 按 RTT 调大,把 gtid_purged 补齐——800GB 的数据,22 分钟搬完,切换窗口 30 秒。