异步任务编排实现千万级数据同步

358 阅读7分钟

背景:

因爬虫数据库使用sqlserver,现有业务需要实现将sqlserver数据迁移至mysql

  • 爬虫库的数据表:company_all_16 单表数据量有1200多万条
  • mysql新建对应的数据表:company

流程:

详细步骤

  1. 定义pageNum=0,pageSize=10000用作分页查询参数
  2. 采用while(true){}循环插入数据,并判断如果查询列表为空就跳出循环
while (true){
    if (companyList.isEmpty()) {
        break;//没有更多数据,退出循环
    }
}
  1. 在循环中采用分页的方式查询10000条sqlserver数据
  2. 分给多个线程进行插入,每个线程向mysql批量插入插入1000条
  3. 将这些线程异步提交到线程池
//while循环外部定义
List<CompletableFuture<Boolean>> completableFutureList = new ArrayList<>(500);

//while循环内部
CompletableFuture future=CompletableFuture.supplyAsync(()->{
    try{
        插入数据库
    }catch(Exception e){
        记录异常
    }
},threadPoolExecutor);
completableFutureList.add(future);
  1. 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资源浪费。