核心库迁移方案:新老库同步 / 切流 / 回滚 / 业务无感知

一句话速记

数据库迁移(换数据库类型、换分片方案、换存储引擎)的核心原则是业务无感知、可随时回滚。标准模式:双写(写两个库)→ 全量校验 → 灰度切读 → 全量切读 → 停止双写,每步都可回滚,整个过程不停服务。

关键细节

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))  # 从老库重新同步这些 ID

6)切读与回滚

切读控制(配置中心动态调整)

# 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 增量同步,分批校验,灰度切读,配置中心控比例,随时回滚」

状态

  • 已背速记
  • 能说五步迁移法
  • 能解释双写失败的补偿方案