19.7 再平衡与数据迁移

一句话

再平衡是 “把数据搬到一个新的均衡状态”,难点不在搬,而在搬的过程中还能正确读写:先追平、再切换、最后清理,每一步都要能回滚,且迁移流量必须有上限。

迁移的四个阶段

阶段 做什么 风险
① 准备 新节点加入、元数据登记、限速配置 新节点未被正确纳入路由
② 追平(snapshot + 增量) 全量拷贝 + 持续回放增量 追不上(写入速率 > 迁移速率)
③ 切换 把读/写路由指向新节点(可分批) 切换瞬间的双写/漏写
④ 清理 删除旧副本、回收空间 删早了无法回滚

“追平” 是唯一可能无限期卡住的阶段:增量速率必须小于迁移带宽,否则永远追不上。

两种分区策略的迁移差异

策略 迁移单位 优点 缺点
固定分区数(先分后迁) 整个分区(如 1024 个逻辑分区) 迁移简单、路由稳定 分区数固定,后期扩容粒度粗
动态分裂(range/按大小分裂) 一个 range 按数据量自适应 分裂与迁移同时发生,复杂度高
一致性哈希 环上区间 增删节点只动相邻区间 区间大小不均,见 19.6 有界负载一致性哈希

一致性:迁移中的读写

方案 做法 代价
停写迁移 停服务 → 搬 → 启动 有停机窗口,适合小数据
双写 新旧同时写 一致性靠对账,复杂度高
路由切换 + 追平 旧为主,新追平后切读、再切写 需要 “追平” 与 “可回滚”
影子读校验 同时读新旧并比对 可用于验证,但双倍成本

在线迁移的双写:一套能落地的步骤

双写在表里只有一行,实际它是迁移里最容易翻车的一环。可抄的六步:

步 做什么 关键约束
① 建同步 新库先当旧库的从库追平历史数据;跨多库多表用 CDC(如 Canal)抓 binlog,按分片逻辑写进新库 历史数据 TB 级,等追平再动代码
② 开双写 代码同时写新旧,但只保证旧库成功;新库失败单独记日志 + 异步补写 先断主从同步再开双写,见下方 ⚠️
③ 抽样校验 按比例抽样比对新旧,不做全量比对 校验脚本必须提前在测试环境验证
④ 读灰度 读流量 10% → 50% → 100% 每档看错误率与 P99,别一次切满
⑤ 留退路 整个窗口内随时能把读写切回旧库 旧库在新库验证通过前不能下线
⑥ 收尾 观察几天后再改回单写新库,旧库降级为只读/备份 别急着删旧库

⚠️ 先断主从同步,再开双写。如果新库还在从旧库同步,同一条变更会被写两遍:一遍来自 binlog 回放,一遍来自代码双写——轻则重复行,重则唯一键冲突。正确做法是用一个开关同时"断开同步 + 开启双写",这两件事必须原子。

⚠️ 最易翻车的是第 ③ 步的校验脚本本身:迁移当天才写脚本、脚本自己有 bug,会把"两边不一致"误判成"一致"。校验脚本要在测试环境先跑通,并故意造几条脏数据验证它抓得出来。

另一条路线是级联同步(零代码改造,但需要一次短暂停写):

旧库 ──从库──> 新库 ──> 备库
① 新库作旧库从库,追平
② 备库作新库从库,三库一致
③ 切读到新库(观察)
④ 低峰期停写 → 切写 → 恢复写入
回滚:读切回备库 → 停写 → 写切回备库

代价是第 ④ 步有一个停写窗口(分钟级),换来的是完全不用改业务代码。数据量不大、能接受短暂停服时,这条路比双写省事得多。

缓存迁移:空缓存等于把库打穿

迁移不只搬数据库,还要搬缓存。这里唯一的指标是命中率:新集群是空的,等于 100% 穿透回源——库很可能当场被打挂。

做法 后果
新缓存直接接全量读 命中率 0,库被瞬时打穿
边跑边预热 + 读灰度 命中率爬升,安全
命中率 ≥ 90% 才切主流量 剩余 10% 回源在库的承受范围内

还有一个常被忽略的物理约束:如果新缓存集群在另一个机房,同城跨专线单次往返约 2–3ms,而一次请求可能访问十几次到几十次缓存——累计多出几十到上百毫秒。所以同机房的服务只调同机房的缓存,跨机房访问要么改成本地缓存,要么接受这笔延迟。

数字:迁移时间怎么估

迁移时间 ≈ 数据量 ÷ 有效带宽 × 放大系数
例:1TB ÷ 100MB/s ≈ 2.8 小时;放大系数 1.3(重传/小文件/压缩)≈ 3.6 小时

加上 “追平期的增量回放” 与 “切换验证”,真实窗口通常是纯传输的 1.5–2 倍。迁移期不要把带宽打满(建议 ≤ 50%),否则前台请求的尾延迟会明显恶化。

代码:可回滚的迁移状态机

-- 迁移位点表:记录每个分区的进度,支持断点续传与回滚
CREATE TABLE migration_state (
  shard_id     INT PRIMARY KEY,
  phase        TEXT NOT NULL,          -- copy | catchup | switch | done | rolled_back
  last_key     BIGINT NOT NULL,        -- 已拷到的位置(断点)
  snapshot_lsn TEXT,                   -- 追平的起点
  updated_at   TIMESTAMP DEFAULT now()
);

-- 拷贝一批(按主键范围,避免长事务)
SELECT * FROM orders WHERE id > $last_key ORDER BY id LIMIT 5000;
-- 追平:从 snapshot_lsn 开始回放增量,直到 lag < 1s
-- 切换:把路由表里该分区的 owner 指向新节点(可一键回滚)

反模式

反模式 后果
一次性全量迁移 源库压力打满,业务抖动
迁移期间做 schema 变更 两边结构不一致,回滚困难
没有回滚预案 切过去发现问题只能硬扛
不校验 静默丢行/丢列,几周后才发现
迁移不打限速 抢占带宽,影响在线请求
迁移期间改索引/约束 校验失败,回滚成本翻倍
不记录迁移位点 断点重试从零开始,重复导入
双写时新库还挂着主从同步 同一条变更写两遍:重复行或唯一键冲突
迁移当天才写校验脚本 脚本自己出错,"不一致"被误判成"一致"
新缓存没预热就接全量读 命中率 0,全部回源把库打穿

三条铁律

🔑 先追平、再切换、后清理;顺序颠倒就没有回滚点。

🔑 迁移带宽 ≤ 可用带宽的 50%,把余量留给业务。

🔑 每一步都要能回滚;不能回滚的迁移是一次赌博。

自检

Q1 迁移一直 “追不平”,最可能的原因是什么?

▸ 答案:增量产生速率 ≥ 迁移可用的复制带宽。常见于写入很高或迁移被限速过狠。解法:提高迁移带宽(但不超过 50%)、先迁冷数据、或采用 “分批切换”(先把一部分分区的写切走,减少增量)。

Q2 为什么 “固定分区数” 能让迁移变简单?

▸ 答案:因为分区是迁移的最小单位,且分区数不变:扩容时只是把某些分区整体搬到新节点,路由表是 “key → 分区 → 节点” 的两级映射,迁移不影响 key 的归属计算。代价是分区数一旦定下,单分区过大时只能靠加分区数重建。

Q3 迁移期间用什么方式保证 “读不到旧数据”?

▸ 答案:切换读之前,必须确认新节点已追平到某个位点,并在切换时记录该位点(类似 LSN);之后的读请求带版本/位点约束,未追平的数据回源旧节点。没有位点概念的迁移,无法证明 “读到的是最新”。

Q4 数据校验该怎么做,才不是 “查个 count”?

▸ 答案:分层校验:① 行数与 checksum(快,能发现大问题);② 按主键区间抽样比对(发现局部丢失/篡改);③ 关键字段的业务聚合比对(如金额总和)。只对 count 会漏掉 “行数对但内容错”。

Q5 双写迁移为什么必须先断开新库的主从同步?不断会怎样?

▸ 答案:因为不断同步时,同一条变更会有两个来源写进新库:一遍是 binlog 回放(新库作为旧库从库),一遍是代码里的双写。结果是重复行,若表上有唯一键就是持续冲突报错。正确做法是用一个开关把“断同步”和“开双写”做成原子操作,顺序固定为先断同步、再开双写。

Q6 为什么“新库校验通过”还不能直接切全量读?

▸ 答案:数据一致不等于容量与延迟一致。新库的连接池是冷的、缓存是空的、索引可能还没预热,全量切的瞬间命中率 0、全部回源。要按 10% → 50% → 100% 灰切,每档看错误率与 P99;缓存类迁移更要以命中率 ≥ 90% 作为切主流量的门槛。

Q5 什么时候该选择 “停写迁移”?

▸ 答案:数据量小(几十 GB 内)、有可接受的维护窗口、且一致性要求极高(如账务切换)。停写迁移最可靠,但代价是停机;大系统只能用 “追平 + 切换 + 回滚” 的在线方案。

Q7 为什么迁移要按主键范围分批,而不是 LIMIT/OFFSET?

▸ 答案:因为 OFFSET 会扫描并丢弃前面所有行,越往后越慢(O(N²) 总成本);而按主键范围(WHERE id > last_key ORDER BY id LIMIT n)每批都走索引、成本恒定,且天然支持断点续传。OFFSET 还可能在并发写入时漏行/重行。

关联