一、背景
- 客户系统 MySQL 数据库中有一张表,表中数据是十亿级。
- 现在客户想要把这张表迁移到 Mongodb 中。
- 客户要求不能停机,使用户进行无感切换。
二、思路
根据背景描述,可以使用双写的思路对代码进行改造。主要步骤如下:
- 先开启双写
- 双写即新表旧表同时写入数据。
- 开启之前的数据属于存量数据,之后的数据属于增量数据。
- 开启双写之后因为新表中没有数据,所以新表中对存量数据的修改和删除会报错。不需要理会直接忽略即可(或者也可以记录下来用于排查问题等),因为后面迁移存量数据时会一同迁移过来。
- 注意:此时已经开始迁移增量数据了。
- 在双写之前需要添加分布式锁,保证迁移数据过程中不会更新数据。比如:双写时先更新id=123的旧表数据,此时迁移数据开始但是没有迁移完成,同时更新新表id=123的业务开始执行,但是因为迁移没有完成导致新表中没有这条数据,导致更新失败,从而数据不一致。
- 读取旧表中的数据
- 这一步表示,展示数据的时候的业务逻辑不变,还是读取旧表中的数据。
- 存量数据迁移
- 把旧表中的所有数据通过代码的形式批量同步到新表中。因为步骤1中已经开始同步增量数据了,旧表中的增量数据也会重新同步到新表中,因此需要做唯一字段的校验如果插入新表时报主键冲突异常,可以直接忽略。或者先判断是否存在,如果存在就舍弃。
- 在迁移过程中添加分布式锁。比如迁移id=1234的数据。迁移之前先加锁,如果此时有对这条数据的更新/删除操作那么就获取不到锁,直接循环阻塞。
- 等这条数据迁移完成之后释放锁,此时更新/删除操作会获取到锁,然后执行双写。这样新表旧表中的数据理论上就可以同步。(解决了在数据迁移过程中在旧表中更新了这条数据而新表中还没有数据导致的数据不一致问题。)
- 为了保证数据不丢和能实现断点续传,可以在旧表中增加一个字段,来标识出这条记录是否已经被迁移过,这样我们就可以在一条记录迁移成功后,把他的这个标识改了,这样如果中间失败了,就可以知道哪些数据迁移过,就只迁移这些没迁移的即可。
- 读取新表中的数据
- 此时数据迁移完成,新表旧表数据已经同步,页面中的查询接口可以读取新表数据(此处建议增加一个动态开关,如果新增数据有问题可以及时回滚查旧表中的数据)。
- 可以考虑增加一个旁路验证的逻辑--在读取新表中的数据时(可以起一个异步线程,或者MQ等)进行一次旧表的读取,然后把拿到的数据与新表的读取做对比,当发现不一致的时候,报警报出来,进行人工核对。
- 全量数据核对
- 非必要。上面的步骤理论上可以保证数据一致性,如果考虑到还是有数据不一致的问题,可以增加一个全量数据核对的逻辑。通过代码一条条比对、通过工具比对等。
- 关闭双写
- 用户使用一段时间后,如果功能正常将双写关闭,只把数据写到新表中。
三、代码
这里只提供伪代码供参考
import com.liran.middle.common.base.utils.ThreadPoolUtil;
import org.apache.commons.lang3.StringUtils;
public class SmoothDataMigration {
// 数据写入类型: dualWrite-双写;newWrite-新表写入
private String writeType;
// 查询数据类型: oldTable-旧表;newTable-新表
private String getType;
/**
* 数据写入时的主业务逻辑
*/
public void mainBusiness() {
// 如果开启双写,新表中同时也要写一份数据
if ("dualWrite".equals(writeType)) {
// 如果没有获取到锁则进行阻塞,每2秒钟尝试一次一共循环10次。10次后如果还是没有获取到锁同样也执行后面的逻辑。30秒后锁过期
this.tryLock(2000, 主键id, 30, 10);
// 旧表正常增删改
oldTable.insert();
oldTable.update();
oldTable.delete();
/*这里可以使用异步的形式将数据写入新表,防止对旧表操作产生影响。异步方案如下:
1. 使用线程池做异步操作。-- 适用于可以连接到新表数据库的场景。比如新表旧表在同一个数据库中,或者可以通过多数据源连接到新表数据库。
2. 使用 MQ 做异步操作。 -- 适用于异构系统同步,下游系统可以直接监听 MQ,将数据写入到新表。还可以分担服务器压力。
3. 调用 API 接口调用。 -- 适用于异构系统同步,直接调用下游系统的 API 接口,将数据写入到新表。下游系统改造少。
*/
ThreadPoolUtil.submit(() -> {
// 未迁移数据之前,对存量数据进行更新/删除操作可能会报错,为了不影响正常的业务逻辑,可以先忽略报错。或者记录下来.
try {
newTable.insert();
newTable.update();
newTable.delete();
} catch (Exception e) {
errorTable.insert();
}
// 新表写入之后,释放锁。此时可以进行数据迁移该数据。
this.unLock(主键id);
});
} else if ("newWrite".equals(writeType)) {
// 只对新表进行操作
newTable.insert();
newTable.update();
newTable.delete();
} else {
// 只对旧表进行操作
oldTable.insert();
oldTable.update();
oldTable.delete();
}
}
/**
* 可以通过定时任务,异步进行数据迁移。
*/
public void dataMigration() {
// 如果没有获取到锁则进行阻塞,每2秒钟尝试一次一共循环10次。10次后如果还是没有获取到锁同样也执行后面的逻辑。30秒后锁过期
this.tryLock(2000, 主键id, 30, 10);
// 旧表中获取存量数据。获取的时候sql要添加order by排序,否则可能会漏掉数据。
// 为了保证数据不丢和能实现断点续传,我们可以在旧表中增加一个字段(is_migration_success),来标识出这条记录是否已经被迁移过,这样我们就可以在一条记录迁移成功后,
// 把他的这个标识改了,这样如果中间失败了,我们就知道哪些数据迁移过,哪些数据没迁移过,就只迁移这些没迁移的就行了。
Object data = oldTable.select(is_migration_success = false, order by id);
try {
// 插入新表中,此处也可以使用多种方式。比如 MQ、API接口、多数据源等
newTable.insert(data);
} catch (Exception e) {
// 如果新表中插入失败,则表示新表中已经有这个数据了。必然是增量数据,直接忽略即可。或者记录错误的数据,后续人工处理。
}
// 迁移成功之后,更新旧表中的标识。
oldTable.update(is_migration_success = true);
// 解锁
this.unLock(主键id);
}
/**
* 读取数据时路径判
*/
public void getData() {
// 数据迁移完成之后,从新表中获取数据。当关闭双写之后,就只从新表中获取数据,不能再切换为旧表。
if ("newTable".equals(getType)) {
Object newData = newTable.select();
// 开启旁路验证,每个用户在进行查询操作时会进行校验,判断新旧表中的数据是否一致,如果不一致可以通知给管理员等,进行人工排查。
if (开启旁路验证) {
// 可以使用线程池或者mq做异步操作。目的只是通知管理员排查。不影响主业务逻辑。
ThreadPoolUtil.submit(() -> {
Object oldData = oldTable.select();
if (newData != oldData) {
throw new RuntimeException("新表数据与旧表数据不一致,请排查!");
}
});
}
} else {
// 数据迁移完成之前,一直从旧表中获取数据。
oldTable.select();
}
}
//=======================以下为分布式锁工具============================================================================================
/**
* 取锁入口
*
* @param waitTime 等待时间 单位毫秒
* @param key 页面id
* @param expireTime 过期时间 单位秒
* @param maxRetryTimes 最大重试次数
* @return
*/
public boolean tryLock(long waitTime, String key, Integer expireTime, int maxRetryTimes) {
String keyName = "test:" + key;
boolean flag = false;
if (getLock(keyName, expireTime)) {
flag = true;
} else {
//重试取锁
if (retryLock(waitTime, keyName, expireTime, maxRetryTimes)) {
flag = true;
}
}
return flag;
}
/**
* 重试锁机制
* 在获取锁失败后,等待指定时间后重新尝试获取锁
*
* @param waitTime 等待时间 单位毫秒
* @param keyName 锁名称
* @param expireTime 过期时间 单位秒
* @return true 成功取得锁,false 获取锁失败
*/
private boolean retryLock(long waitTime, String keyName, Integer expireTime, int maxRetryTimes) {
//重试次数
int retryTimes = 1;
try {
while (retryTimes <= maxRetryTimes) {
//在等待指定时间后重新拿锁
Thread.sleep(waitTime);
if (getLock(keyName, expireTime)) {
return true;
}
retryTimes++;
}
return false;
} catch (Exception e) {
return false;
}
}
/**
* 获得分布式锁
*
* @param keyName 锁名称
* @param expireTime 过期时间 单位秒
* @return true 成功取得锁,false 获取锁失败
*/
public boolean getLock(String keyName, Integer expireTime) {
Boolean result = false;
try {
if (reids.tryLock(keyName, expireTime)) {
result = true;
}
} catch (Exception e) {
return false;
}
return result;
}
/**
* 解锁
*
* @param key 锁名
*/
public void unLock(String key) {
if (StringUtils.isEmpty(key)) {
return;
}
String keyName = "test:" + key;
try {
reids.unLock(keyName);
} catch (Exception e) {
logger.error("error unLock", e);
}
}
}
四、注意
- 此方案逻辑上可以保证不停机的数据迁移。
- 真实使用时要做好回滚的方案,保证旧逻辑的正确性。