核心库迁移方案:新老库同步 / 切流 / 回滚 / 业务无感知
一句话速记
数据库迁移(换数据库类型、换分片方案、换存储引擎)的核心原则是业务无感知、可随时回滚。标准模式:双写(写两个库)→ 全量校验 → 灰度切读 → 全量切读 → 停止双写,每步都可回滚,整个过程不停服务。
关键细节
1)迁移的三大挑战
挑战 1:数据一致性
老库有历史数据(需要全量迁移)
迁移期间业务还在写(需要增量同步)
新老库数据可能短暂不一致(需要校验)
挑战 2:不停服
不能停业务做迁移(零停机迁移)
切换过程中如果有问题需要快速回滚
挑战 3:回滚
已经写入新库的数据怎么回滚?
→ 继续双写到老库,回滚时把读切回老库即可
2)标准迁移步骤(五步法)
Step 1:双写(Double Write)
应用层同时写老库和新库(老库写成功为准,新库写失败只告警不失败)
→ 新库数据从此时起实时同步
Step 2:历史数据全量迁移
存量数据从老库导到新库(分批,避免影响老库性能)
工具:mysqldump, DataX, Flink CDC, Canal
Step 3:数据校验
自动对比新老库的数据(按主键范围分批)
告警不一致的数据,修复后再进入下一步
Step 4:切读(灰度 → 全量)
先切 1% 流量读新库 → 观察(错误率、延迟)
逐步扩大:1% → 5% → 10% → 50% → 100%
Step 5:下线老库
全量读切到新库,运行稳定 1 周后
停止双写(只写新库)
老库降为只读(再保留几天作为兜底)→ 最终下线
3)双写的实现
应用层双写:
@Service
public class OrderRepository {
@Autowired private OldOrderDao oldDao;
@Autowired private NewOrderDao newDao;
@Autowired private MigrationConfig config; // 动态配置:双写开关
@Transactional
public void save(Order order) {
// 老库:主写(必须成功)
oldDao.save(order);
// 新库:副写(失败只告警,不影响主流程)
if (config.isDoubleWriteEnabled()) {
try {
newDao.save(order);
} catch (Exception e) {
// 告警:新库写失败,需要补偿
alertService.alert("NewDB write failed: " + order.getId(), e);
// 记录失败 ID,供补偿任务修复
failedWriteQueue.add(order.getId());
}
}
}
}Canal + MQ 旁路双写(侵入性更低):
不改应用代码,通过 Binlog 同步:
老库写入 → Binlog → Canal 监听 → 发 MQ → 消费者写新库
优点:应用层零改造
缺点:新库写入有延迟(ms 级),短暂不一致
适用:老库是 MySQL,新库也支持该数据模型
4)历史数据迁移
分批迁移(不影响线上):
// 按 ID 范围分批迁移
Long startId = 0L;
int batchSize = 1000;
while (true) {
List<Order> batch = oldDao.findByIdRange(startId, startId + batchSize);
if (batch.isEmpty()) break;
newDao.batchInsert(batch); // 批量写新库(幂等)
// 更新进度
startId = batch.get(batch.size() - 1).getId() + 1;
// 限速(避免打满老库)
Thread.sleep(10); // 10ms 间隔
}全量迁移工具选型:
mysqldump:简单,全量,需要停写(不适合在线迁移)
DataX(阿里):支持多种源目标(MySQL/ES/HBase/Hive),批量读写,有速率限制
Flink CDC:实时流式迁移(全量+增量),适合大数据量,延迟低
Canal:只做增量(Binlog),需要配合初始全量工具
5)数据校验
# 校验脚本(按 ID 范围对比)
def verify_batch(start_id, end_id):
old_data = old_db.query("SELECT id, md5(data) FROM orders WHERE id BETWEEN ? AND ?",
start_id, end_id)
new_data = new_db.query("SELECT id, md5(data) FROM orders WHERE id BETWEEN ? AND ?",
start_id, end_id)
old_dict = {row.id: row.md5 for row in old_data}
new_dict = {row.id: row.md5 for row in new_data}
# 新库缺失的 ID
missing = set(old_dict.keys()) - set(new_dict.keys())
# 内容不一致的 ID
mismatch = [id for id in old_dict if id in new_dict and old_dict[id] != new_dict[id]]
if missing or mismatch:
log.warn(f"Missing: {missing}, Mismatch: {mismatch}")
repair(missing | set(mismatch)) # 从老库重新同步这些 ID6)切读与回滚
切读控制(配置中心动态调整):
# Apollo/Nacos 配置(随时调整,不发布代码)
migration:
read_from_new: 10 # 10% 流量读新库
read_from_old: 90 # 90% 流量读老库public Order findById(Long orderId) {
int readPercent = config.getReadFromNewPercent();
boolean useNew = (Math.abs(orderId.hashCode()) % 100) < readPercent;
if (useNew) {
return newDao.findById(orderId); // 读新库
} else {
return oldDao.findById(orderId); // 读老库
}
}快速回滚:
只需修改配置:read_from_new=0
→ 所有读流量切回老库(秒级生效,无需发布)
注意:双写必须在回滚窗口期内持续(否则老库数据落后)
建议:从切读开始,双写至少再坚持 7 天
延伸追问
- Q:双写期间,老库写成功但新库写失败,怎么修复? → 记录失败 ID(Redis Queue 或 DB 失败表),补偿任务定时从老库读出对应数据,重新写入新库,保证最终一致。
- Q:为什么切读要用 userId/orderId Hash 而不是随机数? → 确保同一实体(同一订单 ID)在灰度期间始终从同一个库读(一致性视图),避免忽读旧忽读新造成”闪烁”(用户看到数据来回变化)。
我的记法
- 五步法:双写 → 全量迁移 → 校验 → 灰度切读 → 停双写
- 双写:老库主写(必须成功),新库副写(失败只告警)
- 切读:配置中心动态调整比例,ID Hash 保证读的一致性
- 回滚:percent=0,切回老库,双写继续保数据同步
- 一句话:「双写+Canal 增量同步,分批校验,灰度切读,配置中心控比例,随时回滚」
状态
- 已背速记
- 能说五步迁移法
- 能解释双写失败的补偿方案