背景:
因爬虫数据库使用sqlserver,现有业务需要实现将sqlserver数据迁移至mysql
- 爬虫库的数据表:company_all_16 单表数据量有1200多万条
- mysql新建对应的数据表:company
流程:
详细步骤
- 定义pageNum=0,pageSize=10000用作分页查询参数
- 采用while(true){}循环插入数据,并判断如果查询列表为空就跳出循环
while (true){
if (companyList.isEmpty()) {
break;//没有更多数据,退出循环
}
}
- 在循环中采用分页的方式查询10000条sqlserver数据
- 分给多个线程进行插入,每个线程向mysql批量插入插入1000条
- 将这些线程异步提交到线程池
//while循环外部定义
List<CompletableFuture<Boolean>> completableFutureList = new ArrayList<>(500);
//while循环内部
CompletableFuture future=CompletableFuture.supplyAsync(()->{
try{
插入数据库
}catch(Exception e){
记录异常
}
},threadPoolExecutor);
completableFutureList.add(future);
- allOf()方法:将所有future加载到CompletableFuture中;whenComplete()方法:当任务完成之后再执行pageNum+1的操作;join()方法:让主线程等待异步线程执行完。
CompletableFuture.allOf(completableFutureList.toArray(new CompletableFuture[completableFutureList.size()])) .whenComplete((v, t)-> pageNum.getAndIncrement()).join();
实现:
1、多数据源配置
server:
port: 1111
servlet:
context-path: /api
spring:
application:
name: datasync
#配置数据库链接等数据源
datasource:
dynamic:
druid:
initial-size: 200
max-active: 200
min-idle: 200
max-wait: 30000
primary: business #设置默认的数据源或者数据源组,默认值即为master
strict: false #严格匹配数据源,默认false. true未匹配到指定数据源时抛异常,false使用默认数据源
datasource:
business:
url: jdbc:mysql://192.168.1.1:3306/business?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
sqlserver:
url: jdbc:sqlserver://192.168.2.2:1433;DatabaseName=business
username: sa
password: MUxaTbuQwpl
driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
web:
resources:
#add-mappings=true表示如果所有的controller都没有命中,则使用默认的静态资源处理器做匹配
add-mappings: true
mvc:
throw-exception-if-no-handler-found: true
#接入前端静态资源页面
static-path-pattern: /static/**
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
#实体扫描,多个package用逗号或者分号分隔
configuration:
#是否开启自动驼峰命名规则
map-underscore-to-camel-case: true
#MyBatis 自动映射策略,通过该配置可指定 MyBatis 是否并且如何来自动映射数据表字段与对象的属性,
auto-mapping-behavior: full
#打印SQL
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
#分页框架
pagehelper:
reasonable: true
support-methods-arguments: true
params: countSql
2、定义线程池
@Configuration
public class ThreadPoolCreator {
/**
* 核心线程数
* 如果执行CPU密集任务,则尽量不超过操作系统核数2倍
* 如果IO密集型可适当加大
* 具体设置根据压测结果决定
*/
private static int corePoolSize = Runtime.getRuntime().availableProcessors() * 3;
/**
* 最大线程数 避免内存交换 设置为核心核心线程数
*/
private static int maximumPoolSize = corePoolSize;
/**
* 最大空闲时间
*/
private static long keepAliveTime = 1;
/**
* 最大空闲时间单位
*/
private static TimeUnit unit = TimeUnit.HOURS;
/**
* 使用有界队列,避免内存溢出
*/
private static BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(500);
/**
* 默认线程工厂,建议自定义,并设置线程名称获取规则
*/
private static ThreadFactory threadFactory = Executors.defaultThreadFactory();
/**
* 拒绝策略
*/
private static RejectedExecutionHandler handler = new ThreadPoolExecutor.AbortPolicy();
@Bean
public ThreadPoolExecutor threadPoolExecutor(){
return new ThreadPoolExecutor(
corePoolSize,
maximumPoolSize,
keepAliveTime, unit,
workQueue,
threadFactory,
handler);
}
}
3、CompletableFuture实现异步任务编排
@Slf4j
@Service
public class TaskBiz {
@Resource
private CompanyAll16Mapper companyAll16Mapper;
@Resource
private SyncErrorMapper errorMapper;
@Resource
private CompanyBiz companyBiz;
@Resource
private ThreadPoolExecutor threadPoolExecutor;
//执行同步操作方法
public void sync() {
AtomicInteger pageNum = new AtomicInteger(0);
int pageSize = 10000;
while (true){
List<CompletableFuture<Boolean>> completableFutureList = new ArrayList<>(500);
//首先分页查询10000条 companyAll6数据,然后提交给线程池进行入库
PageInfo<Company> pageInfo = selectPage(pageNum.get(), pageSize);
List<Company> companyList = pageInfo.getList();
if (companyList.isEmpty()) {
break;//没有更多数据,退出循环
}
//每个线程插入1000条数据
int batchSize = 1000;
IntStream.range(0, companyList.size())
.boxed()
.collect(Collectors.groupingBy(it -> it / batchSize))
.forEach((key, indices) -> {
List<Company> batch = indices.stream().map(companyList::get).collect(Collectors.toList());
CompletableFuture<Boolean> future = CompletableFuture.supplyAsync(()->{
Boolean b=null;
try {
log.info("开始插入:pageNum=" + pageNum.get() + ",batchSize=" + batch.size() + ",批次=" + key + ",共插入数据:" + batch.size() + "条");
// 批量插入
b = companyBiz.saveBatch(batch);
log.info("插入成功:pageNum=" + pageNum.get() + ",是否插入成功=" + b + ",批次=" + key + ",共插入数据:" + batch.size() + "条");
} catch (Exception e) {
log.error("插入失败:pageNum=" + pageNum.get() + ",batchSize=" + batch.size() + ",批次=" + key + ",预计插入数据:" + batch.size() + "条");
//记录失败数据到错误表当中
SyncError syncError = new SyncError();
syncError.setPageNum(pageNum.get());
syncError.setPageSize(pageSize);
syncError.setErrorBatch(key);
syncError.setErrorCause(e.getCause().toString());
errorMapper.insert(syncError);
}
return b;
},threadPoolExecutor);
completableFutureList.add(future);
});
//将所有future加载到CompletableFuture中,whenComplete代表:当任务完成之后再执行pageNum+1的操作
CompletableFuture.allOf(completableFutureList.toArray(new CompletableFuture[completableFutureList.size()])) .whenComplete((v, t)-> pageNum.getAndIncrement()).join();
}
}
//sqlserver表的分页查询
public PageInfo<Company> selectPage(Integer pageNum, Integer pageSize) {
if (pageNum == null || pageSize == null) {
pageNum = 0;
pageSize = 10;
}
PageHelper.startPage(pageNum, pageSize);
List<Company> companyList = companyAll16Mapper.selectLimit();
PageInfo<Company> pageInfo = new PageInfo(companyList);
return pageInfo;
}
}
由于每个人的服务器配置不同,我使用的虚拟机只有2核CPU,16GB内存,经过测试发现,每分钟大约可以插入20万条数据,如果服务器的配置更好,应该会有更好的效率。
思考:
1. 为什么需要线程池并发执行?
如果不并发执行将无法充分利用操作系统的cpu资源,单线程执行较为缓慢,效率很低。
2. 使用多线程处理数据可能会带来哪些问题?
-
数据一致性问题
问题:在迁移过程中如果某个步骤失败且没有适当的错误处理和恢复机制
解决:在数据库插入时使用try Catch进行捕获,在catch中记录错误信息,将错误信息记录在错误表当中。最后根据错误信息手动补偿数据。
-
事务管理
解决:为每一个迁移任务或者一批数据使用单独的事务,即对每个线程的提交动作使用单独的事务控制。确保出现错误时能够回滚。在回滚之后可以根据错误信息手动处理错误数据。
-
线程安全问题
问题:如果多个线程访问共享资源时可能会出现线程安全问题
解决:在数据迁移的过程当中,我们是在主线程中先查询10000条分页数据,然后由主线程进行分批,每一批1000条,然后才提交给线程池做处理,因此未涉及到并发访问共享资源,不会有线程安全问题。
-
JVM内存溢出问题
问题:如果每一次分页的数据量设计的过多,每个线程处理的数据过多,则会导致JVM内存占满,出现OutOfMemory异常。
解决:设置合适的分页查询数据量,每一批次查询出10000条数据,每个线程处理1000条,mybatis-plus批量插入默认恰好也是处理1000条数据。
3. 为什么要使用CompletableFuture?Future 不行吗?
必须要说一下Future的弊端了:
- 不支持手动完成:当提交了一个数据库的插入任务,但是有可能插入的比较慢,通过其他路径已经获取到了任务结果,现在没法把这个任务通知到正在执行的线程,所以必须主动取消,或者一直等待他执行完成。
- 不支持进一步的非阻塞调用:通过Future的get()方法会一直阻塞到任务完成,但是想在获取任务之后执行额外的任务,因为Future不支持回调函数,所以无法实现这个功能。
- 不支持多个Future合并:我想在suoyoudeFuture运行完毕之后,执行某些函数,是无法通过Future实现的。
CompletableFuture的好处:
- 可以通过回调的方式处理计算结果,并且提供了转换和组合的CompletableFuture的方法。
- supplyAsync() 方法的参数是
Supplier<U>供给型接口(无参有返回值),这也是一个函数式接口,U是返回结果值的类型。当需要异步操作且关心返回结果的时候,可以使用supplyAsync()方法。 get()阻塞式获取执行结果get(long timeout, TimeUnit unit)带超时的阻塞式获取执行结果getNow(T valueIfAbsent)立刻获取执行结果join()不抛异常的阻塞时获取执行结果whenComplete等待前面任务执行完再执行当前处理allOf实现并行地执行多个任务,等待所有任务执行完成(无需考虑执行顺序),该方法可以实现并行地执行多个任务,适用于多个任务没有依赖关系,可以互相独立执行的,传入参数为多个任务,没有返回值
在本次实现当中需要使用CompletableFuture.allof().whencomplete().join();方法来等待一次分页查询的所有线程执行完之后再对pageNum进行自增操作。否则将必须使用List 对一次分页查询的所有线程进行循环遍历,然后调用Future.get()方法来进行阻塞式等待,如果第一次遍历的insert插库耗费的时间很久,而后面的insert方法已经执行完毕,那么后面的所有线程在等待的过程当中会造成CPU阻塞,而无法响应其他请求,因此会造成较为严重的cpu资源浪费。