写点什么

XLSX 海量数据复杂填充解决方案

用户头像
九叔
关注
发布于: 3 小时前
XLSX海量数据复杂填充解决方案

前言

Excel 数据导出本质上不算复杂,老牌的开源组件 JXL 开箱即用,基本能够满足大部分业务场景。但在实际的开发过程中,我们的监管合规团队所面临的主要矛盾有 2 点,首先是海量数据问题,其次是复杂的模板样式,前者可以通过引入开源的 POI 组件去解,从而避免数据转储过程中应用出现 OOM 而导致写中断;后者则可以基于衍生自 POI 的 EasyExce 组件来实现复杂模板填充;而整体的解法目前市面上则存在相应的空白。本文,笔者将会带你深入剖析 POI 和 EasyExcel 组件的核心源码,彻底弄清楚海量数据导出过程中是如何避免 OOM 异常的,并会着重讲解如何扩展 EasyExcel 组件来实现海量数据背景下的复杂模板填充方案。

为何数据导出会引发 OOM

运行期一次性导出大量的数据必然会导致应用产生 OOM 异常,那究竟是因为什么原因所引起的呢?是一次性创建过多的数据对象把内存打满了?在正式回答这个问题之前,我们先来看看一个实际的数据对象在 heap 空间中究竟会占用多大的 size。JVM 中对象的内存布局由下述 3 部分构成:

  • object header

  • markword:存储对象自身的运行期数据(比如:hashcode、GC 分代年龄、锁状态标识等);

  • klass:当前对象指向其类元数据的指针;

  • instance data:程序中所定义的各种类型的字段内容信息;

  • padding:对其填充(可能存在),JVM 要求对象大小必须是 8bytes 的倍数;


简单来说,一个对象所占用的内存大小的计算公式为:

以 64bit OS 为例,在缺省开启指针压缩的情况下,GC 会为每个对象分配 12bytes 的 heap 空间,用于存储 object header 空间中的各类数据信息,其中 klass 指针占用 4bytes,markword 占用 8bytes;假设 instance data 空间所占用的内存大小为 20bytes 时,对象大小为 32bytes,此时 padding 空间无需进行任何内存占位,反之则需要将对象大小填充至 8bytes 的倍数,如图 1-1 所示。

图1 对象内存布局

以实际情况为例,每个 ReportFormsDTO 对象(数据对象)用于记录 Excel 中的一行数据,每行则由 9 列数据构成,一个 ReportFormsDTO 对象的实际内存占用率,示例 1-1:

com.alipay.excel.ReportFormsDTO@449b2d27d object externals:          ADDRESS       SIZE TYPE                            PATH                           VALUE        76acabf08         24 java.lang.String                .paymentServiceProvider        (object)        76acabf20         32 [C                              .paymentServiceProvider.value  [P, R, O, -, 1]        76acabf40         24 java.lang.String                .terminalMerchantOrderNo       (object)        76acabf58         24 [C                              .terminalMerchantOrderNo.value [0, 0, 1]        76acabf70         24 java.lang.String                .currency                      (object)        76acabf88         24 [C                              .currency.value                [U, S, D]        76acabfa0        384 (something else)                (somewhere else)               (something else)        76acac120         24 java.lang.Double                .amount                        12.56        76acac138         24 java.lang.String                .category                      (object)        76acac150         32 [C                              .category.value                [T, Y, P, E, -, 1]        76acac170         24 java.lang.String                .flowOfGoods                   (object)        76acac188         32 [C                              .flowOfGoods.value             [F, L, O, W, -, 1]        76acac1a8       9120 (something else)                (somewhere else)               (something else)        76acae548         24 java.lang.Long                  .id                            123        76acae560         96 (something else)                (somewhere else)               (something else)        76acae5c0         24 java.lang.Long                  .date                          1631540601934        76acae5d8     442920 (something else)                (somewhere else)               (something else)        76ad1a800         24 java.lang.String                .terminalMerchantName          (object)        76ad1a818         88 [C                              .terminalMerchantName.value    [e, b, 5, b, e, e, 1, 0, -, 6, d, c, b, -, 4, 1, e, 2, -, b, 8, 4, 4, -, 5, 4, 2, a, 8, 4, 9, 8, 4, 9, 5, 1]        76ad1a870      28688 (something else)                (somewhere else)               (something else)        76ad21880         48 com.alipay.excel.ReportFormsDTO                                (object)
复制代码

如上所示,实际 ReportFormsDTO 对象的totalsize496bytes,就算直接在内存中创建 100w 个目标对象,也不过占用 473MB 左右的 heap 空间资源,以当下计算机的硬件水平完全负担得起。由此可见,数据导出时引发 OOM 的“元凶”另有其人。笔者以 POI XSSFWorkbook 为例,如图 2 所示。

图2 实时内存占用分析

基于 XSSFWorkbook 的数据导出过程中,我们 attach 到目标应用上进行观察。不难发现,heap 资源随着时间的推移正在逐步被蚕食殆尽,由于 GC 完全无法释放资源,以至于后续没有足够的可用内存空间分配给新的对象实例,最终应用不得不抛出 OOM 异常来结束这短暂而悲剧的一生。

图3 dump文件分析

如图 3 所示,通过对 dump 文件进行分析,其中 instance count 最多,以及 retained size 占用量最大的对象类型为Xobj$ElementXobjXobj$AttrXobj。简单来说,它们都是 XSSFWorkbook 在执行过程中基于 xmlbeans 组件生成的 XML DOM 节点,由于数量巨大,且全部存储在内存中,继而必然引发应用在数据导出过程中产生 OOM 异常。至于 XSSFWorkbook 为什么需要生成 DOM 树,这与 OOXML(Office Open XML)规范有关,本文就不再继续展开讨论了,感兴趣的同学可以自行查阅其它文献。

POI-SXSSF 核心源码剖析

POI-SXSSF 是 poi 在 3.8 版本之后面向开发人员提供的一种可以有效解决因大量数据导出而引发 OOM 的解决方案。之所以 POI-SXSSF 能够避免写操作产生 OOM,是因为 POI-SXSSF 并不会像 POI-XSSF 一样,将所有的 row 数据都常驻于内存中,它仅仅只会选择将很小一部分区间数据缓存在内部的 TreeMap 中,一旦TreeMap.size > window size时,poi 便会基于 FIFO 机制将 first row 数据 flush 到磁盘中的临时文件内。最后待应用层选择执行 write 操作时,poi 才会将所有的临时文件数据 merge 到目标 xlsx 文件中。如图 4 所示。

图4 POI-SXSSF整体架构

在 SXSSFWorkbook#createSheet()方法中,有 2 个主要任务,首先是为每个 sheet 创建一个 poi-sxssf-sheet.xml 临时文件;其次是设置 window size。调用 DefaultTempFileCreationStrategy#createTempFile()方法生成 sheet 临时文件,示例 1-2:

public File createTempFile(String prefix, String suffix) throws IOException {  // 创建目录,java.io.tmpdir/poifiles  createPOIFilesDirectory();
// 在java.io.tmpdir/poifiles目录下创建临时文件,poi-sxssf-sheet.xml File newFile = File.createTempFile(prefix, suffix, dir); if (System.getProperty(KEEP_FILES) == null) { newFile.deleteOnExit(); } // 返回文件句柄 return newFile;}
复制代码

如果开发人员没有显式设置 window size 的大小,缺省则为 100,示例 1-3:

public static final int DEFAULT_WINDOW_SIZE = 100;
复制代码

执行 SXSSFSheet#createRow()方法时,poi 内部会基于 TreeMap 缓存所创建的每一个 SXSSFRow 对象,然后判断 TreeMap.size 是否大于 window size,如果大于就将 first data 数据 flush 到磁盘中的临时文件内,反之继续向 TreeMap 中写入 SXSSFRow 对象,直至最终超出所设定的阈值,示例 1-4:

public SXSSFRow createRow(int rownum){  // 省略部分代码  SXSSFRow newRow=new SXSSFRow(this);  _rows.put(rownum,newRow);  allFlushed = false;  if(_randomAccessWindowSize>=0&&_rows.size()>_randomAccessWindowSize)  {    try    {      // 如果TreeMap.size > window size时则将first data flush到磁盘上      flushRows(_randomAccessWindowSize);    }    catch (IOException ioe)    {      throw new RuntimeException(ioe);    }  }  return newRow;}
复制代码

执行 SXSSFWorkbook#write()方法时,同样也有 2 个主要任务,首先是将 TreeMap 中驻留的所有 SXSSFRow 对象数据全部 flush 到 poi-sxssf-sheet.xml 文件内;待结束后再将所有的临时文件 merge 到目标 xlsx 文件中,最后删除掉所有临时文件后,即可完成一次 xlsx 数据导出操作,示例 1-5:

    public void write(OutputStream stream) throws IOException    {        // 将内存中所有数据全部flush到disc        flushSheets();
//Save the template File tmplFile = TempFile.createTempFile("poi-sxssf-template", ".xlsx"); boolean deleted; try { FileOutputStream os = new FileOutputStream(tmplFile); try { _wb.write(os); } finally { os.close(); }
//Substitute the template entries with the generated sheet data files final ZipEntrySource source = new ZipFileZipEntrySource(new ZipFile(tmplFile)); // merge所有临时文件至目标xlsx injectData(source, stream); } finally { deleted = tmplFile.delete(); } if(!deleted) { throw new IOException("Could not delete temporary file after processing: " + tmplFile); } }
复制代码

海量数据复杂填充的常规方案

我们面临的主要矛盾,首先是海量数据问题,其次是异常复杂的模板样式。easyexcel 组件扩展自 poi,并对其易用性进行了高层次的封装,使得开发人员能够快速上手。而且 easyexcel 还具备很强的复杂模板渲染能力,但如果我们加上海量数据这个前提,easyexcel 就显得无能为力。这里笔者为大家演示下我们的模板样例,如图 5 所示。

图5 复杂模板样式

如图 5 所示,待动态填充的 list 数据底部还存在一段固定的文字模板,如果直接使用 easyexcel 的进行模板填充,底部的固定文字内容在填充渲染时会被 list 数据直接覆盖掉,这显然不符合报表要求。但如果要强制保留原有的模板样式,则只能够开启强制换行操作,示例 1-6:

FillConfig.builder().forceNewRow(true).build();
复制代码

值得注意的是,一旦开启强制换行,我们又得重新面临 OOM 的问题,因为 easyexcel 底层调用的是Sheet.shiftRows()方法,poi 会选择将所有的数据全部加载进内存。迫于无奈,监管合规的同学最终选择了一种业务上有损的解决方案。简而言之,就是将 template 模板一分为二,分别拆分为 list 数据模板和底部固定文字模板;程序中首先使用 easyexcel 基于第一个模板进行 list 数据的动态填充,待填充结束后,再读取第 2 个模板的固定文字内容,并基于 poi 拷贝 row 和 cell 的 style 后,使用相同的文件句柄实现数据追加。

基于 paste 索引的优雅方案

要保证模板填充后的完整性和正确性,除了拆分 template 模板外,还有更优雅的解决方案吗?答案是肯定的,只要能够确定 template 模板中底部固定文字的起始索引,在使用 easyexcel 基于 template 模板进行 list 数据填充渲染后,就能通过 poi 去读取,并基于相同的文件句柄去完成数据和样式的追加操作。

图6 整体流程

paste 索引应该如何获取?由于 easyexcel 缺省并没有提供这样的能力,因此在实际的开发过程中,开发人员只能自行对其进行扩展。首先我们需要在 WriteSheet 类中声明变量 listStartIndex,用于定义模板 list 的起始索引位,加 1 后即可作为 paste 索引使用,示例 1-7:

public class WriteSheet extends WriteBasicParameter {    /**     * 模板list起始索引     */    public Integer listStartIndex;    // 省略setter/getter和其他代码}
复制代码

ExcelWriteFillExecutor#fill()方法中设置 listStartIndex,示例 1-8:

public void fill(Object data, FillConfig fillConfig, WriteSheet writeSheet) {        // 省略部分代码
// processing data if (realData instanceof Collection) { List<AnalysisCell> analysisCellList = readTemplateData(templateCollectionAnalysisCache); Collection collectionData = (Collection) realData; if (CollectionUtils.isEmpty(collectionData)) { return; } Iterator iterator = collectionData.iterator(); if (WriteDirectionEnum.VERTICAL.equals(fillConfig.getDirection()) && fillConfig.getForceNewRow()) { shiftRows(collectionData.size(), analysisCellList); } while (iterator.hasNext()) { if (!analysisCellList.isEmpty() && Objects.isNull(writeSheet.getListStartIndex())) { // 设置模板list起始行索引 writeSheet.setListStartIndex(analysisCellList.get(0).getRowIndex()); } doFill(analysisCellList, iterator.next(), fillConfig, getRelativeRowIndex()); } } else { doFill(readTemplateData(templateAnalysisCache), realData, fillConfig, null); } }
复制代码

在此大家需要注意,在使用 fill 进行数据填充时,如果入参是 list 数据,我们就可以待 easyexcel 解析变量{}结束后顺利从analysisCellList.get(0).getRowIndex()获取到 listStartIndex。当成功计算出 paste 索引后,接下来还有 2 件事要处理。根据 pasteIndex 利用 poi 读取 template 模板中的固定文字信息并使用相同的文件句柄追加数据,示例 1-9:

public void paste() throws Throwable {  // 判断是否需要拼接模板list底部数据  if (isForceNewRow) {    return;  }  // 获取模板list起始索引  int pasteIndex = writeSheet.getListStartIndex() + 1;  // 设置拼接索引  rowWriteHandler.setIndex(pasteIndex);  List<List<String>> datas = new ArrayList<>();  // list之后的数据就是需要继续拼接的数据  for (int i = pasteIndex; i < lastRowNum; i++) {    List<String> rd = new ArrayList<>();    List<String> finalRd = rd;    sheet.getRow(i).cellIterator().forEachRemaining(cell -> {      switch (cell.getCellTypeEnum()) {        case NUMERIC:          finalRd.add(String.valueOf(cell.getNumericCellValue()));          break;        case STRING:          finalRd.add(cell.getStringCellValue());          break;        case BLANK:          finalRd.add(null);          break;        default:          break;      }    });    // 判断是否为空行    if (rd.parallelStream().allMatch(x -> Objects.isNull(x))) {      rd = new ArrayList<>();    } else {      List<String> nrd = new ArrayList<>(rd);      for (int j = nrd.size() - 1; j >= 0; j--) {        if (Objects.isNull(nrd.get(j))) {          nrd.remove(j);        } else {          break;        }      }      rd = nrd;    }    datas.add(rd);  }  // 使用相同的文件句柄实现数据和样式的追加  writer.write(datas, writeSheet);}
复制代码

定义并重写RowWriteHandler#afterRowDispose()方法,待 row 数据追加结束时 clone 原 row 和 cell 对象的 style 至目标 xlsx 文件中,示例 1-10:

public class RowWriteHandlerImpl extends AbstractRowWriteHandler {    /**     * 拼接索引     */    private Integer pasteIndex;    private Sheet sheet;
protected RowWriteHandlerImpl(Sheet sheet) { this.sheet = sheet; }
public void setIndex(Integer pasteIndex) { this.pasteIndex = pasteIndex; }
@Override public void afterRowDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, Integer relativeRowIndex, Boolean isHead) { if (Objects.isNull(pasteIndex)) { return; } Iterator<Cell> rowIterator = row.cellIterator(); // 通过pio读取模板后,指定pasteIndex获取目标Row对象,以便于clone row和cell的style Row source = sheet.getRow(pasteIndex++); row.setHeight(source.getHeight()); while (rowIterator.hasNext()) { Cell cell = rowIterator.next(); CellStyle cellStyle = writeSheetHolder.getSheet().getWorkbook().createCellStyle(); cellStyle.cloneStyleFrom(source.getCell(cell.getColumnIndex()).getCellStyle()); cell.setCellStyle(cellStyle); } }}
复制代码

项目地址:https://github.com/gaoxianglong/easyexcel-demo/tree/master/src/main/java/com/github/xlsx/test


至此,本文内容全部结束。如果在阅读过程中有任何疑问,欢迎在评论区留言参与讨论。



推荐文章:

发布于: 3 小时前阅读数: 113
用户头像

九叔

关注

支付宝 | 高级技术专家 2020.03.25 加入

GIAC全球互联网架构大会讲师,《超大流量分布式系统架构解决方案》、《人人都是架构师》、《Java虚拟机精讲》书籍作者

评论 (1 条评论)

发布
用户头像
图6步骤1,poi是如何知道模板中固定文字的位置的呢?
2 小时前
回复
没有更多了
XLSX海量数据复杂填充解决方案