摘要:从一次"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 = 10 (10%读新库,观察)
Day 3: readNewPercent = 30 (30%读新库)
Day 4: readNewPercent = 50 (50%读新库)
Day 5: readNewPercent = 100 (100%读新库,切换完成)
监控指标:
切换过程中监控:
- 接口响应时间(新库 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
灰度切换逐步放,问题回滚五分钟