记一次批量保存数据的优化史

一 、流程简介

数据源来源于Excel ,采用POI 解析后,保存到数据库中。
在数据导出时,会按照实体配置的模板,生成一种固定格式的Excel。例如第一行约定为实体的名称,第二行为字段名称(字段之间无固定顺序),从第三行开始就为数据行。所以导入数据的时候,也是按照此种格式进行解析文件。
执行流程

1. 结构说明

由于系统的表结构基本都是父子表结构:
在这里插入图片描述
于是导入的数据在Excel 中展示为:表示 SO_001 这个订单下了2个商品。
在这里插入图片描述

2. 优化点分析

1) 上传文件部分

优化前的处理为:java 代码读取文件流到POI 中的Workbook 中,一直到数据保存完成才将workbook 关闭。在这一过程中,程序占用内存特别大。尤其是当导入的数据量达到10w+ 以后,内存就开始飙升。

此处的优化方式是:将文件上传到 temp 目录后再拿到文件流处理,且将数据解析到Wrapper 后,就将workbook
关闭,因为此后不再需要workbook 对象。上传到 temp 目录后读取文件流,也是为了解析后可以直接关闭流。

2) 解析Excel 为Wrapper 对象

从Excel 中读取的字段名顺序不固定,读取到以后也只能以key-value 形式存储在Wrapper 中,Wrapper 的结构是:

public class FieldValue {
    private String fieldName;// 字段名
    private Object fieldValue; //字段值
   
}

class ImportDataWrapper {
  List<FieldValue> fieldEntry;
  // 一个实体类可能有多个明细:key 为明细
  Map<String,List<FieldValue>> details;
}

乍一看,可以用多线程并行解析。

3)将Wrapper 对象转换为Java对象

对象的 json 结构是:
在这里插入图片描述

此处暂时认为是可以采用多线程进行读取数据封装为实体。

4) 循环单条保存

保存前需要校验数据的合法性,唯一性,或是设置默认值等的操作,同样也可以采用多线程并行执行,可以提高效率。

5) 保存到数据库

保存数据的方式,目前由于技术选型中使用了Hibernate。如果继续使用persist() 每条数据都需要一个事务,开一次关一次,如果10W 条数据。。。 考虑使用JDBC 原生SQL 执行批量更新。没错,就是 statement.addBatch(); 一次可以等到2000条数据以后再提交。但是一拍脑袋,怎么能这样做呢,如果有明细表的时候,是需要获取到主表的id设置到明细表中保存的(例如:订单明细表需要有一个外键存储是属于哪条订单的明细)。也就是在保存明细时,就需要获取主表id。那只能在保存主表的时候返回id,然后再接着保存明细了,这才是一条完整的数据。所以,有明细的时候不能用addBatch()

决定用 DataSource getConnection();
connection.prepareStatement();
statement.executeUpdate();

二、好戏开演

按照前面分析的每个优化点,进行开发。上传文件部分如愿改好。

1. 万事开头难

前面分析的将Excel 读取到的值转换为 Wrapper 对象,从表面上来看,是可以使用多线程解析,但是当看到具体代码的时候,寸步难行。因为当含有明细行的数据的时候,无法解析。
比如上面蛋糕,笔记本的例子中,第一条数据(蛋糕)被thread-1 拿走了,需要判断,当前是否有存在SO_001 的订单已经解析出来,有的话,只需要明细行添加,否则的话需要创建一个Wrapper。第二条数据(笔记本)被 thread-2 拿走了也是做同样的判断。明细表也同样做判断,并且在导入的时候可能不止一个明细表的数据。然而线程之间执行是无序的,并且这里需要的线程之间数据共享,那就会有并发的问问题。。 试过写一段出来,解析出来的数据少很多。好吧,可能是哪里姿势不对。先处理别的优化点,后面再看性能。

2. 批量进行预保存的校验与赋值

此处采用了面向接口编程,即定义一个interface:

public interface DataPreImportSerivce<T extends Object> {

    /**
     * 预导入
     *
     * @param data
     * @return
     */
    T preImport(T data);

    /**
     * @param data       要保存的数据
     * @return
     */
    default int save(T data) {

        return 0;
    }

    /**
     * 批量保存数据
     *
     * @param datas 要保存的数据
     * @return 返回失败记录数
     */
    default int batchSave(CopyOnWriteArrayList<?> datas) {
        return 0;
    }

由于系统的数据结构分为多种,不同的种类对应有一个父类的Service,让这些父类的Service实现类实现这个 DataPreImportSerivce 接口,这样可以在具体的基类实现类中实现每种数据结构的实现方式。在进行数据校验时,所有的数据导入都需要校验的,在其他类中进行转换一下即可:(ps: 不方便写类型的地方都用Object 替换了,此处主要是设计思想)。

@Service
class BasicServiceImpl implements BasicService, DataPreImportSerivce<Object> {

    @Override
    public Object preImport(Object data) {
	   //统一校验非空字段
	}
	
}

@Service
class TreeServiceImpl implements TreeService, DataPreImportSerivce<Object> {

 // 当前Object 类型是继承了BasicServiceImpl 中的Object 才能这样使用。
    @Override
    public Object preImport(Object data) {
	   ((DataPreImportSerivce)basicService).preImport(data);
	}

@Autowired
private BasicService basicService;
	
}

在导入的时候只需要统一调用:

//javaType 为具体导入的类
 Class<?> importClass = ReflectionUtils.classForName(javaType);
 // 根据具体的类找到具体对应的 DataPreImportSerivce 实现类。
 DataPreImportSerivce preImportSerivce = mappingService.lookupService((Class<? extends Object>) importClass);
 //调用预导入
preImportSerivce.preImport(importData);

开始启用多线程,现在是准备好了所有需要导入的数据:datas

1) 贪新鲜

在系统中配置了线程池:

ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 50,
                60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(Integer.MAX_VALUE));
                
for(Object data:datas){
    executor.execute(new Runnable() {
          @Override
           public void run() {
               preImportSerivce.preImport(data);
           }
     });
}

这样写,是实现了多线程调用,数据在调用时不会受影响,但是在 preImportSerivce.preImport(data); 执行抛出异常时,线程根本没有抛出来,catch 了也没有用。

2) 改头不换面

for(Object data:datas){
    executor.submit(new Runnable() {
          @Override
           public void run() {
              try{
              	 preImportSerivce.preImport(data);
               }catch(Exception e){
               		logger.error("preImport 执行出错!",e);
				}
           }
     });
}

这样写,可以实现准确的抓获异常。但是需要统计失败了多少条,如果校验失败的数量大于0 的话就不进行保存,因为一旦开始保存,用户在下一次导入的时候需要对导入的文件进行修改,把已经导入成功的去掉,重新导入没有导入的,当数据量大的时候,这将是巨大的工作量,于是采用只有当文件中的数据全部正确的情况才导入,校验时会校验所有数据的合法性,将一次性提示到界面。
所以此时需要知道这些子线程都执行完毕后,方法才返回。但在这种写法中,线程执行过程中,主线程不会等子线程执行就返回了。

3)子线程:等等我啊

① CountDownLatch
	AtomicInteger fail = new AtomicInteger(0);
	for (Object data : datas) {
	  executePreImport(data, fail);
	}
	logger.info("失败:" + fail.get());
 

 public void executePreImport(Object data,AtomicInteger fail){
 	 CountDownLatch countDownLatch = new CountDownLatch(1);
   	 executor.submit(new Runnable() {
        @Override
        public void run() {
            try{
                preImportSerivce.preImport(data);
            }catch(Exception e){
                logger.error("preImport 执行出错!",e);
                fail.incrementAndGet();
            }finally {
                countDownLatch.countDown();
            }
        }
    });
    try {
        countDownLatch.await();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    logger.info("主线程等在这里了");
}

看似是主线程等到了子线程,但是为啥看起来这么怪怪的。执行起来也是这么慢慢的啊。。。原来是当成单线程执行了,根本不是多线程并行执行。写了那么多感觉挺费劲的。换种便利的写法吧。

② parallelStream()

改了好几次,怎么突然忘了并行流这个东西呢,既省事又省时,倒是有点担心CPU会不会爆掉。试试水先,一行代码搞定:

 datas.parallelStream().forEach(data-> preImportSerivce.preImport(data));

写完刚提交上去还在看计数的问题。测试同事就说这个性能没啥提升啊,都10几分钟过去了,5w条数据还没导入。一去看服务器CPU 和内存,占用率都相当高,有一瞬间,请求都卡了。

③ ForkJoinPool

分析上面的代码,思想是没问题,但是就是由于一下子创建了很多线程来做,导致CPU和内存都很高。那我就看看有没有类似的并行流中能限制线程大小的功能。在翻API的过程中找到一个类似的:ForkJoinPool

在这里插入图片描述
在这里插入图片描述

可以指定线程数量,这样不至于占用服务器所有的性能。

具体实现参照大佬的博客实践的,简单易懂:(良心推荐,不是广告不是托)https://blog.csdn.net/niyuelin1990/article/details/78658251

终极版代码:


public class ExcelPreImportTask extends RecursiveTask<CopyOnWriteArrayList<BaseFailCause>> {

    private CopyOnWriteArrayList<Object> importDatas;
    private Integer dataSize = null;
    private CopyOnWriteArrayList<BaseFailCause> failCauses;
    private boolean split = false;

    public boolean isSplit() {
        return split;
    }

    public void setSplit(boolean split) {
        this.split = split;
    }

    public ExcelPreImportTask(CopyOnWriteArrayList<Object> needImportDatas,
                              CopyOnWriteArrayList<BaseFailCause> failCauses) {
        this.importDatas = needImportDatas;
        this.failCauses = failCauses;
    }


    @Override
    protected CopyOnWriteArrayList<BaseFailCause> compute() { //101
        try {
            if (split) {
                for (Object importData : importDatas) {
                    try {
                        // preImport importData
                    } catch (Exception e) {
                        e.printStackTrace();
                        BaseFailCause baseFailCause = null;
                        //build exception
                        failCauses.add(baseFailCause);
                    }
                }
            } else {
                int averageSize = dataSize / 5;  
                int remain = dataSize % 5;
                CopyOnWriteArrayList<ExcelPreImportTask> subImportTasks = new CopyOnWriteArrayList<>();
                for (int i = 0; i < 5; i++) {
                    int startIndex = i * averageSize;
                    int endIndex = (i * averageSize + averageSize) > dataSize ? dataSize - 1 : i * averageSize + averageSize;
                    List<Object> partDatas = importDatas.subList(startIndex, endIndex);
                    CopyOnWriteArrayList<Object> datas = new CopyOnWriteArrayList<>();
                    datas.addAll(partDatas);

                    ExcelPreImportTask excelPreImportTask = new ExcelPreImportTask(datas, failCauses);
                    excelPreImportTask.setSplit(true);
                    subImportTasks.add(excelPreImportTask);
                }
                if (remain != 0) {
                    int startIndex = dataSize - remain;
                    List<Object> partDatas = importDatas.subList(startIndex, dataSize - 1);
                    CopyOnWriteArrayList<Object> datas = new CopyOnWriteArrayList<>();
                    datas.addAll(partDatas);
                    ExcelPreImportTask excelPreImportTask = new ExcelPreImportTask(datas, failCauses);
                    excelPreImportTask.setSplit(true);
                    subImportTasks.add(excelPreImportTask);
                }
                subImportTasks.forEach(task -> task.fork());
                subImportTasks.forEach(task -> {
                    task.join();
                });
            }
        } catch (Exception e) {
            e.printStackTrace();
            BaseFailCause baseFailCause = null;
            //build exception 用来记录失败的原因
            failCauses.add(baseFailCause);
        }
        return failCauses;
    }
}

private final int BATCH_LIMIT = 5000;

public void preImport(){
	CopyOnWriteArrayList<BaseFailCause> failCauses = new CopyOnWriteArrayList<>();
	logger.info(">>>>>>>>>>>>>>>>>>>>>> 开始执行预导入 ");
	if (datas.size() > BATCH_LIMIT) {
		ExcelPreImportTask preImportTask = new ExcelPreImportTask(datas);
		ForkJoinPool forkJoinPool = new ForkJoinPool(8);
		forkJoinPool.invoke(preImportTask);
	} else {
		for (Object importData : datas) {
			try {
				preImportSerivce.preImport(importData);
			}catch(Exception e){
				e.printStackTrace();
				failCauses.add(baseFailCause);
			}
		}
	}
	logger.info(">>>>>>>>>>>>>>>>>>>>>> 预导入执行完成 >>>>>>>>>>>>>>>>>");
   if (!CollectionUtils.isEmpty(failCauses)) {
          task.setStatus(TaskStatus.FAIL);
          failCount = totalCount;
          return;
          //如果有出错数据,就不执行保存。
     }
    logger.info(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> 开始导入数据 <<<<<<<<<<<<<<<<<<<<<<<<<");
}

3. 批量保存数据

前面说到,不能用addBatch , 那就使用单条语句执行吧: statement.executeUpdate(); 但还是最终使用多线程执行,每条数据使用一个线程保存,如果出错,则在异常中进行手动补偿回滚。就不开启事务来执行了。伪代码如下:

//用来截取数据使用,表示每个线程处理的数据量
 private final int STEP_SIZE = 2000;
  
 public int batchSave(CopyOnWriteArrayList<Object> datas) {
	int parts = datas.size() / STEP_SIZE + 1;
	List<Callable<Integer>> callables = new ArrayList<>();
	// 处理的线程数
	ExecutorService executorService = Executors.newWorkStealingPool(8);
	//总失败数量:
	int totalFailCount = 0;
	for (int i = 0; i < parts; i++) {
		int toIndex = 0;
		int startIndex = i * STEP_SIZE;
		if (startIndex + STEP_SIZE > datas.size()) {
			toIndex = datas.size();
		} else {
			toIndex = startIndex + STEP_SIZE;
		}
		//将需要插入的数据进行拆分,每个list交由一个线程来处理
		List<Object> partDatas = datas.subList(startIndex, toIndex);
		CopyOnWriteArrayList<Object> subDatas = new CopyOnWriteArrayList<>();
		subDatas.addAll(partDatas);
		callables.add(new Callable() {

			@Override
			public Object call() throws Exception {
				Connection connection = null;
				PreparedStatement statement = null;
				ResultSet resultSet;
				Integer failCount = 0;
				try {
					//get Connection
					for (Object data : subDatas) {
						String insertSql = "";//build Insert Sql
						statement = connection.prepareStatement(insertSql.toString(), Statement.RETURN_GENERATED_KEYS);
						statement.executeUpdate();
						resultSet = statement.getGeneratedKeys();
						//获取保存后的id,保存明细数据,此处省略细节...
					}
				} catch (Exception e) {
					e.printStackTrace();
				} finally {
					if (statement != null) {
						try {
							statement.close();
						} catch (SQLException e) {
							e.printStackTrace();
						}
					}
					//close Connection
				}
				return failCount;
			}
		});
	}

	try {
		List<Future<Integer>> futures = executorService.invokeAll(callables);
		for (Future<Integer> future : futures) {
			try {
				//获取每个线程执行后,失败的数量进行累计
				totalFailCount += future.get();
			} catch (ExecutionException e) {
				e.printStackTrace();
			}
		}
	} catch (InterruptedException e) {
		e.printStackTrace();
	} finally {
		executorService.shutdown();
	}

	return totalFailCount;

}

4. 历史遗留问题

在上面的优化上,基本可以满足使用。但唯一不足的地方是:当使用POI 解析数据时,如果文件大小超过20M(视机器性能而定)左右,会发生OOM 错误。大概错误为:
在这里插入图片描述
这份日志的来源是:部署在Linux 上使用打开了dump,接着上传文件,过几分钟后出现FullGC,最后就出现OOM了。x度也有很多的解决方法,这里考虑到业务场景的问题,准备采用SAX 解析数据,具体思想和 https://blog.csdn.net/weixin_42330218/article/details/81368034 类似。此处就不多阐述。

三、总结

在整个优化后的流程和优化前相比,大体上类似。但就每个细节,先分析,再逐个实践,最后得到最优的方案。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值