Spring Boot项目导入大量Excel数据的解决方案

需求分析

工作的时候出现一个比较特别的需求:一次性导入16万行有着17个字段的Excel表格,原本我维护的代码使用ExcelUtil实现的,但是客户使用的时候反馈:一次性导入1万条数据会直接卡住,并且数据一直增加。

image-20240219205724525

image-20240219205805853

后来查看日志、自己在Windows上操作一遍,寻找相关关键词,很可能是发生了内存溢出。

image-20240219210229928

最后也在ExcelUtil的官方文档中得到了进一步证实:

image-20240219210705904

尝试解决

流方式读取?

一开始想着用最小的改动来完成这个变态需求,后来在写的时候才发现ExcelUtil的流方式虽然能够支持大量数据的读取但是也相应地有个致命缺点:封装性比较差!很难和Web业务结合,所以我果断选择跑路。

EasyExcel!

在网上寻找大量数据导入Excel的方法中,发现了一个神奇宝贝:EasyExcel

什么是Easy Excel?

EasyExcel是一个基于Java的、快速、简洁、解决大文件内存溢出的Excel处理工具。
他能让你在不用考虑性能、内存的等因素的情况下,快速完成Excel的读、写等功能。

不懂?

Java解析、生成Excel比较有名的框架有Apache poi、jxl。但他们都存在一个严重的问题就是非常的耗内存,poi有一套SAX模式的API可以一定程度的解决一些内存溢出的问题,但POI还是有一些缺陷,比如07版Excel解压缩以及解压后存储都是在内存中完成的,内存消耗依然很大。
easyexcel重写了poi对07版Excel的解析,一个3M的excel用POI sax解析依然需要100M左右内存,改用easyexcel可以降低到几M,并且再大的excel也不会出现内存溢出;03版依赖POI的sax模式,在上层做了模型转换的封装,让使用者更加简单方便

怎么用?

说句实话,看官方文档的例子我苦恼了很久

导入的逻辑都是在Listener中实现的,关于导入的结果应该怎么体现呢,这让我挺困惑的,搜网一圈之后发现一篇类似的issue:

导入的代码业务逻辑都在Listener中实现的,如果想返回成功多少失败多少条,应该怎么返回?

  1. 自行在构造器传入一些变量来满足业务
  2. 自行插入些成员变量来获得导入情况

根据这个思路,我写了这个Listener



@Slf4j
public class DataListener extends AnalysisEventListener<ImportParam> {

    /**
     *  每隔100条存储数据库,然后清理list,方便内存回收
    */
    private static final int BATCH_COUNT = 100;
    private List<Temp> list = new ArrayList<>();
    // 记录成功条数
    @Getter
    private int successCount;
    // 记录错误信息
    @Getter
    private List<String> errorCount = new ArrayList<>();
    private XXXService xxxService;
    private HttpServletRequest request; // 根据自己业务需求传
    public DataListener(XXXService xxxService, HttpServletRequest request) {
        this.xxxService = xxxService;
        this.request = request;
    }
    @Override
    public void invoke(ImportParam tempValue, AnalysisContext context ) {
        // TODO
        // 导入数据字段检验
        if (tempValue.getName() == null || Tools.isEmpty(tempValue.getName().toString()) || Tools.isEmpty(tempValue.getName().toString())) {
            errorCount.add("导入数据必填项为空");
            return ;
        }
        ...
        
        // TODO
        // 这里模拟ImportParam二次封装成多个不同对象的过程,Service可能操作多个表
		tempValue...>add
        log.info("准备导入{}的数据:data = {}",add.getDataA().getName(), add.getDataA());
        list.add(add);

        if (list.size() >= BATCH_COUNT) {
            saveData(request, 其他参数);
            list.clear();
            this.successCount+=100;
        }
    }

    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {
        // TODO
        // 对剩下不足100的数据进行处理
        ...
        saveData(phoneArray, list, adminId, request);
        // 记录成功导入的数量
        this.successCount += list.size();
    }

    public void saveData(HttpServletRequest request, 其他参数){
        try {
            Map<String, String> result = xxxService.daoforeachlist(其他参数, request);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    };
}

控制层的写法:

	@ApiOperation(value = "Excel导入")
    @PostMapping(value = "/import")
    public Result api_import_client(MultipartFile file, HttpServletRequest request)throws XXXBusinessException,Exception{
        try {
            if (EmptyUtils.isEmpty(file)) {
                return new Result("0", "请传入文件");
            }
            
            init(request);
            InputStream inputStream = file.getInputStream();
            
            // 构建监听器实例,传入EasyExcel、文件输入流导入数据
            DataListener dataListener = new DataListener(clientService, operatorAdminId, adminId, request);
            EasyExcel.read(inputStream, ImportParam.class, dataListener).sheet().doRead();
            
            // 通过监听器实例获取状态
            List<String> errorCount = dataListener.getErrorCount();
            int successCount = dataListener.getSuccessCount();
            
            // 返回导入结果
            if (errorCount.isEmpty()) {
                return new Result<>("1", "成功操作了"+successCount+"条记录");
            } else {
                log.error("出错记录:\n" + errorCount.toString());
                return new Result("0", "成功操作了"+successCount+"条记录,"+errorCount.size()+"条记录出错");
            }
            
        } catch (Exception e) {
            e.printStackTrace(); return new Result("0", "系统出错,操作失败");
        }
    }

EasyExcel 怎么做到“再大的excel也不会出现内存溢出”的呢?

我的理解是:

EasyExcel 的设计挺巧妙的,把Listener设计成自己执行,也可以自行在构造器传入一些变量来满足业务,最重要的是可以自定义BATCH_COUNT,通过每次循环中判断列表数据是否超过了BATCH_COUNT来控制list不会过大,list不会过大,一点一点存,自然不会有内存溢出的问题了,“再大的excel也就不会出现内存溢出”~只是相当于也许会多花点时间(实际上EasyExcel比ExcelUtil快)