平滑扩容与数据迁移
分库分表上线一年后数据量翻倍,需要从 64 张表扩到 128 张。如果直接按新规则上线,几乎全部数据都要换位置,而业务不能停机。平滑扩容解决的就是:在不停机、不丢数据、可回滚的前提下,把数据搬到新的分片布局上。
为什么取模扩容如此痛苦
假设原本 64 个分片,改为 65 个:
旧规则:shard = id % 64 新规则:shard = id % 65
id = 100 → 旧 36,新 35 变了
id = 200 → 旧 8, 新 5 变了
id = 64 → 旧 0, 新 64 变了
因为 64 与 65 互质,只有极少数 id 的落点在两个规则下相同,几乎 100% 的数据都要迁移。迁移期间既要搬历史数据又要处理增量写入,风险极高。结论:分片数量尽量取 2 的幂,避免随意加 1。
双倍扩容法
把 64 扩到 128,而不是 65。关键在于新旧规则的关系:
旧规则:shard = id % 64 → 0 ~ 63
新规则:shard = id % 128 → 0 ~ 127
同一个 id:
id % 128 < 64 → 新分片号 == 旧分片号,数据不用动
id % 128 >= 64 → 新分片号 == 旧分片号 + 64,只需搬到新增分片
更重要的性质是不跨库合并:旧分片 3 的数据只会流向新分片 3 或新分片 67,永远不会流向新分片 5。因此每个旧分片可以独立迁移,互不影响,也便于逐个灰度。
迁移前:64 个分片承载全部数据
迁移中:新分片 64~127 由对应旧分片 0~63 复制而来,双写生效
迁移后:128 个分片各承载约一半数据
用一致性哈希减少迁移
| 方案 | 扩容时迁移比例 | 实现复杂度 | 分布均匀性 |
|---|---|---|---|
| 取模 | 几乎 100% | 低 | 均匀 |
| 双倍扩容 | 约 50% | 中 | 均匀 |
| 一致性哈希 | 约 1/N(N 为节点数) | 高 | 需虚拟节点才均匀 |
一致性哈希的代价是实现复杂、要靠虚拟节点解决倾斜,而且"数据归谁"不再直观。实际工程中,取模 + 双倍扩容因为简单可预测,使用得更普遍。
迁移的五个阶段
| 阶段 | 动作 | 回滚方式 |
|---|---|---|
| 1 双写 | 新写入同时写旧、新分片,读仍走旧分片 | 关掉双写开关 |
| 2 存量同步 | 按旧分片逐批把历史数据复制到新分片 | 停止同步任务,丢弃新分片数据 |
| 3 数据校验 | 比对两侧行数与关键字段 | 无需回滚,修复差异后重跑 |
| 4 灰度切换 | 按比例把读流量切到新分片并逐步放大 | 把读流量比例调回 0 |
| 5 清理 | 确认稳定后停止双写、下线旧分片 | 旧分片保留期内可随时恢复 |
阶段1 双写:write(id, data) 先写旧分片,再写新分片(失败进补偿队列重试)
阶段2 同步:按旧分片独立进行,主键游标分页 + 断点续传
阶段3 校验:COUNT(*) 比对 + 主键差集 + 抽样字段比对
阶段4 切换:读比例 1% → 5% → 20% → 50% → 100%,逐步观察
阶段5 清理:停双写 → 观察 7 天 → 归档并下线旧分片
迁移进度与断点记录表
长跑任务必须有断点,否则中断一次就得重来:
CREATE TABLE migrate_progress (
task_name VARCHAR(64) NOT NULL COMMENT '任务名,如 order_64to128',
old_shard INT NOT NULL COMMENT '旧分片号',
last_id BIGINT NOT NULL DEFAULT 0 COMMENT '已同步到的最大主键',
migrated_rows BIGINT NOT NULL DEFAULT 0 COMMENT '已迁移行数',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0未开始 1进行中 2完成 3失败',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (task_name, old_shard)
) ENGINE=InnoDB COMMENT='迁移进度表';
- 用主键游标分页,不要用
LIMIT offset——深分页会越跑越慢; - 写入用幂等 upsert,任务重跑不会产生重复数据;
- 单批几百到几千行并限速,避免同步任务打满主库 IO。
-- 按主键游标取一批(id 必须有序)
SELECT id, user_id, amount, status FROM t_order_0
WHERE id > 1000000 ORDER BY id LIMIT 1000;
-- 幂等写入新分片,重复执行结果一致
INSERT INTO t_order_9 (id, user_id, amount, status)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE amount = VALUES(amount), status = VALUES(status);
一致性校验
校验最容易被省略,也最不能省:
| 校验方式 | 做法 | 能发现的问题 |
|---|---|---|
| 行数比对 | 两侧 COUNT(*) 对比 | 漏迁、重复迁移 |
| 主键集合比对 | 分批取两侧主键做差集 | 精确定位缺失的行 |
| 抽样字段比对 | 按比例抽样比对全部字段 | 字段截断、时区偏差 |
| 全量比对 | 分批全字段比对(离线跑) | 最彻底,但耗时长 |
-- 行数比对:在两侧分别执行后对比结果
SELECT COUNT(*) FROM t_order_0;
-- 输出:12480000
回滚预案
回滚触发:灰度切换后错误率上升、校验差异超阈值、新分片延迟或可用性异常
阶段1~2 出错 → 停双写与同步任务,清理新分片残留数据
阶段3 出错 → 暂停同步、修复差异后重跑,不影响线上读
阶段4 出错 → 读流量比例调回 0,恢复读旧分片
阶段5 之后 → 旧分片数据必须保留一段时间,不能立即删除
- 双写与读切换都做成配置开关,秒级生效,不要靠发版;
- 旧分片在确认稳定前不删除,保留期建议不少于一个业务周期;
- 切换放在低峰期,并按 1%、5%、20%、50%、100% 逐级放量,每级至少观察一个完整业务周期。
常见坑
- 双写不一致:写新分片失败却没有补偿机制,数据会悄悄丢失;
- 增量同步不幂等:binlog 重复消费会产生重复行或覆盖新值;
- 全量与增量之间有缝隙:全量同步开始后的新写入必须靠增量补齐;
- 主键冲突:两侧各自自增会撞号,应以分布式 ID 或业务主键为准;
- 大事务长锁:别用一条大事务提交百万行,会长时间占用 undo log 与锁;
- 忽略监控:迁移期间必须盯主库延迟、IO、连接数与新分片错误率。
小结:平滑扩容的核心是"双写 + 存量搬迁 + 校验 + 灰度切换 + 可回滚"。分片数取 2 的幂并用双倍扩容,可把迁移量降到约一半且不跨库合并;迁移按阶段推进,每阶段留开关与断点,校验环节不可省略,旧分片确认稳定后再下线。