一 、流程简介
数据源来源于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 类似。此处就不多阐述。
三、总结
在整个优化后的流程和优化前相比,大体上类似。但就每个细节,先分析,再逐个实践,最后得到最优的方案。