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 还可能在并发写入时漏行/重行。