千万数量级excel,如何快速导入?

104 阅读9分钟

前言

最近星球中有两位小伙伴问了我关于excel数据导入的问题:

  1. 使用Java如何处理复杂大数据量的excel导入解析和导入表。
  2. 导入千万级excel文件到数据库,有没有比较快的方法?

这两个问题有些相似之处,业务场景都是需要导入大数据量的excel文件到数据库。

今天就跟大家一起聊聊,千万级excel数据导入功能该如何实现,希望这篇文章对你会有所帮助。

更多项目实战在项目实战网:java突击队

1. 问题

对于千万级excel数据导入功能,可能会遇到一些问题,我们先聊聊会到哪些问题。

1.1 单个Sheet存不了

我们都知道excel的一个Sheet,对于2003版,最大行数是65536行,对于2007以上版本,最大行数是1048576行。

由此可见,千万级的数据是没法保存到excel的单个Sheet当中的。

那我们该怎么办呢?

1.2 出现OOM问题

即使我们想办法,把一千万的数据保存到excel当中了,但如果程序在导入数据时,将这些数据加载到内存中,内存会直接爆掉,出现OOM问题。

我们又该怎么办呢?

1.3 导入耗时太长

如果你在程序中使用单个线程,在循环中一个个将excel的数据写入到数据库,会有大量的远程IO操作,极有可能会导致该excel程序导入耗时很长。

如何优化excel的导入性能呢?

1.4 丢数据了

如果excel中有1000万的数据,但是你导入数据的数据只有995万,少了5万数据。

如何快速查询哪些数据导入失败了呢?

如何处理异常数据呢?

1.5 CPU使用率过高

如果我们将excel改成多线程导入,并且每个线程导入时间非常短,可能会导致线程之间在不停的切换上下文,可能会导致CPU使用率过高问题。

这时又该怎么办呢?

带着这些问题,开始今天的文章之旅。

2. EasyExcel

导入海量数据的Excel文件,最大的问题莫过于程序出现OOM了。

一旦出现OOM,程序将会直接挂掉。

为了防止出现OOM问题,建议使用阿里开源的excel导入导出工具:EasyExcel

EasyExcel采用流式读取方式,我们可以直接从Excel文件中进行读取数据,不需要加载整个Excel文件到内存中。

例如下面这段代码:

public void readExcel(String filePath) {
    EasyExcel.read(filePath, MySheet.class, new MySheetListener()).sheet().headRowNumber(1).doRead();
}

public class MySheetListener extends AnalysisEventListener {
    private static final int DATA_SIZE = 100000;
    private List dataList = new ArrayList<>();

    @Override
    public void invoke(MySheet data, AnalysisContext context) {
        dataList.add(data);
        if (dataList.size() >= DATA_SIZE) {
            saveData(dataList);
            dataList.clear();
        }
    }

    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {
        saveData(dataList);
    }

    private void saveData(List dataList) {
        //保存数据
    }
}

在上面的这段代码中,DATA_SIZE为100000,即每读取100000条数据,就会调用一次MySheetListener的invoke()方法,并将数据加入到dataList中,最终通过saveData()方法进行批量保存。

这样可以避免一次性将全部数据读取到内存中,降低内存使用的风险。

对于百万以内的数据,放在excel的一个Sheet中问题不大。

但如果有千万级的数据,放在一个Sheet显然是保存不下的,我们可以将数据保存到多个Sheet中,或者直接将数据保存CSV文件中。

EasyExcel3.1.1版本已支持csv读取了。

更多项目实战在项目实战网:java突击队

3. 性能问题

通过前面使用EasyExcel能够保证读取excel,不会出现OOM问题。

但可能会出现性能问题。

那么该如何解决呢?

3.1 批量保存数据

我们的程序在读取到一批excel数据之后,可能会有些业务逻辑处理,然后将数据保存到数据库当中。

你可以在一个循环中,每一条数据进行一次业务逻辑处理,然后保存数据。

例如这样的:

for(Entity entity: dataList) {
   //业务逻辑处理
   doBusiness(entity);
   //将数据保存到数据库
   save(entity);
}

如果是这样保存数据,性能可能会大打折扣,因为上面的这段代码,在for循环中一次只保存一条数据,每次都需要访问远程的数据库,影响数据写入的性能。

优化如下:

List<Entity> saveList = Lists.newArrayList();
for(Entity entity: dataList) {
   //业务逻辑处理
   doBusiness(entity);
   saveList.add(entity);
}

//将数据批量保存到数据库
batchSave(saveList);

每次对数据库操作都是批量操作,这样可以减少访问数据库的频率,从而可以显著的提升写入数据的性能。

但需要注意的是:批量写入数据时,一次性也不要写入太多的数据。比如如果一次性写入10万的数据,可能会导致写入数据库超时,或者访问该数据库的其他程序出现异常。

如果数据太多了,可以分配写入数据库。

例如:

List<Entity> saveList = Lists.newArrayList();
for(Entity entity: dataList) {
   //业务逻辑处理
   doBusiness(entity);
   saveList.add(entity);
}

//将数据批量保存到数据库
List<List<Entity>> parationList = Lists.partition(saveList,1000);
for(List<Entity> subDataList: parationList) {
   batchSave(subDataList);
}

我们可以通过Lists.partition方法将大数据saveList进行分割,每1000条记录为一组。

在这里推荐大家使用guava包下的Lists类进行集合操作,真的非常方便。

更多项目实战在项目实战网:java突击队

3.2 多线程

通过上面批量保存数据的优化,将数据的写入数据库的性能提升了一些。

但如果程序除了保存数据之外,还有一些业务逻辑,这样优化还是不够的。

如果你的程序用的是单线程,并且在一个循环中一条数据一条数据去处理业务逻辑,显然效率有点低。

你可以将代码优化成批量处理业务逻辑,但有的时候,单个处理逻辑非常复杂,改成批量处理改造成本非常大。

这时该如何优化性能呢?

答:使用多线程。

我们可以使用CompletableFuture类进行多线程调用,并且将生成的Entity对象保存到CopyOnWriteArrayList集合当中。

例如:

List<Entity> resultList = Lists.newCopyOnWriteArrayList();
CompletableFuture.allOf(dataList.stream()
   .map(data ->
       CompletableFuture.supplyAsync(() -> {
         //业务处理
         return entity;
       },
        executor)
        .whenComplete((r,ex)-> {
           resultList.add(r);
          }
        )
   ) 
  .toArray(CompletableFuture[]::new) 
).join();

最后将批量保存数据的地方改成resultList的批量保存。

其中executor是配置的线程池,注意我们在日常工作中,使用CompletableFuture的场景中,记得要配置线程池,否则每次都是new一个新的线程。

此外,如果使用多线程处理业务,可能会导致CPU使用率过高,要控制线程的数量,不能配置太多。

但线程数也不能配置太少,会影响业务处理的速度,最后可以做成配置项,可以根据项目的实际情况进行动态调整。

3.3 原始JDBC

为了方便对数据库进行操作,少写一些访问数据库的代码,通常情况下,我们在项目中会使用一些开源的ORM映射框架,比如:Mybatis或者Hibernate等。

这些ORM映射框架,虽说确实可以帮我们提升开发效率。

但它们也引入了额外的逻辑,提升了代码的复杂度,比如底层使用了反射机制,相对于直接new一个对象,它的效率更低。

使用原生JDBC进行数据库操作,确实需要多写一些代码,比如获取数据库Connection、获取PreparedStatement等。

例如:

@Resource(name="dataSource")
  private DataSource dataSource;

  /* JDBC 的批量操作 */
  @Test
  public void insertBatch()  {
      Connection connection=dataSource.getConnection();
      connection.setAutoCommit(false);
      String sql="insert into test(a,b, c, d, d, f, g, , i) " +
              "   values(?,?,?,?,?,?,?,?,?) ";

      PreparedStatement statement=connection.prepareStatement(sql);
      for (int i = 0; i < 1000 ; i++) {
          statement.setString(1, "aaa"+i);
          statement.setString(2, "bbb"+i);
          statement.setInt(3, i);
          statement.setInt(4, i);
          statement.setString(5, ""+i);
          statement.setString(6, "ddd"+i);
          statement.setString(7, "eee"+i);
          statement.setString(8, "fff"+i);
          statement.setString(9, "ggg"+i);
          statement.addBatch();
      }
      long start=System.currentTimeMillis();
      statement.executeBatch();
      connection.commit();
      connection.close();
      statement.close();
  }

但这样的批量操作效率却非常高。

4. 异常处理

在数据导入的过程中,可能会出现一些异常情况,比如:网络抖动,导致某一次写入数据失败了。

这时该如何处理呢?

答:增加失败自动重试机制

我们在批量保存数据时,可以把异常捕获一下,如果判断是数据库的某些异常,可以自动重试2次,如果有任意一次成功了,则直接返回成功,否则返回失败。

在日志中打印一条异常日志,记录批次编号,集合的第一个元素和最后一个元素的数据,方便后面可以快速定位数据。

导入成功之后,一定要检查excel中的数据总条数,跟数据库中导入成功的数据总条数是否相等。

看看有没有漏数据的情况。

对于重试之后还是没有导入成功的数据,我们可以通过日志搜索出来,分析失败的原因,是程序bug,还是数据问题。

如果是程序bug,修复了bug之后重新导入数据。

如果是数据问题,要咨询相关业务人员,是直接丢弃数据,还是修改excel重新导入。当然这时的excel不是完整的excel,而是只包含异常数据的excel。

更多项目实战在项目实战网:java突击队

5.数据重复

我们的程序在导入excel文件时,通常情况下,需要先检查一下,该excel中是否存在重复的数据。

但如果excel数据太多,没办法一次加载到内存,根本没法校验整个excel的数据的重复性,这时该怎么办呢?

我们可以校验每一批次的数据中,是否包含了重复的数据,如果有重复的数据,则直接忽略,不用写入数据库。

防止数据库中出现重复数据最有效的办法是,在表中给关键字段增加唯一索引

这时如果我们的程序在并发批量插入数据时,刚好插入了重复的数据,就会报:Duplicate entry * for key,导致程序导入失败。

其实在导入数据时,可以使用insert ignore语法,它会判断如果存在主键或者唯一索引重复的数据,则直接忽略。

这样及时excel文件不小了导入了两次,产生了并发写入的问题,也不会对程序造成太大的影响。