保证上亿级数据迁移正确的终极秘诀!

摘要:从一次"8000万数据迁移后发现丢了50万条"的严重事故出发,深度剖析大数据量迁移的完整方案。通过双写同步、数据校验、灰度切换的3阶段策略,揭秘如何在不停机的情况下完成数据迁移、如何保证新老数据一致性、以及如何快速回滚。配合时序图展示迁移流程,给出MySQL迁移MySQL、MySQL迁移ES、分库分表数据迁移等场景的实战方案。


💥 翻车现场

2025年9月,哈吉米接到了一个"重大任务"。

技术总监:"user表已经8000万数据了,需要分库分表。你来负责数据迁移,必须保证数据不丢、不错、不停机。"
哈吉米:"好的!"(内心:这么大的数据量,怎么迁移?)

一个月后,凌晨2点开始迁移。

迁移计划:
1. 停止应用(停机维护)
2. 执行数据迁移脚本
3. 校验数据
4. 启动应用

预计时间:4小时

凌晨2点,开始迁移……

# mysqldump导出
mysqldump -uroot -p db_old user > user.sql
# 耗时:2小时

# 导入到新库
mysql -uroot -p db_new_0 < user_shard_0.sql
mysql -uroot -p db_new_1 < user_shard_1.sql
...
# 耗时:3小时

# 总耗时:5小时(超时了)

凌晨7点,迁移完成,启动应用。

上午9点,客服主管炸了。

客服主管:@哈吉米 大量用户反馈登录失败!说账号不存在!
哈吉米:"不可能啊,数据都迁移了……"

紧急核对数据:

-- 旧库
SELECT COUNT(*) FROM user;
-- 结果:80000000

-- 新库(10个分片)
SELECT COUNT(*) FROM db_new_0.user;  -- 7850000
SELECT COUNT(*) FROM db_new_1.user;  -- 7920000
...
-- 总计:79500000

-- 少了50万条数据!

哈吉米(崩溃):"卧槽,丢了50万用户数据!而且停机了5小时,损失惨重!"

南北绿豆和阿西噶阿西赶来复盘。

南北绿豆:"大数据量迁移,绝对不能停机迁移,必须用在线迁移!"
阿西噶阿西:"而且要有数据校验回滚方案!"
哈吉米:"……"(欲哭无泪)


🤔 数据迁移的3大核心原则

南北绿豆在白板上写下3条原则。

原则1:不停机(在线迁移)

传统方式:
停机 → 迁移数据 → 启动

问题:
- ❌ 停机时间长(几小时)
- ❌ 影响用户(无法访问)
- ❌ 损失大(交易停止)

在线迁移:
服务不停 → 双写同步 → 逐步切换

好处:
- ✅ 零停机
- ✅ 用户无感知
- ✅ 可以灰度切换

原则2:可校验(数据一致性)

迁移后必须校验:
1. 数据量是否一致?
2. 数据内容是否一致?
3. 抽样对比(随机抽取1%数据比对)

校验不通过 → 不能切换

原则3:可回滚(快速止损)

迁移出问题时:
立即回滚到旧库

回滚时间:< 5分钟

回滚方案:
- 老库保留(不删除)
- 双写期间,老库是主库
- 切换只是改配置(秒级)

阿西噶阿西:"这3条原则是数据迁移的生命线,缺一不可!"


🎯 完整迁移方案:4个阶段

阶段1:双写阶段(1-2周)

目标:新老库同时写入,保证数据同步

@Service
public class UserService {
    
    @Autowired
    private UserMapper oldUserMapper;  // 老库
    
    @Autowired
    private UserShardingMapper newUserMapper;  // 新库(分库分表)
    
    @Autowired
    private RedisTemplate redisTemplate;
    
    /**
     * 双写:同时写老库和新库
     */
    @Transactional(rollbackFor = Exception.class)
    public void createUser(User user) {
        try {
            // 1. 写老库(主库,必须成功)
            oldUserMapper.insert(user);
            
            // 2. 写新库(异步,失败不影响业务)
            CompletableFuture.runAsync(() -> {
                try {
                    newUserMapper.insert(user);
                } catch (Exception e) {
                    log.error("新库写入失败,记录到补偿队列", e);
                    // 记录到补偿队列(后续补偿)
                    redisTemplate.opsForList().rightPush("migrate:failed", user.getId());
                }
            });
            
        } catch (Exception e) {
            log.error("老库写入失败", e);
            throw e;
        }
    }
    
    /**
     * 读取:只读老库
     */
    public User getUserById(Long userId) {
        return oldUserMapper.selectById(userId);  // 只读老库
    }
}

双写流程图

sequenceDiagram
    participant Client as 客户端
    participant App as 应用
    participant OldDB as 老库
    participant NewDB as 新库(分片)
    participant MQ as 补偿队列

    Client->>App: 1. 创建用户
    
    App->>OldDB: 2. INSERT user(同步)
    OldDB->>App: 3. 成功 ✅
    
    par 异步写新库
        App->>NewDB: 4. INSERT user(异步)
        
        alt 写入成功
            NewDB->>App: 5. 成功
        else 写入失败
            NewDB->>App: 6. 失败
            App->>MQ: 7. 记录到补偿队列
        end
    end
    
    App->>Client: 8. 返回成功
    
    Note over OldDB,NewDB: 老库是主库(权威)<br/>新库写入失败不影响业务

关键点

1. 老库同步写(必须成功)
2. 新库异步写(失败不影响业务)
3. 新库失败,记录到补偿队列
4. 定时任务从补偿队列重试

补偿任务

@Component
public class MigrateCompensationTask {
    
    @Scheduled(fixedDelay = 5000)  // 每5秒执行
    public void compensate() {
        // 从补偿队列取失败的userId
        String userId = (String) redisTemplate.opsForList().leftPop("migrate:failed");
        
        if (userId != null) {
            try {
                // 从老库查询
                User user = oldUserMapper.selectById(Long.parseLong(userId));
                
                // 重新写入新库
                newUserMapper.insert(user);
                
                log.info("补偿成功: userId={}", userId);
                
            } catch (Exception e) {
                // 补偿失败,重新放回队列
                redisTemplate.opsForList().rightPush("migrate:failed", userId);
                log.error("补偿失败: userId={}", userId, e);
            }
        }
    }
}

阶段2:历史数据迁移(1-2周)

目标:把老库的存量数据迁移到新库

@Component
public class DataMigrationTask {
    
    @Autowired
    private UserMapper oldUserMapper;
    
    @Autowired
    private UserShardingMapper newUserMapper;
    
    /**
     * 分批迁移历史数据
     */
    public void migrateHistoryData() {
        Long maxId = 0L;
        int batchSize = 1000;
        
        while (true) {
            // 1. 从老库分批查询(每次1000条)
            List<User> users = oldUserMapper.selectByIdRange(maxId, batchSize);
            
            if (users.isEmpty()) {
                break;  // 迁移完成
            }
            
            // 2. 批量写入新库
            for (User user : users) {
                try {
                    newUserMapper.insert(user);
                } catch (DuplicateKeyException e) {
                    // 已存在(双写阶段已插入),跳过
                }
            }
            
            // 3. 更新进度
            maxId = users.get(users.size() - 1).getId();
            log.info("迁移进度: id={}, 已迁移{}条", maxId, maxId);
            
            // 4. 休息100ms,避免打爆数据库
            Thread.sleep(100);
        }
        
        log.info("历史数据迁移完成");
    }
}

执行时间估算

8000万数据:
- 每批1000条
- 每批耗时:0.5秒
- 总批次:80000次
- 总耗时:80000 × 0.5秒 = 40000秒 ≈ 11小时

优化:
- 开启并行(10个线程)
- 总耗时:11小时 / 10 = 1.1小时

阶段3:数据校验(1-3天)

目标:确保新老数据一致

@Component
public class DataVerificationTask {
    
    /**
     * 校验数据量
     */
    public void verifyCount() {
        // 老库总数
        long oldCount = oldUserMapper.count();
        
        // 新库总数(所有分片)
        long newCount = 0;
        for (int i = 0; i < 10; i++) {
            newCount += newUserMapper.countByShard(i);
        }
        
        log.info("数据量对比:老库={}, 新库={}, 差异={}", 
            oldCount, newCount, oldCount - newCount);
        
        if (oldCount != newCount) {
            log.error("数据量不一致!");
        }
    }
    
    /**
     * 抽样校验数据内容
     */
    public void verifySample() {
        Random random = new Random();
        int sampleSize = 10000;  // 抽样1万条
        
        int mismatchCount = 0;
        
        for (int i = 0; i < sampleSize; i++) {
            // 随机抽取一个userId
            Long userId = random.nextLong() % 80000000;
            
            // 从老库查询
            User oldUser = oldUserMapper.selectById(userId);
            
            // 从新库查询
            User newUser = newUserMapper.selectById(userId);
            
            // 对比
            if (!Objects.equals(oldUser, newUser)) {
                mismatchCount++;
                log.error("数据不一致: userId={}, old={}, new={}", 
                    userId, oldUser, newUser);
            }
        }
        
        double errorRate = (double) mismatchCount / sampleSize * 100;
        log.info("抽样校验完成:样本={}, 不一致={}, 错误率={}%", 
            sampleSize, mismatchCount, errorRate);
        
        if (errorRate > 0.1) {
            log.error("错误率过高,不能切换!");
        }
    }
}

阶段4:灰度切换(1-2天)

目标:逐步切换流量到新库

@Service
public class UserService {
    
    @Value("${migrate.read.new.percent}")
    private int readNewPercent = 0;  // 读新库的流量百分比(灰度)
    
    /**
     * 读取用户(灰度切换)
     */
    public User getUserById(Long userId) {
        // 灰度:10%流量读新库,90%读老库
        if (ThreadLocalRandom.current().nextInt(100) < readNewPercent) {
            // 读新库
            return newUserMapper.selectById(userId);
        } else {
            // 读老库
            return oldUserMapper.selectById(userId);
        }
    }
}

灰度策略

Day 1: readNewPercent = 0   (所有流量读老库)
Day 2: readNewPercent = 1010%读新库,观察)
Day 3: readNewPercent = 3030%读新库)
Day 4: readNewPercent = 5050%读新库)
Day 5: readNewPercent = 100100%读新库,切换完成)

监控指标

切换过程中监控:
- 接口响应时间(新库 vs 老库)
- 接口错误率
- 数据一致性(定时对比)
- CPU、内存使用率

如果新库有问题:
立即回滚(readNewPercent = 0)

🎯 完整迁移时间线

阶段0:准备(1周)
├─ 搭建新库(分库分表)
├─ 部署数据校验工具
└─ 编写迁移脚本

阶段1:双写(1-2周)
├─ 上线双写代码
├─ 新数据同时写老库和新库
├─ 历史数据后台迁移
└─ 监控同步延迟

阶段2:数据校验(1-3天)
├─ 对比数据量
├─ 抽样对比数据内容
├─ 修复不一致数据
└─ 校验通过

阶段3:灰度切换(1-2天)
├─ 10%流量读新库
├─ 30%流量读新库
├─ 50%流量读新库
├─ 100%流量读新库
└─ 监控无问题

阶段4:清理(1周后)
├─ 停止双写
├─ 下线老库(归档)
└─ 迁移完成

总耗时:3-4周
停机时间:0

时序图

gantt
    title 数据迁移甘特图
    dateFormat  YYYY-MM-DD
    section 准备阶段
    搭建新库           :2024-09-01, 3d
    编写脚本           :2024-09-02, 2d
    
    section 双写阶段
    上线双写代码       :2024-09-05, 1d
    历史数据迁移       :2024-09-06, 7d
    数据补偿           :2024-09-06, 14d
    
    section 校验阶段
    数据量校验         :2024-09-13, 1d
    抽样校验           :2024-09-14, 2d
    修复不一致         :2024-09-15, 1d
    
    section 切换阶段
    10%灰度            :2024-09-17, 1d
    50%灰度            :2024-09-18, 1d
    100%切换           :2024-09-19, 1d
    
    section 清理阶段
    停止双写           :2024-09-26, 1d
    归档老库           :2024-09-27, 1d

🎯 不同场景的迁移方案

场景1:MySQL迁移到MySQL(分库分表)

迁移脚本

public class MysqlToMysqlMigration {
    
    public void migrate() {
        long startId = 0;
        int batchSize = 1000;
        
        while (true) {
            // 1. 从老库分批查询
            List<User> users = jdbcTemplate.query(
                "SELECT * FROM user WHERE id > ? LIMIT ?",
                new Object[]{startId, batchSize},
                new BeanPropertyRowMapper<>(User.class)
            );
            
            if (users.isEmpty()) break;
            
            // 2. 批量插入新库(分片)
            for (User user : users) {
                // 根据user_id路由到对应分片
                int shardIndex = (int) (user.getId() % 10);
                insertToShard(shardIndex, user);
            }
            
            startId = users.get(users.size() - 1).getId();
            log.info("迁移进度: {}", startId);
            
            Thread.sleep(100);  // 限速
        }
    }
}

场景2:MySQL迁移到ES(搜索场景)

迁移脚本

public class MysqlToEsMigration {
    
    @Autowired
    private ElasticsearchClient esClient;
    
    public void migrate() {
        long startId = 0;
        int batchSize = 1000;
        
        while (true) {
            // 1. 从MySQL查询
            List<Product> products = productMapper.selectByIdRange(startId, batchSize);
            
            if (products.isEmpty()) break;
            
            // 2. 转换成ES文档
            List<BulkOperation> operations = products.stream()
                .map(product -> BulkOperation.of(b -> b
                    .index(i -> i
                        .index("product")
                        .id(product.getId().toString())
                        .document(convertToDoc(product))
                    )
                ))
                .collect(Collectors.toList());
            
            // 3. 批量写入ES
            esClient.bulk(b -> b.operations(operations));
            
            startId = products.get(products.size() - 1).getId();
            log.info("迁移到ES: {}", startId);
            
            Thread.sleep(100);
        }
    }
}

场景3:Redis迁移(缓存预热)

public class RedisMigration {
    
    public void migrate() {
        // 方案1:全量迁移(数据量小)
        Set<String> keys = oldRedis.keys("*");
        for (String key : keys) {
            String value = oldRedis.get(key);
            Long ttl = oldRedis.ttl(key);
            
            newRedis.set(key, value);
            if (ttl > 0) {
                newRedis.expire(key, ttl, TimeUnit.SECONDS);
            }
        }
        
        // 方案2:懒加载(数据量大)
        // 不迁移,让缓存自然过期,新请求写入新Redis
    }
}

🎯 数据校验的3种方法

方法1:数据量校验

// 对比总数
long oldCount = oldUserMapper.count();
long newCount = newUserMapper.countAllShards();

if (oldCount != newCount) {
    log.error("数据量不一致:老库={}, 新库={}, 差异={}", 
        oldCount, newCount, oldCount - newCount);
}

方法2:抽样校验

// 随机抽取1%数据对比
int sampleSize = (int) (oldCount * 0.01);

for (int i = 0; i < sampleSize; i++) {
    Long userId = randomUserId();
    
    User oldUser = oldUserMapper.selectById(userId);
    User newUser = newUserMapper.selectById(userId);
    
    if (!equals(oldUser, newUser)) {
        log.error("数据不一致: userId={}", userId);
    }
}

方法3:MD5校验

// 计算每个分片的MD5
String oldMd5 = calculateMd5(oldUserMapper.selectAll());
String newMd5 = calculateMd5(newUserMapper.selectAll());

if (!oldMd5.equals(newMd5)) {
    log.error("MD5不一致");
}

🎯 快速回滚方案

回滚决策树

graph TD
    A[发现问题] --> B{数据不一致?}
    
    B -->|是| C[立即回滚]
    B -->|否| D{性能下降?}
    
    D -->|是| E{下降超过20%?}
    E -->|是| C
    E -->|否| F[继续观察]
    
    D -->|否| G{错误率上升?}
    G -->|是| C
    G -->|否| F
    
    C --> H[修改配置:readNewPercent=0]
    H --> I[所有流量切回老库]
    I --> J[回滚完成 5分钟内]
    
    style C fill:#FFB6C1
    style J fill:#90EE90

回滚步骤

// 1. 修改配置(Nacos)
migrate:
  read:
    new:
      percent: 0  # 改成0,所有流量回老库

// 2. 配置自动推送到所有服务(10秒内生效)

// 3. 监控确认(流量已切回)

// 4. 排查新库问题

// 5. 修复后,重新切换

🎓 面试标准答案

题目:如何保证大数据量迁移的正确性?

答案

4个阶段

阶段1:双写

  • 新数据同时写老库和新库
  • 老库是主库(权威)
  • 新库失败,记录补偿队列

阶段2:历史数据迁移

  • 分批迁移(每批1000条)
  • 限速(避免打爆数据库)
  • 并行迁移(提升速度)

阶段3:数据校验

  • 数据量校验(总数对比)
  • 抽样校验(随机抽取1%对比)
  • MD5校验(整体校验)

阶段4:灰度切换

  • 10% → 30% → 50% → 100%
  • 监控指标(响应时间、错误率)
  • 问题立即回滚

3大原则

  • 不停机(在线迁移)
  • 可校验(数据一致性)
  • 可回滚(快速止损)

注意事项

  • 双写期间,老库是主库
  • 新库写入失败,异步补偿
  • 定时校验数据一致性
  • 灰度切换,逐步放量
  • 问题及时回滚(5分钟内)

🎉 结束语

一个月后,哈吉米成功完成了数据迁移。

哈吉米:"这次用在线迁移,零停机,数据一条不丢!"

南北绿豆:"对,大数据量迁移必须:双写、校验、灰度、回滚,缺一不可。"

阿西噶阿西:"记住:永远不要停机迁移,永远要有回滚方案。"

哈吉米:"还有数据校验,数据量、抽样、MD5三重校验,确保万无一失!"

南北绿豆:"对,数据迁移是高风险操作,宁可慢一点,也要稳!"


记忆口诀

数据迁移四阶段,双写校验灰度切
双写同步保一致,老库主库新库从
历史数据分批迁,限速并行提效率
数据校验三重验,量抽样MD5
灰度切换逐步放,问题回滚五分钟